fix koboldai patches for release 1.99, fix update dialog from not triggering, speed up downloads (woah) which we accidentally slowed down, update advanced command modal for 1.99

This commit is contained in:
lone-cloud 2025-09-21 02:40:58 -07:00
parent 29d55bc387
commit 8ddc952079
15 changed files with 582 additions and 434 deletions

View file

@ -179,9 +179,7 @@ You can use the CLI mode on Windows in exactly the same way as in the Linux/macO
## Known Issues ## Known Issues
- automatic GPU layer detection does not currently work consistently accross all backends and platforms. It's advised to set this configuration manually if you are told in the launch terminal that your VRAM could not be detected. It should work correctly on all platforms after 1.99.0 of KoboldCpp gets released except for the Windows ROCm binary which was not written well.
- Windows ROCm support is... problematic and currently requires for the user to manually add the installed ROCm bin directory to the system PATH. In particular "hipInfo.exe" must be present, which is not always the case for older verions of ROCm. - Windows ROCm support is... problematic and currently requires for the user to manually add the installed ROCm bin directory to the system PATH. In particular "hipInfo.exe" must be present, which is not always the case for older verions of ROCm.
- not specific to Gerbil, but the open sourced AMD GPU drivers for Linux likely won't work well for image gen. They are not throttled, unlike the proprietary AMD drivers, and are likely to result in system instability under heavy load, especially when running with the larger models. You will probably need to manually tweak your system, but dual-booting to Window is likely the easiest approach if you're experiencing crashes. User beware.
## Future Considerations ## Future Considerations

View file

@ -1,7 +1,7 @@
{ {
"name": "gerbil", "name": "gerbil",
"productName": "Gerbil", "productName": "Gerbil",
"version": "1.4.2", "version": "1.4.3",
"description": "Run Large Language Models locally", "description": "Run Large Language Models locally",
"main": "out/main/index.js", "main": "out/main/index.js",
"homepage": "./", "homepage": "./",

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 { useKoboldVersions } from '@/hooks/useKoboldVersions'; import { useKoboldVersionsStore } from '@/stores/koboldVersions';
import { getDisplayNameFromPath } from '@/utils/version'; import { getDisplayNameFromPath } from '@/utils/version';
import { GITHUB_API } from '@/constants'; import { GITHUB_API } from '@/constants';
import { safeExecute } from '@/utils/logger'; import { safeExecute } from '@/utils/logger';
@ -21,6 +21,7 @@ import { Modal } from '@/components/Modal';
interface UpdateAvailableModalProps { interface UpdateAvailableModalProps {
opened: boolean; opened: boolean;
onClose: () => void; onClose: () => void;
onSkip: () => void;
updateInfo?: BinaryUpdateInfo; updateInfo?: BinaryUpdateInfo;
onUpdate: (download: DownloadItem) => Promise<void>; onUpdate: (download: DownloadItem) => Promise<void>;
} }
@ -28,10 +29,11 @@ interface UpdateAvailableModalProps {
export const UpdateAvailableModal = ({ export const UpdateAvailableModal = ({
opened, opened,
onClose, onClose,
onSkip,
updateInfo, updateInfo,
onUpdate, onUpdate,
}: UpdateAvailableModalProps) => { }: UpdateAvailableModalProps) => {
const { downloading, downloadProgress } = useKoboldVersions(); const { downloading, downloadProgress } = useKoboldVersionsStore();
const currentVersion = updateInfo?.currentVersion; const currentVersion = updateInfo?.currentVersion;
const availableUpdate = updateInfo?.availableUpdate; const availableUpdate = updateInfo?.availableUpdate;
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
@ -141,7 +143,7 @@ export const UpdateAvailableModal = ({
<Group justify="flex-end" gap="sm"> <Group justify="flex-end" gap="sm">
<Button <Button
variant="outline" variant="outline"
onClick={onClose} onClick={onSkip}
disabled={isDownloading || isUpdating} disabled={isDownloading || isUpdating}
leftSection={<X size={16} />} leftSection={<X size={16} />}
> >

View file

@ -14,7 +14,7 @@ import { StatusBar } from '@/components/App/StatusBar';
import { ErrorBoundary } from '@/components/App/ErrorBoundary'; import { ErrorBoundary } from '@/components/App/ErrorBoundary';
import { AppRouter } from '@/components/App/Router'; import { AppRouter } from '@/components/App/Router';
import { useUpdateChecker } from '@/hooks/useUpdateChecker'; import { useUpdateChecker } from '@/hooks/useUpdateChecker';
import { useKoboldVersions } from '@/hooks/useKoboldVersions'; import { useKoboldVersionsStore } from '@/stores/koboldVersions';
import { usePreferencesStore } from '@/stores/preferences'; import { usePreferencesStore } from '@/stores/preferences';
import { STATUSBAR_HEIGHT, TITLEBAR_HEIGHT } from '@/constants'; import { STATUSBAR_HEIGHT, TITLEBAR_HEIGHT } from '@/constants';
import type { DownloadItem } from '@/types/electron'; import type { DownloadItem } from '@/types/electron';
@ -39,10 +39,11 @@ export const App = () => {
updateInfo: binaryUpdateInfo, updateInfo: binaryUpdateInfo,
showUpdateModal, showUpdateModal,
checkForUpdates, checkForUpdates,
dismissUpdate, skipUpdate,
closeModal,
} = useUpdateChecker(); } = useUpdateChecker();
const { handleDownload: sharedHandleDownload } = useKoboldVersions(); const { handleDownload, loadingRemote } = useKoboldVersionsStore();
useEffect(() => { useEffect(() => {
const checkInstallation = async () => { const checkInstallation = async () => {
@ -52,20 +53,28 @@ export const App = () => {
]); ]);
determineScreen(currentVersion, hasSeenWelcome); determineScreen(currentVersion, hasSeenWelcome);
if (currentVersion) {
setTimeout(() => {
checkForUpdates();
}, 2000);
}
setHasInitialized(true); setHasInitialized(true);
}; };
checkInstallation(); checkInstallation();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEffect(() => {
const runUpdateCheck = async () => {
if (loadingRemote || !hasInitialized) return;
const currentVersion =
await window.electronAPI.kobold.getCurrentVersion();
if (currentVersion) {
setTimeout(() => {
checkForUpdates();
}, 1000);
}
};
runUpdateCheck();
}, [loadingRemote, hasInitialized, checkForUpdates]);
const determineScreen = ( const determineScreen = (
currentVersion: unknown, currentVersion: unknown,
hasSeenWelcome: boolean hasSeenWelcome: boolean
@ -80,14 +89,14 @@ export const App = () => {
}; };
const handleBinaryUpdate = async (download: DownloadItem) => { const handleBinaryUpdate = async (download: DownloadItem) => {
const success = await sharedHandleDownload({ const success = await handleDownload({
item: download, item: download,
isUpdate: true, isUpdate: true,
wasCurrentBinary: true, wasCurrentBinary: true,
}); });
if (success) { if (success) {
dismissUpdate(); closeModal();
} }
}; };
@ -181,7 +190,8 @@ export const App = () => {
<UpdateAvailableModal <UpdateAvailableModal
opened={showUpdateModal && !!binaryUpdateInfo} opened={showUpdateModal && !!binaryUpdateInfo}
onClose={dismissUpdate} onClose={closeModal}
onSkip={skipUpdate}
updateInfo={binaryUpdateInfo || undefined} updateInfo={binaryUpdateInfo || undefined}
onUpdate={handleBinaryUpdate} onUpdate={handleBinaryUpdate}
/> />

View file

@ -4,7 +4,7 @@ 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 { useKoboldVersions } from '@/hooks/useKoboldVersions'; import { useKoboldVersionsStore } from '@/stores/koboldVersions';
import type { DownloadItem } from '@/types/electron'; import type { DownloadItem } from '@/types/electron';
interface DownloadScreenProps { interface DownloadScreenProps {
@ -19,8 +19,8 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
loadingRemote, loadingRemote,
downloading, downloading,
downloadProgress, downloadProgress,
handleDownload: sharedHandleDownload, handleDownload: handleDownloadFromStore,
} = useKoboldVersions(); } = useKoboldVersionsStore();
const [downloadingAsset, setDownloadingAsset] = useState<string | null>(null); const [downloadingAsset, setDownloadingAsset] = useState<string | null>(null);
const downloadingItemRef = useRef<HTMLDivElement>(null); const downloadingItemRef = useRef<HTMLDivElement>(null);
@ -31,7 +31,7 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
async (download: DownloadItem) => { async (download: DownloadItem) => {
setDownloadingAsset(download.name); setDownloadingAsset(download.name);
const success = await sharedHandleDownload({ const success = await handleDownloadFromStore({
item: download, item: download,
isUpdate: false, isUpdate: false,
wasCurrentBinary: false, wasCurrentBinary: false,
@ -47,7 +47,7 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
setDownloadingAsset(null); setDownloadingAsset(null);
}, },
[sharedHandleDownload, onDownloadComplete] [handleDownloadFromStore, onDownloadComplete]
); );
useEffect(() => { useEffect(() => {

View file

@ -5,6 +5,7 @@ import {
TextInput, TextInput,
NumberInput, NumberInput,
Button, Button,
SimpleGrid,
} from '@mantine/core'; } from '@mantine/core';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { InfoTooltip } from '@/components/InfoTooltip'; import { InfoTooltip } from '@/components/InfoTooltip';
@ -75,8 +76,7 @@ export const AdvancedTab = () => {
return ( return (
<Stack gap="md"> <Stack gap="md">
<div> <div>
<Stack gap="md"> <SimpleGrid cols={3} spacing="lg" verticalSpacing="md">
<Group gap="lg" align="flex-start" wrap="nowrap">
<CheckboxWithTooltip <CheckboxWithTooltip
checked={!noshift} checked={!noshift}
onChange={(checked) => handleNoshiftChange(!checked)} onChange={(checked) => handleNoshiftChange(!checked)}
@ -90,51 +90,7 @@ export const AdvancedTab = () => {
label="No Shift" label="No Shift"
tooltip="Don't use GPU layer shifting for incomplete offloads, which may reduce model performance." tooltip="Don't use GPU layer shifting for incomplete offloads, which may reduce model performance."
/> />
</Group>
<Group gap="lg" align="flex-start" wrap="nowrap">
<CheckboxWithTooltip
checked={flashattention}
onChange={handleFlashattentionChange}
label="Flash Attention"
tooltip="Enable flash attention to reduce memory usage. May produce incorrect answers for some prompts, but improves performance."
/>
<CheckboxWithTooltip
checked={usemmap}
onChange={handleUsemmapChange}
label="MMAP"
tooltip="Use MMAP to load models when enabled."
/>
</Group>
<Group gap="lg" align="flex-start" wrap="nowrap">
<CheckboxWithTooltip
checked={quantmatmul && isGpuBackend}
onChange={handleQuantmatmulChange}
label="QuantMatMul"
tooltip={
!isGpuBackend
? 'QuantMatMul is only available for CUDA and ROCm backends.'
: 'Enable MMQ mode to use finetuned kernels instead of default CuBLAS/HipBLAS for prompt processing.'
}
disabled={!isGpuBackend}
/>
<CheckboxWithTooltip
checked={lowvram && isGpuBackend}
onChange={handleLowvramChange}
label="Low VRAM"
tooltip={
!isGpuBackend
? 'Low VRAM mode is only available for CUDA and ROCm backends.'
: 'Avoid offloading KV Cache or scratch buffers to VRAM. Allows more layers to fit, but may result in a speed loss.'
}
disabled={!isGpuBackend}
/>
</Group>
<Group gap="lg" align="flex-start" wrap="nowrap">
<CheckboxWithTooltip <CheckboxWithTooltip
checked={noavx2} checked={noavx2}
onChange={handleNoavx2Change} onChange={handleNoavx2Change}
@ -147,6 +103,25 @@ export const AdvancedTab = () => {
disabled={isLoading || !backendSupport?.noavx2} disabled={isLoading || !backendSupport?.noavx2}
/> />
<CheckboxWithTooltip
checked={usemmap}
onChange={handleUsemmapChange}
label="MMAP"
tooltip="Use MMAP to load models when enabled."
/>
<CheckboxWithTooltip
checked={quantmatmul && isGpuBackend}
onChange={handleQuantmatmulChange}
label="QuantMatMul"
tooltip={
!isGpuBackend
? 'QuantMatMul is only available for CUDA and ROCm backends.'
: 'Enable MMQ mode to use finetuned kernels instead of default CuBLAS/HipBLAS for prompt processing.'
}
disabled={!isGpuBackend}
/>
<CheckboxWithTooltip <CheckboxWithTooltip
checked={failsafe} checked={failsafe}
onChange={handleFailsafeChange} onChange={handleFailsafeChange}
@ -158,8 +133,26 @@ export const AdvancedTab = () => {
} }
disabled={isLoading || !backendSupport?.failsafe} disabled={isLoading || !backendSupport?.failsafe}
/> />
</Group>
</Stack> <CheckboxWithTooltip
checked={flashattention}
onChange={handleFlashattentionChange}
label="Flash Attention"
tooltip="Enable flash attention to reduce memory usage. May produce incorrect answers for some prompts, but improves performance."
/>
<CheckboxWithTooltip
checked={lowvram && isGpuBackend}
onChange={handleLowvramChange}
label="Low VRAM"
tooltip={
!isGpuBackend
? 'Low VRAM mode is only available for CUDA and ROCm backends.'
: 'Avoid offloading KV Cache or scratch buffers to VRAM. Allows more layers to fit, but may result in a speed loss.'
}
disabled={!isGpuBackend}
/>
</SimpleGrid>
</div> </div>
<div> <div>

View file

@ -60,6 +60,7 @@ const UI_COVERED_ARGS = new Set([
'--sdlora', '--sdlora',
'--sdflashattention', '--sdflashattention',
'--sdconvdirect', '--sdconvdirect',
'--tensor_split',
] as const) as ReadonlySet<string>; ] as const) as ReadonlySet<string>;
const IGNORED_ARGS = new Set([ const IGNORED_ARGS = new Set([
@ -67,6 +68,13 @@ const IGNORED_ARGS = new Set([
'--version', '--version',
'--launch', '--launch',
'--config', '--config',
'--showgui',
'--skiplauncher',
'--unpack',
'--exportconfig',
'--exporttemplate',
'--nomodel',
'--singleinstance',
] as const) as ReadonlySet<string>; ] as const) as ReadonlySet<string>;
const COMMAND_LINE_ARGUMENTS = [ const COMMAND_LINE_ARGUMENTS = [
@ -241,7 +249,7 @@ const COMMAND_LINE_ARGUMENTS = [
{ {
flag: '--cli', flag: '--cli',
description: description:
'Does not launch the HTTP server. Instead, enables input from the command line, accepting interactive console input and displaying responses to the terminal.', 'Does not launch KoboldCpp HTTP server. Instead, enables KoboldCpp from the command line, accepting interactive console input and displaying responses to the terminal.',
type: 'boolean', type: 'boolean',
category: 'Advanced', category: 'Advanced',
}, },
@ -347,7 +355,7 @@ const COMMAND_LINE_ARGUMENTS = [
description: 'How many tokens to draft per chunk before verifying results', description: 'How many tokens to draft per chunk before verifying results',
metavar: '[tokens]', metavar: '[tokens]',
type: 'int', type: 'int',
default: 16, default: 8,
category: 'Speculative Decoding', category: 'Speculative Decoding',
}, },
{ {
@ -360,6 +368,14 @@ const COMMAND_LINE_ARGUMENTS = [
default: 999, default: 999,
category: 'Speculative Decoding', category: 'Speculative Decoding',
}, },
{
flag: '--draftgpusplit',
description:
'GPU layer distribution ratio for draft model (default=same as main). Only works if multi-GPUs selected for MAIN model and tensor_split is set!',
metavar: '[Ratios]',
type: 'float[]',
category: 'Speculative Decoding',
},
{ {
flag: '--quantkv', flag: '--quantkv',
description: description:
@ -392,6 +408,114 @@ const COMMAND_LINE_ARGUMENTS = [
type: 'boolean', type: 'boolean',
category: 'Performance', category: 'Performance',
}, },
{
flag: '--ratelimit',
description:
'If enabled, rate limit generative request by IP address. Each IP can only send a new request once per X seconds.',
metavar: '[seconds]',
type: 'int',
default: 0,
category: 'Advanced',
},
{
flag: '--ignoremissing',
description:
'Ignores all missing non-essential files, just skipping them instead.',
type: 'boolean',
category: 'Advanced',
},
{
flag: '--chatcompletionsadapter',
description:
'Select an optional ChatCompletions Adapter JSON file to force custom instruct tags.',
metavar: '[filename]',
default: 'AutoGuess',
category: 'Advanced',
},
{
flag: '--forceversion',
description:
'If the model file format detection fails (e.g. rogue modified model) you can set this to override the detected format (enter desired version, e.g. 401 for GPTNeoX-Type2).',
metavar: '[version]',
type: 'int',
default: 0,
category: 'Advanced',
},
{
flag: '--smartcontext',
description:
'Reserving a portion of context to try processing less frequently. Outdated. Not recommended.',
type: 'boolean',
category: 'Advanced',
},
{
flag: '--unpack',
description:
'Extracts the file contents of the KoboldCpp binary into a target directory.',
metavar: 'destination',
type: 'string',
default: '',
category: 'Advanced',
},
{
flag: '--exportconfig',
description:
'Exports the current selected arguments as a .kcpps settings file',
metavar: '[filename]',
type: 'string',
default: '',
category: 'Advanced',
},
{
flag: '--exporttemplate',
description:
'Exports the current selected arguments as a .kcppt template file',
metavar: '[filename]',
type: 'string',
default: '',
category: 'Advanced',
},
{
flag: '--nomodel',
description:
'Allows you to launch the GUI alone, without selecting any model.',
type: 'boolean',
category: 'Advanced',
},
{
flag: '--singleinstance',
description:
'Allows this KoboldCpp instance to be shut down by any new instance requesting the same port, preventing duplicate servers from clashing on a port.',
type: 'boolean',
category: 'Advanced',
},
{
flag: '--maxrequestsize',
description:
'Specify a max request payload size. Any requests to the server larger than this size will be dropped. Do not change if unsure.',
metavar: '[size in MB]',
type: 'int',
default: 32,
category: 'Advanced',
},
{
flag: '--overridekv',
aliases: ['--override-kv'],
description:
'Advanced option to override a metadata by key, same as in llama.cpp. Mainly for debugging, not intended for general use. Types: int, float, bool, str',
metavar: '[name=type:value]',
default: '',
category: 'Advanced',
},
{
flag: '--overridetensors',
aliases: ['--override-tensor', '-ot'],
description:
'Advanced option to override tensor backend selection, same as in llama.cpp.',
metavar: '[tensor name pattern=buffer type]',
default: '',
category: 'Advanced',
},
{ {
flag: '--admin', flag: '--admin',
description: description:
@ -436,6 +560,24 @@ const COMMAND_LINE_ARGUMENTS = [
default: '', default: '',
category: 'Horde Worker', category: 'Horde Worker',
}, },
{
flag: '--hordemaxctx',
description:
'Sets the maximum context length your worker will accept from an AI Horde job. If 0, matches main context limit.',
metavar: '[amount]',
type: 'int',
default: 0,
category: 'Horde Worker',
},
{
flag: '--hordegenlen',
description:
'Sets the maximum number of tokens your worker will generate from an AI horde job.',
metavar: '[amount]',
type: 'int',
default: 0,
category: 'Horde Worker',
},
{ {
flag: '--sdthreads', flag: '--sdthreads',
description: description:
@ -455,6 +597,48 @@ const COMMAND_LINE_ARGUMENTS = [
default: 0, default: 0,
category: 'Image Generation', category: 'Image Generation',
}, },
{
flag: '--sdclamped',
description:
'If specified, limit generation steps and image size for shared use. Accepts an extra optional parameter that indicates maximum resolution (eg. 768 clamps to 768x768, min 512px, disabled if 0).',
metavar: '[maxres]',
type: 'int',
default: 0,
category: 'Image Generation',
},
{
flag: '--sdclampedsoft',
description:
'If specified, limit max image size to curb memory usage. Similar to --sdclamped, but less strict, allows trade-offs between width and height (e.g. 640 would allow 640x640, 512x768 and 768x512 images). Total resolution cannot exceed 1MP.',
metavar: '[maxres]',
type: 'int',
default: 0,
category: 'Image Generation',
},
{
flag: '--sdvaeauto',
description:
'Uses a built-in VAE via TAE SD, which is very fast, and fixed bad VAEs.',
type: 'boolean',
category: 'Image Generation',
},
{
flag: '--sdloramult',
description: 'Multiplier for the image LORA model to be applied.',
metavar: '[amount]',
type: 'float',
default: 1.0,
category: 'Image Generation',
},
{
flag: '--sdtiledvae',
description:
'Adjust the automatic VAE tiling trigger for images above this size. 0 disables vae tiling.',
metavar: '[maxres]',
type: 'int',
default: 768,
category: 'Image Generation',
},
{ {
flag: '--whispermodel', flag: '--whispermodel',
description: description:
@ -476,6 +660,29 @@ const COMMAND_LINE_ARGUMENTS = [
type: 'boolean', type: 'boolean',
category: 'Audio', category: 'Audio',
}, },
{
flag: '--ttswavtokenizer',
description: 'Specify the WavTokenizer GGUF model.',
metavar: '[filename]',
default: '',
category: 'Audio',
},
{
flag: '--ttsmaxlen',
description: 'Limit number of audio tokens generated with TTS.',
type: 'int',
default: 4096,
category: 'Audio',
},
{
flag: '--ttsthreads',
description:
'Use a different number of threads for TTS if specified. Otherwise, has the same value as --threads.',
metavar: '[threads]',
type: 'int',
default: 0,
category: 'Audio',
},
{ {
flag: '--embeddingsmodel', flag: '--embeddingsmodel',
description: description:
@ -491,6 +698,15 @@ const COMMAND_LINE_ARGUMENTS = [
type: 'boolean', type: 'boolean',
category: 'Embeddings', category: 'Embeddings',
}, },
{
flag: '--embeddingsmaxctx',
description:
'Overrides the default maximum supported context of an embeddings model (defaults to trained context).',
metavar: '[amount]',
type: 'int',
default: 0,
category: 'Embeddings',
},
] as const; ] as const;
const AVAILABLE_ARGUMENTS = COMMAND_LINE_ARGUMENTS.filter( const AVAILABLE_ARGUMENTS = COMMAND_LINE_ARGUMENTS.filter(

View file

@ -181,14 +181,7 @@ export const GeneralTab = ({
} }
}; };
const handleFrontendPreferenceChange = async (value: string | null) => { const handleFrontendPreferenceChange = (value: string | null) => {
if (
!value ||
!['koboldcpp', 'sillytavern', 'openwebui', 'comfyui'].includes(value)
)
return;
await checkAllFrontendRequirements();
setFrontendPreference(value as FrontendPreference); setFrontendPreference(value as FrontendPreference);
}; };
@ -268,7 +261,6 @@ export const GeneralTab = ({
value={frontendPreference} value={frontendPreference}
onChange={handleFrontendPreferenceChange} onChange={handleFrontendPreferenceChange}
disabled={isOnInterfaceScreen} disabled={isOnInterfaceScreen}
onClick={() => checkAllFrontendRequirements()}
data={frontendConfigs.map((config) => ({ data={frontendConfigs.map((config) => ({
value: config.value, value: config.value,
label: config.label, label: config.label,

View file

@ -18,7 +18,7 @@ import {
} from '@/utils/version'; } from '@/utils/version';
import { formatDownloadSize } from '@/utils/format'; import { formatDownloadSize } from '@/utils/format';
import { useKoboldVersions } from '@/hooks/useKoboldVersions'; import { useKoboldVersionsStore } from '@/stores/koboldVersions';
import type { InstalledVersion, ReleaseWithStatus } from '@/types/electron'; import type { InstalledVersion, ReleaseWithStatus } from '@/types/electron';
interface VersionInfo { interface VersionInfo {
@ -40,9 +40,9 @@ export const VersionsTab = () => {
loadingRemote, loadingRemote,
downloading, downloading,
downloadProgress, downloadProgress,
handleDownload: sharedHandleDownload, handleDownload: handleDownloadFromStore,
getLatestReleaseWithDownloadStatus, getLatestReleaseWithDownloadStatus,
} = useKoboldVersions(); } = useKoboldVersionsStore();
const [installedVersions, setInstalledVersions] = useState< const [installedVersions, setInstalledVersions] = useState<
InstalledVersion[] InstalledVersion[]
@ -171,7 +171,7 @@ export const VersionsTab = () => {
const download = availableDownloads.find((d) => d.name === version.name); const download = availableDownloads.find((d) => d.name === version.name);
if (!download) return; if (!download) return;
const success = await sharedHandleDownload({ const success = await handleDownloadFromStore({
item: download, item: download,
isUpdate: false, isUpdate: false,
wasCurrentBinary: false, wasCurrentBinary: false,
@ -186,7 +186,7 @@ export const VersionsTab = () => {
const download = availableDownloads.find((d) => d.name === version.name); const download = availableDownloads.find((d) => d.name === version.name);
if (!download) return; if (!download) return;
const success = await sharedHandleDownload({ const success = await handleDownloadFromStore({
item: download, item: download,
isUpdate: true, isUpdate: true,
wasCurrentBinary: version.isCurrent, wasCurrentBinary: version.isCurrent,

View file

@ -25,10 +25,6 @@ export const KLITE_CSS_OVERRIDE = `
margin-left: 10px; margin-left: 10px;
} }
.topmenu {
padding: 10px;
}
#navbarNavDropdown { #navbarNavDropdown {
padding: 0; padding: 0;
} }

View file

@ -1,276 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { logError } from '@/utils/logger';
import { getROCmDownload } from '@/utils/rocm';
import { GITHUB_API } from '@/constants';
import { filterAssetsByPlatform } from '@/utils/platform';
import type {
DownloadItem,
GitHubRelease,
ReleaseWithStatus,
GitHubAsset,
InstalledVersion,
} from '@/types/electron';
import { sortDownloadsByType } from '@/utils/assets';
interface CachedReleaseData {
releases: DownloadItem[];
timestamp: number;
}
interface HandleDownloadParams {
item: DownloadItem;
isUpdate?: boolean;
wasCurrentBinary?: boolean;
}
const CACHE_KEY = 'kobold-releases-cache';
const CACHE_DURATION = 60000;
const loadFromCache = (): CachedReleaseData | null => {
try {
const cached = localStorage.getItem(CACHE_KEY);
if (!cached) return null;
const data: CachedReleaseData = JSON.parse(cached);
const isExpired = Date.now() - data.timestamp > CACHE_DURATION;
return isExpired ? null : data;
} catch {
return null;
}
};
const saveToCache = (releases: DownloadItem[]) => {
try {
const data: CachedReleaseData = {
releases,
timestamp: Date.now(),
};
localStorage.setItem(CACHE_KEY, JSON.stringify(data));
} catch {}
};
const transformReleaseToDownloadItems = (
release: GitHubRelease,
platform: string
) => {
const version = release.tag_name?.replace(/^v/, '') || 'unknown';
const platformAssets = filterAssetsByPlatform(release.assets, platform);
return platformAssets.map((asset) => ({
name: asset.name,
url: asset.browser_download_url,
size: asset.size,
version,
}));
};
const fetchLatestReleaseFromAPI = async (
platform: string
): Promise<DownloadItem[]> => {
const response = await fetch(GITHUB_API.LATEST_RELEASE_URL);
if (!response.ok) {
if (response.status === 403) {
throw new Error('GitHub API rate limit reached');
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const release: GitHubRelease = await response.json();
return transformReleaseToDownloadItems(release, platform);
};
const getLatestReleaseWithDownloadStatus =
async (): Promise<ReleaseWithStatus | null> => {
try {
const response = await fetch(GITHUB_API.LATEST_RELEASE_URL);
if (!response.ok) return null;
const latestRelease = await response.json();
if (!latestRelease) return null;
const installedVersions =
await window.electronAPI.kobold.getInstalledVersions();
const availableAssets = latestRelease.assets.map((asset: GitHubAsset) => {
const installedVersion = installedVersions.find(
(v: InstalledVersion) => {
const pathParts = v.path.split(/[/\\]/);
const launcherIndex = pathParts.findIndex(
(part) =>
part === 'koboldcpp-launcher' ||
part === 'koboldcpp-launcher.exe'
);
if (launcherIndex > 0) {
const directoryName = pathParts[launcherIndex - 1];
return directoryName === asset.name;
}
return false;
}
);
return {
asset,
isDownloaded: !!installedVersion,
installedVersion: installedVersion?.version,
};
});
return {
release: latestRelease,
availableAssets,
};
} catch (err) {
logError('Failed to fetch latest release with status:', err as Error);
return null;
}
};
export const useKoboldVersions = () => {
const [platform, setPlatform] = useState('');
const [availableDownloads, setAvailableDownloads] = useState<DownloadItem[]>(
[]
);
const [loadingPlatform, setLoadingPlatform] = useState(true);
const [loadingRemote, setLoadingRemote] = useState(true);
const [downloading, setDownloading] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState<
Record<string, number>
>({});
const loadPlatform = useCallback(async () => {
setLoadingPlatform(true);
const platform = await window.electronAPI.kobold.getPlatform();
setPlatform(platform);
setLoadingPlatform(false);
}, []);
const loadRemoteVersions = useCallback(async () => {
if (!platform) return;
setLoadingRemote(true);
try {
const cached = loadFromCache();
if (cached) {
const rocm = await getROCmDownload();
const allDownloads: DownloadItem[] = [...cached.releases];
if (rocm) {
allDownloads.push(rocm);
}
setAvailableDownloads(sortDownloadsByType(allDownloads));
setLoadingRemote(false);
return;
}
const [releases, rocm] = await Promise.all([
fetchLatestReleaseFromAPI(platform),
getROCmDownload(),
]);
saveToCache(releases);
const allDownloads: DownloadItem[] = [...releases];
if (rocm) {
allDownloads.push(rocm);
}
setAvailableDownloads(sortDownloadsByType(allDownloads));
} catch (err) {
logError('Failed to load remote versions:', err as Error);
const cached = loadFromCache();
if (cached) {
const rocm = await getROCmDownload().catch(() => null);
const allDownloads: DownloadItem[] = [...cached.releases];
if (rocm) {
allDownloads.push(rocm);
}
setAvailableDownloads(sortDownloadsByType(allDownloads));
}
} finally {
setLoadingRemote(false);
}
}, [platform]);
const handleDownload = useCallback(
async ({
item,
isUpdate = false,
wasCurrentBinary = false,
}: HandleDownloadParams): Promise<boolean> => {
const downloadName = item.name;
setDownloading(downloadName);
setDownloadProgress((prev) => ({ ...prev, [downloadName]: 0 }));
try {
const result = await window.electronAPI.kobold.downloadRelease({
name: item.name,
browser_download_url: item.url,
size: item.size,
version: item.version,
isUpdate,
wasCurrentBinary,
});
return result.success !== false;
} catch (err) {
logError(`Failed to download ${item.name}:`, err as Error);
return false;
} finally {
setDownloading(null);
setDownloadProgress((prev) => {
const newProgress = { ...prev };
delete newProgress[downloadName];
return newProgress;
});
}
},
[]
);
useEffect(() => {
loadPlatform();
}, [loadPlatform]);
useEffect(() => {
if (platform) {
loadRemoteVersions();
}
}, [platform, loadRemoteVersions]);
useEffect(() => {
const handleProgress = (progress: number) => {
if (downloading) {
setDownloadProgress((prev) => ({
...prev,
[downloading]: progress,
}));
}
};
const cleanup =
window.electronAPI.kobold.onDownloadProgress(handleProgress);
return cleanup;
}, [downloading]);
return {
platform,
availableDownloads,
loadingPlatform,
loadingRemote,
downloading,
downloadProgress,
handleDownload,
getLatestReleaseWithDownloadStatus,
};
};

View file

@ -4,7 +4,7 @@ import {
compareVersions, compareVersions,
stripAssetExtensions, stripAssetExtensions,
} from '@/utils/version'; } from '@/utils/version';
import { useKoboldVersions } from '@/hooks/useKoboldVersions'; import { useKoboldVersionsStore } from '@/stores/koboldVersions';
import { getROCmDownload } from '@/utils/rocm'; import { getROCmDownload } from '@/utils/rocm';
import type { InstalledVersion, DownloadItem } from '@/types/electron'; import type { InstalledVersion, DownloadItem } from '@/types/electron';
@ -20,10 +20,9 @@ export const useUpdateChecker = () => {
const [dismissedUpdates, setDismissedUpdates] = useState<Set<string>>( const [dismissedUpdates, setDismissedUpdates] = useState<Set<string>>(
new Set() new Set()
); );
const [dismissedUpdatesLoaded, setDismissedUpdatesLoaded] = useState(false);
const { availableDownloads: releases } = useKoboldVersions();
const { availableDownloads: releases, loadingRemote } =
useKoboldVersionsStore();
useEffect(() => { useEffect(() => {
const loadDismissedUpdates = async () => { const loadDismissedUpdates = async () => {
const dismissed = (await window.electronAPI.config.get( const dismissed = (await window.electronAPI.config.get(
@ -33,23 +32,17 @@ export const useUpdateChecker = () => {
if (dismissed) { if (dismissed) {
setDismissedUpdates(new Set(dismissed)); setDismissedUpdates(new Set(dismissed));
} }
setDismissedUpdatesLoaded(true);
}; };
loadDismissedUpdates(); loadDismissedUpdates();
}, []); }, []);
const saveDismissedUpdates = useCallback((updates: Set<string>) => {
window.electronAPI.config.set('dismissedUpdates', Array.from(updates));
}, []);
const checkForUpdates = useCallback(async () => { const checkForUpdates = useCallback(async () => {
if (!dismissedUpdatesLoaded || releases.length === 0) { if (loadingRemote || releases.length === 0) {
return; return;
} }
setIsChecking(true); setIsChecking(true);
const [currentVersion, rocmDownload] = await Promise.all([ const [currentVersion, rocmDownload] = await Promise.all([
window.electronAPI.kobold.getCurrentVersion(), window.electronAPI.kobold.getCurrentVersion(),
getROCmDownload(), getROCmDownload(),
@ -92,24 +85,33 @@ export const useUpdateChecker = () => {
} }
setIsChecking(false); setIsChecking(false);
}, [dismissedUpdates, dismissedUpdatesLoaded, releases]); }, [dismissedUpdates, releases, loadingRemote]);
const dismissUpdate = useCallback(async () => { const skipUpdate = useCallback(() => {
if (updateInfo) { if (updateInfo) {
const updateKey = `${updateInfo.currentVersion.path}-${updateInfo.availableUpdate.version}`; const updateKey = `${updateInfo.currentVersion.path}-${updateInfo.availableUpdate.version}`;
const newDismissedUpdates = new Set([...dismissedUpdates, updateKey]); const newDismissedUpdates = new Set([...dismissedUpdates, updateKey]);
setDismissedUpdates(newDismissedUpdates); setDismissedUpdates(newDismissedUpdates);
await saveDismissedUpdates(newDismissedUpdates); window.electronAPI.config.set(
'dismissedUpdates',
Array.from(newDismissedUpdates)
);
} }
setShowUpdateModal(false); setShowUpdateModal(false);
setUpdateInfo(null); setUpdateInfo(null);
}, [updateInfo, dismissedUpdates, saveDismissedUpdates]); }, [updateInfo, dismissedUpdates]);
const closeModal = useCallback(() => {
setShowUpdateModal(false);
setUpdateInfo(null);
}, []);
return { return {
updateInfo, updateInfo,
showUpdateModal, showUpdateModal,
isChecking, isChecking,
checkForUpdates, checkForUpdates,
dismissUpdate, skipUpdate,
closeModal,
}; };
}; };

View file

@ -78,9 +78,10 @@ async function removeDirectoryWithRetry(
async function handleExistingDirectory( async function handleExistingDirectory(
unpackedDirPath: string, unpackedDirPath: string,
isUpdate: boolean isUpdate: boolean,
wasCurrentBinary = false
) { ) {
if (!isUpdate || !(await pathExists(unpackedDirPath))) { if (!isUpdate) {
return; return;
} }
@ -91,7 +92,26 @@ async function handleExistingDirectory(
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
} }
if (await pathExists(unpackedDirPath)) {
await removeDirectoryWithRetry(unpackedDirPath); await removeDirectoryWithRetry(unpackedDirPath);
}
if (wasCurrentBinary) {
const currentBinaryPath = getCurrentKoboldBinary();
if (currentBinaryPath && (await pathExists(currentBinaryPath))) {
const oldVersionDir = currentBinaryPath
.split(/[/\\]/)
.slice(0, -1)
.join('/');
if (
oldVersionDir !== unpackedDirPath &&
(await pathExists(oldVersionDir))
) {
await removeDirectoryWithRetry(oldVersionDir);
sendKoboldOutput(`Removed old version: ${oldVersionDir}`);
}
}
}
} catch (error) { } catch (error) {
logError('Failed to remove existing directory for update:', error as Error); logError('Failed to remove existing directory for update:', error as Error);
throw new Error( throw new Error(
@ -104,21 +124,20 @@ async function handleExistingDirectory(
async function downloadFile(asset: GitHubAsset, tempPackedFilePath: string) { async function downloadFile(asset: GitHubAsset, tempPackedFilePath: string) {
const writer = createWriteStream(tempPackedFilePath); const writer = createWriteStream(tempPackedFilePath);
let downloadedBytes = 0;
const response = await fetch(asset.browser_download_url);
if (!response.ok) {
throw new Error(`Download failed: ${response.statusText}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
const totalBytes = asset.size;
const mainWindow = getMainWindow(); const mainWindow = getMainWindow();
let downloadedBytes = 0;
let lastProgressUpdate = 0;
const response = await fetch(asset.browser_download_url);
if (!response.ok || !response.body) {
throw new Error(`Failed to download: ${response.statusText}`);
}
const totalBytes = parseInt(
response.headers.get('content-length') || '0',
10
);
const reader = response.body.getReader(); const reader = response.body.getReader();
const pump = async () => { const pump = async () => {
@ -126,13 +145,18 @@ async function downloadFile(asset: GitHubAsset, tempPackedFilePath: string) {
if (done) { if (done) {
writer.end(); writer.end();
mainWindow.webContents.send('download-progress', 100);
return; return;
} }
downloadedBytes += value.length; downloadedBytes += value.length;
if (totalBytes > 0) { if (totalBytes > 0) {
const progress = (downloadedBytes / totalBytes) * 100; const progress = (downloadedBytes / totalBytes) * 100;
const now = Date.now();
if (now - lastProgressUpdate > 100) {
mainWindow.webContents.send('download-progress', progress); mainWindow.webContents.send('download-progress', progress);
lastProgressUpdate = now;
}
} }
writer.write(Buffer.from(value)); writer.write(Buffer.from(value));
@ -199,8 +223,13 @@ export async function downloadRelease(asset: GitHubAsset) {
const unpackedDirPath = join(getInstallDir(), folderName); const unpackedDirPath = join(getInstallDir(), folderName);
try { try {
await handleExistingDirectory(unpackedDirPath, Boolean(asset.isUpdate)); await handleExistingDirectory(
unpackedDirPath,
Boolean(asset.isUpdate),
Boolean(asset.wasCurrentBinary)
);
await downloadFile(asset, tempPackedFilePath); await downloadFile(asset, tempPackedFilePath);
await mkdir(unpackedDirPath, { recursive: true }); await mkdir(unpackedDirPath, { recursive: true });
await unpackKoboldCpp(tempPackedFilePath, unpackedDirPath); await unpackKoboldCpp(tempPackedFilePath, unpackedDirPath);
const launcherPath = await setupLauncher( const launcherPath = await setupLauncher(

View file

@ -322,7 +322,11 @@ export function getMainWindow() {
export const sendToRenderer = <T extends IPCChannel>( export const sendToRenderer = <T extends IPCChannel>(
channel: T, channel: T,
...args: IPCChannelPayloads[T] ...args: IPCChannelPayloads[T]
) => getMainWindow().webContents.send(channel, ...args); ) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, ...args);
}
};
export function sendKoboldOutput(message: string, raw?: boolean) { export function sendKoboldOutput(message: string, raw?: boolean) {
const cleanMessage = stripVTControlCharacters(message); const cleanMessage = stripVTControlCharacters(message);

View file

@ -0,0 +1,182 @@
import { create } from 'zustand';
import { logError, safeExecute } from '@/utils/logger';
import { getROCmDownload } from '@/utils/rocm';
import { GITHUB_API } from '@/constants';
import { filterAssetsByPlatform } from '@/utils/platform';
import type {
DownloadItem,
GitHubRelease,
ReleaseWithStatus,
GitHubAsset,
InstalledVersion,
} from '@/types/electron';
import { sortDownloadsByType } from '@/utils/assets';
interface HandleDownloadParams {
item: DownloadItem;
isUpdate?: boolean;
wasCurrentBinary?: boolean;
}
const transformReleaseToDownloadItems = (
release: GitHubRelease,
platform: string
) => {
const version = release.tag_name?.replace(/^v/, '') || 'unknown';
const platformAssets = filterAssetsByPlatform(release.assets, platform);
return platformAssets.map((asset) => ({
name: asset.name,
url: asset.browser_download_url,
size: asset.size,
version,
}));
};
const fetchLatestReleaseFromAPI = async (platform: string) => {
const response = await fetch(GITHUB_API.LATEST_RELEASE_URL);
if (!response.ok) {
if (response.status === 403) {
throw new Error('GitHub API rate limit reached');
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const release: GitHubRelease = await response.json();
return transformReleaseToDownloadItems(release, platform);
};
interface KoboldVersionsState {
platform: string;
availableDownloads: DownloadItem[];
loadingPlatform: boolean;
loadingRemote: boolean;
downloading: string | null;
downloadProgress: Record<string, number>;
initialize: () => Promise<void>;
handleDownload: (params: HandleDownloadParams) => Promise<boolean>;
getLatestReleaseWithDownloadStatus: () => Promise<ReleaseWithStatus | null>;
}
export const useKoboldVersionsStore = create<KoboldVersionsState>(
(set, get) => ({
platform: '',
availableDownloads: [],
loadingPlatform: true,
loadingRemote: true,
downloading: null,
downloadProgress: {},
initialize: async () => {
set({ loadingPlatform: true, loadingRemote: true });
try {
const platform = await window.electronAPI.kobold.getPlatform();
set({ platform, loadingPlatform: false });
const [releases, rocm] = await Promise.all([
fetchLatestReleaseFromAPI(platform),
getROCmDownload(),
]);
const allDownloads: DownloadItem[] = [...releases];
if (rocm) {
allDownloads.push(rocm);
}
set({ availableDownloads: sortDownloadsByType(allDownloads) });
} catch (err) {
logError('Failed to initialize store:', err as Error);
set({ availableDownloads: [] });
} finally {
set({ loadingRemote: false });
}
},
handleDownload: async (params: HandleDownloadParams) => {
const { item, isUpdate = false, wasCurrentBinary = false } = params;
const { downloading } = get();
if (downloading) {
return false;
}
set({ downloading: item.name, downloadProgress: { [item.name]: 0 } });
const progressCleanup = window.electronAPI.kobold.onDownloadProgress(
(progress) => {
set({ downloadProgress: { [item.name]: progress } });
}
);
try {
const asset: GitHubAsset = {
name: item.name,
browser_download_url: item.url,
size: item.size,
version: item.version,
isUpdate,
wasCurrentBinary,
};
const result = await window.electronAPI.kobold.downloadRelease(asset);
return result.success;
} catch (err) {
logError('Download failed:', err as Error);
return false;
} finally {
progressCleanup();
set({ downloading: null, downloadProgress: {} });
}
},
getLatestReleaseWithDownloadStatus: async () =>
safeExecute(async () => {
const [response, installedVersions] = await Promise.all([
fetch(GITHUB_API.LATEST_RELEASE_URL),
window.electronAPI.kobold.getInstalledVersions(),
]);
if (!response.ok) return null;
const latestRelease = await response.json();
if (!latestRelease) return null;
const availableAssets = latestRelease.assets.map(
(asset: GitHubAsset) => {
const installedVersion = installedVersions.find(
(v: InstalledVersion) => {
const pathParts = v.path.split(/[/\\]/);
const launcherIndex = pathParts.findIndex(
(part) =>
part === 'koboldcpp-launcher' ||
part === 'koboldcpp-launcher.exe'
);
if (launcherIndex > 0) {
const directoryName = pathParts[launcherIndex - 1];
return directoryName === asset.name;
}
return false;
}
);
return {
asset,
isDownloaded: !!installedVersion,
installedVersion: installedVersion?.version,
};
}
);
return {
release: latestRelease,
availableAssets,
};
}, 'Failed to fetch latest release with status:'),
})
);
useKoboldVersionsStore.getState().initialize();