display disabled backend options for available but currently hardware-unsupported backends, allow updatable binaries to be available to make current, persist skipping kcpp updates to app config

This commit is contained in:
Egor 2025-08-26 13:01:19 -07:00
parent 253403d45f
commit 4f49f0bad3
10 changed files with 244 additions and 153 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "friendly-kobold", "name": "friendly-kobold",
"productName": "Friendly Kobold", "productName": "Friendly Kobold",
"version": "0.8.0", "version": "0.8.1",
"description": "A desktop app for running Large Language Models locally", "description": "A desktop app for running Large Language Models locally",
"main": "out/main/index.js", "main": "out/main/index.js",
"homepage": "./", "homepage": "./",
@ -55,7 +55,7 @@
"@eslint/js": "^9.34.0", "@eslint/js": "^9.34.0",
"@types/node": "^24.3.0", "@types/node": "^24.3.0",
"@types/react": "^19.1.11", "@types/react": "^19.1.11",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.8",
"@types/strip-ansi": "^5.2.1", "@types/strip-ansi": "^5.2.1",
"@typescript-eslint/eslint-plugin": "^8.41.0", "@typescript-eslint/eslint-plugin": "^8.41.0",
"@typescript-eslint/parser": "^8.41.0", "@typescript-eslint/parser": "^8.41.0",
@ -85,7 +85,7 @@
"@mantine/hooks": "^8.2.7", "@mantine/hooks": "^8.2.7",
"execa": "^9.6.0", "execa": "^9.6.0",
"got": "^14.4.7", "got": "^14.4.7",
"lucide-react": "^0.541.0", "lucide-react": "^0.542.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",

View file

@ -26,7 +26,7 @@ interface DownloadCardProps {
disabled?: boolean; disabled?: boolean;
hasUpdate?: boolean; hasUpdate?: boolean;
newerVersion?: string; newerVersion?: string;
onDownload?: (e: MouseEvent<HTMLButtonElement>) => void; onDownload: (e: MouseEvent<HTMLButtonElement>) => void;
onMakeCurrent?: () => void; onMakeCurrent?: () => void;
onUpdate?: (e: MouseEvent<HTMLButtonElement>) => void; onUpdate?: (e: MouseEvent<HTMLButtonElement>) => void;
} }
@ -51,32 +51,13 @@ export const DownloadCard = ({
getInitialValueInEffect: false, getInitialValueInEffect: false,
}); });
const isDark = computedColorScheme === 'dark'; const isDark = computedColorScheme === 'dark';
const renderActionButton = () => { const renderActionButtons = () => {
if (hasUpdate && onUpdate) { const buttons = [];
return (
<Button
variant="filled"
size="xs"
onClick={onUpdate}
loading={isDownloading}
disabled={disabled}
color="orange"
leftSection={
isDownloading ? (
<Loader size="1rem" />
) : (
<Download style={{ width: rem(14), height: rem(14) }} />
)
}
>
{isDownloading ? 'Updating...' : `Update to ${newerVersion}`}
</Button>
);
}
if (!isInstalled && onDownload) { if (!isInstalled) {
return ( buttons.push(
<Button <Button
key="download"
variant="filled" variant="filled"
size="xs" size="xs"
onClick={onDownload} onClick={onDownload}
@ -96,14 +77,42 @@ export const DownloadCard = ({
} }
if (isInstalled && !isCurrent && onMakeCurrent) { if (isInstalled && !isCurrent && onMakeCurrent) {
return ( buttons.push(
<Button variant="light" size="xs" onClick={onMakeCurrent}> <Button
key="makeCurrent"
variant="light"
size="xs"
onClick={onMakeCurrent}
>
Make Current Make Current
</Button> </Button>
); );
} }
return null; if (hasUpdate && onUpdate) {
buttons.push(
<Button
key="update"
variant="filled"
size="xs"
onClick={onUpdate}
loading={isDownloading}
disabled={disabled}
color="orange"
leftSection={
isDownloading ? (
<Loader size="1rem" />
) : (
<Download style={{ width: rem(14), height: rem(14) }} />
)
}
>
{isDownloading ? 'Updating...' : `Update to ${newerVersion}`}
</Button>
);
}
return buttons.length > 0 ? <Stack gap="xs">{buttons}</Stack> : null;
}; };
return ( return (
@ -157,7 +166,7 @@ export const DownloadCard = ({
</Group> </Group>
</div> </div>
{renderActionButton()} {renderActionButtons()}
</Group> </Group>
{isDownloading && downloadProgress !== undefined && ( {isDownloading && downloadProgress !== undefined && (

View file

@ -1,10 +1,5 @@
import { Stack, Text, Group, Button, Select, Badge } from '@mantine/core'; import { Stack, Text, Group, Button, Select, Badge } from '@mantine/core';
import { import { useState, useCallback } from 'react';
useState,
useCallback,
forwardRef,
type ComponentPropsWithoutRef,
} from 'react';
import { Save, File, Plus, Check } from 'lucide-react'; import { Save, File, Plus, Check } from 'lucide-react';
import type { ConfigFile } from '@/types'; import type { ConfigFile } from '@/types';
import styles from '@/styles/layout.module.css'; import styles from '@/styles/layout.module.css';
@ -19,7 +14,7 @@ interface ConfigFileManagerProps {
onLoadConfigFiles: () => Promise<void>; onLoadConfigFiles: () => Promise<void>;
} }
interface SelectItemProps extends ComponentPropsWithoutRef<'div'> { interface SelectItemProps {
label: string; label: string;
extension: string; extension: string;
} }
@ -35,23 +30,17 @@ const getBadgeColor = (extension: string) => {
} }
}; };
const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>( const SelectItem = ({ label, extension }: SelectItemProps) => (
({ label, extension, ...others }, ref) => ( <Group justify="space-between" wrap="nowrap">
<div ref={ref} {...others}> <Text size="sm" truncate>
<Group justify="space-between" wrap="nowrap"> {label}
<Text size="sm" truncate> </Text>
{label} <Badge size="xs" variant="light" color={getBadgeColor(extension)}>
</Text> {extension}
<Badge size="xs" variant="light" color={getBadgeColor(extension)}> </Badge>
{extension} </Group>
</Badge>
</Group>
</div>
)
); );
SelectItem.displayName = 'SelectItem';
export const ConfigFileManager = ({ export const ConfigFileManager = ({
configFiles, configFiles,
selectedFile, selectedFile,

View file

@ -1,37 +1,38 @@
import { Text, Group, Badge } from '@mantine/core'; import { Text, Group, Badge } from '@mantine/core';
import { forwardRef } from 'react';
import type { ComponentPropsWithoutRef } from 'react';
interface BackendSelectItemProps extends ComponentPropsWithoutRef<'div'> { interface BackendSelectItemProps {
label: string; label: string;
devices?: string[]; devices?: string[];
disabled?: boolean;
} }
export const BackendSelectItem = forwardRef< export const BackendSelectItem = ({
HTMLDivElement, label,
BackendSelectItemProps devices,
>(({ label, devices, ...others }, ref) => ( disabled = false,
<div ref={ref} {...others}> }: BackendSelectItemProps) => (
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Text size="sm" truncate> <Text size="sm" truncate>
{label} {label}
</Text> {disabled && (
{devices && devices.length > 0 && ( <Text component="span" size="xs" ml="xs">
<Group gap={4}> (Compatible device(s) not found)
{devices.slice(0, 2).map((device, index) => ( </Text>
<Badge key={index} size="md" variant="light" color="blue">
{device.length > 25 ? `${device.slice(0, 25)}...` : device}
</Badge>
))}
{devices.length > 2 && (
<Badge size="md" variant="light" color="gray">
+{devices.length - 2}
</Badge>
)}
</Group>
)} )}
</Group> </Text>
</div> {devices && devices.length > 0 && (
)); <Group gap={4}>
{devices.slice(0, 2).map((device, index) => (
BackendSelectItem.displayName = 'BackendSelectItem'; <Badge key={index} size="md" variant="light" color="blue">
{device.length > 25 ? `${device.slice(0, 25)}...` : device}
</Badge>
))}
{devices.length > 2 && (
<Badge size="md" variant="light" color="gray">
+{devices.length - 2}
</Badge>
)}
</Group>
)}
</Group>
);

View file

@ -5,11 +5,7 @@ import { BackendSelectItem } from '@/components/screens/Launch/GeneralTab/Backen
import { GpuDeviceSelector } from '@/components/screens/Launch/GeneralTab/GpuDeviceSelector'; import { GpuDeviceSelector } from '@/components/screens/Launch/GeneralTab/GpuDeviceSelector';
import { useLaunchConfig } from '@/hooks/useLaunchConfig'; import { useLaunchConfig } from '@/hooks/useLaunchConfig';
interface BackendSelectorProps { export const BackendSelector = () => {
onBackendsReady?: () => void;
}
export const BackendSelector = ({ onBackendsReady }: BackendSelectorProps) => {
const { const {
backend, backend,
gpuLayers, gpuLayers,
@ -20,39 +16,95 @@ export const BackendSelector = ({ onBackendsReady }: BackendSelectorProps) => {
} = useLaunchConfig(); } = useLaunchConfig();
const [availableBackends, setAvailableBackends] = useState< const [availableBackends, setAvailableBackends] = useState<
Array<{ value: string; label: string; devices?: string[] }> Array<{
value: string;
label: string;
devices?: string[];
disabled?: boolean;
}>
>([]); >([]);
const hasInitialized = useRef(false); const hasInitialized = useRef(false);
useEffect(() => { useEffect(() => {
const loadBackends = async () => { const loadBackends = async () => {
try { try {
const [cpuCapabilitiesResult, backends] = await Promise.all([ const [cpuCapabilitiesResult, binarySupport, gpuCapabilities] =
window.electronAPI.kobold.detectCPU(), await Promise.all([
window.electronAPI.kobold.getAvailableBackends(), window.electronAPI.kobold.detectCPU(),
]); window.electronAPI.kobold.detectBackendSupport(),
window.electronAPI.kobold.detectGPUCapabilities(),
]);
const cpuBackend = backends.find((b) => b.value === 'cpu'); if (!binarySupport) {
if (cpuBackend) { setAvailableBackends([]);
cpuBackend.devices = cpuCapabilitiesResult.devices; hasInitialized.current = true;
return;
} }
const backends: Array<{
value: string;
label: string;
devices?: string[];
disabled?: boolean;
}> = [];
if (binarySupport.cuda) {
backends.push({
value: 'cuda',
label: 'CUDA',
devices: gpuCapabilities.cuda.devices,
disabled: !gpuCapabilities.cuda.supported,
});
}
if (binarySupport.rocm) {
backends.push({
value: 'rocm',
label: 'ROCm',
devices: gpuCapabilities.rocm.devices,
disabled: !gpuCapabilities.rocm.supported,
});
}
if (binarySupport.vulkan) {
backends.push({
value: 'vulkan',
label: 'Vulkan',
devices: gpuCapabilities.vulkan.devices,
disabled: !gpuCapabilities.vulkan.supported,
});
}
if (binarySupport.clblast) {
backends.push({
value: 'clblast',
label: 'CLBlast',
devices: gpuCapabilities.clblast.devices,
disabled: !gpuCapabilities.clblast.supported,
});
}
backends.push({
value: 'cpu',
label: 'CPU',
devices: cpuCapabilitiesResult.devices,
disabled: false,
});
backends.sort((a, b) => {
if (a.disabled === b.disabled) return 0;
return a.disabled ? 1 : -1;
});
setAvailableBackends(backends); setAvailableBackends(backends);
hasInitialized.current = true; hasInitialized.current = true;
if (onBackendsReady) {
onBackendsReady();
}
} catch (error) { } catch (error) {
window.electronAPI.logs.logError( window.electronAPI.logs.logError(
'Failed to detect available backends:', 'Failed to detect available backends:',
error as Error error as Error
); );
setAvailableBackends([]); setAvailableBackends([]);
if (onBackendsReady) {
onBackendsReady();
}
} }
}; };
@ -66,7 +118,7 @@ export const BackendSelector = ({ onBackendsReady }: BackendSelectorProps) => {
}); });
return cleanup; return cleanup;
}, [onBackendsReady]); }, []);
return ( return (
<div> <div>
@ -89,6 +141,7 @@ export const BackendSelector = ({ onBackendsReady }: BackendSelectorProps) => {
data={availableBackends.map((b) => ({ data={availableBackends.map((b) => ({
value: b.value, value: b.value,
label: b.label, label: b.label,
disabled: b.disabled,
}))} }))}
disabled={availableBackends.length === 0} disabled={availableBackends.length === 0}
renderOption={({ option }) => { renderOption={({ option }) => {
@ -99,6 +152,7 @@ export const BackendSelector = ({ onBackendsReady }: BackendSelectorProps) => {
<BackendSelectItem <BackendSelectItem
label={backendData?.label || option.label.split(' (')[0]} label={backendData?.label || option.label.split(' (')[0]}
devices={backendData?.devices} devices={backendData?.devices}
disabled={backendData?.disabled}
/> />
); );
}} }}

View file

@ -4,11 +4,7 @@ import { BackendSelector } from '@/components/screens/Launch/GeneralTab/BackendS
import { ModelFileField } from '@/components/ModelFileField'; import { ModelFileField } from '@/components/ModelFileField';
import { useLaunchConfig } from '@/hooks/useLaunchConfig'; import { useLaunchConfig } from '@/hooks/useLaunchConfig';
interface GeneralTabProps { export const GeneralTab = () => {
onBackendsReady?: () => void;
}
export const GeneralTab = ({ onBackendsReady }: GeneralTabProps) => {
const { const {
modelPath, modelPath,
contextSize, contextSize,
@ -19,7 +15,7 @@ export const GeneralTab = ({ onBackendsReady }: GeneralTabProps) => {
return ( return (
<Stack gap="md"> <Stack gap="md">
<BackendSelector onBackendsReady={onBackendsReady} /> <BackendSelector />
<ModelFileField <ModelFileField
label="Text Model File" label="Text Model File"

View file

@ -366,27 +366,15 @@ export const VersionsTab = () => {
disabled={downloading !== null} disabled={downloading !== null}
hasUpdate={version.hasUpdate} hasUpdate={version.hasUpdate}
newerVersion={version.newerVersion} newerVersion={version.newerVersion}
onDownload={ onDownload={(e) => {
!version.isInstalled e.stopPropagation();
? (e) => { handleDownload(version);
e.stopPropagation(); }}
handleDownload(version); onUpdate={(e) => {
} e.stopPropagation();
: undefined handleUpdate(version);
} }}
onUpdate={ onMakeCurrent={() => makeCurrent(version)}
version.hasUpdate
? (e) => {
e.stopPropagation();
handleUpdate(version);
}
: undefined
}
onMakeCurrent={
version.isInstalled && !version.isCurrent && !version.hasUpdate
? () => makeCurrent(version)
: undefined
}
/> />
</div> </div>
); );

View file

@ -1,4 +1,4 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { getDisplayNameFromPath } from '@/utils/versionUtils'; import { getDisplayNameFromPath } from '@/utils/versionUtils';
import { compareVersions } from '@/utils'; import { compareVersions } from '@/utils';
import type { InstalledVersion, DownloadItem } from '@/types/electron'; import type { InstalledVersion, DownloadItem } from '@/types/electron';
@ -12,8 +12,52 @@ export const useUpdateChecker = () => {
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null); const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [isChecking, setIsChecking] = useState(false); const [isChecking, setIsChecking] = useState(false);
const [showUpdateModal, setShowUpdateModal] = useState(false); const [showUpdateModal, setShowUpdateModal] = useState(false);
const [dismissedUpdates, setDismissedUpdates] = useState<Set<string>>(
new Set()
);
const [dismissedUpdatesLoaded, setDismissedUpdatesLoaded] = useState(false);
useEffect(() => {
const loadDismissedUpdates = async () => {
try {
const dismissed = (await window.electronAPI.config.get(
'dismissedUpdates'
)) as string[] | undefined;
if (dismissed) {
setDismissedUpdates(new Set(dismissed));
}
setDismissedUpdatesLoaded(true);
} catch (error) {
window.electronAPI.logs.logError(
'Failed to load dismissed updates:',
error as Error
);
setDismissedUpdatesLoaded(true);
}
};
loadDismissedUpdates();
}, []);
const saveDismissedUpdates = useCallback(async (updates: Set<string>) => {
try {
await window.electronAPI.config.set(
'dismissedUpdates',
Array.from(updates)
);
} catch (error) {
window.electronAPI.logs.logError(
'Failed to save dismissed updates:',
error as Error
);
}
}, []);
const checkForUpdates = useCallback(async () => { const checkForUpdates = useCallback(async () => {
if (!dismissedUpdatesLoaded) {
return;
}
setIsChecking(true); setIsChecking(true);
try { try {
@ -59,11 +103,15 @@ export const useUpdateChecker = () => {
compareVersions(matchingDownload.version, currentVersion.version) > 0; compareVersions(matchingDownload.version, currentVersion.version) > 0;
if (hasUpdate) { if (hasUpdate) {
setUpdateInfo({ const updateKey = `${currentVersion.path}-${matchingDownload.version}`;
currentVersion,
availableUpdate: matchingDownload, if (!dismissedUpdates.has(updateKey)) {
}); setUpdateInfo({
setShowUpdateModal(true); currentVersion,
availableUpdate: matchingDownload,
});
setShowUpdateModal(true);
}
} }
} }
} catch (error) { } catch (error) {
@ -74,12 +122,18 @@ export const useUpdateChecker = () => {
} finally { } finally {
setIsChecking(false); setIsChecking(false);
} }
}, []); }, [dismissedUpdates, dismissedUpdatesLoaded]);
const dismissUpdate = useCallback(() => { const dismissUpdate = useCallback(async () => {
if (updateInfo) {
const updateKey = `${updateInfo.currentVersion.path}-${updateInfo.availableUpdate.version}`;
const newDismissedUpdates = new Set([...dismissedUpdates, updateKey]);
setDismissedUpdates(newDismissedUpdates);
await saveDismissedUpdates(newDismissedUpdates);
}
setShowUpdateModal(false); setShowUpdateModal(false);
setUpdateInfo(null); setUpdateInfo(null);
}, []); }, [updateInfo, dismissedUpdates, saveDismissedUpdates]);
return { return {
updateInfo, updateInfo,

View file

@ -9,7 +9,7 @@ export const getAssetDescription = (assetName: string): 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.'; 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.';
} }
if (name.endsWith(ASSET_SUFFIXES.NOCUDA)) { if (name.endsWith(ASSET_SUFFIXES.NOCUDA)) {

View file

@ -1324,12 +1324,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/react-dom@npm:^19.1.7": "@types/react-dom@npm:^19.1.8":
version: 19.1.7 version: 19.1.8
resolution: "@types/react-dom@npm:19.1.7" resolution: "@types/react-dom@npm:19.1.8"
peerDependencies: peerDependencies:
"@types/react": ^19.0.0 "@types/react": ^19.0.0
checksum: 10c0/8db5751c1567552fe4e1ece9f5823b682f2994ec8d30ed34ba0ef984e3c8ace1435f8be93d02f55c350147e78ac8c4dbcd8ed2c3b6a60f575bc5374f588c51c9 checksum: 10c0/561f9679c99e93adba8ecdf7d5ad69cc9d5e35837fa996246a83713f0ce498fc5b871f9a2a3342c7d440fc02159abe10f29c4a4004527d5d38b2e84f21840793
languageName: node languageName: node
linkType: hard linkType: hard
@ -3612,7 +3612,7 @@ __metadata:
"@mantine/hooks": "npm:^8.2.7" "@mantine/hooks": "npm:^8.2.7"
"@types/node": "npm:^24.3.0" "@types/node": "npm:^24.3.0"
"@types/react": "npm:^19.1.11" "@types/react": "npm:^19.1.11"
"@types/react-dom": "npm:^19.1.7" "@types/react-dom": "npm:^19.1.8"
"@types/strip-ansi": "npm:^5.2.1" "@types/strip-ansi": "npm:^5.2.1"
"@typescript-eslint/eslint-plugin": "npm:^8.41.0" "@typescript-eslint/eslint-plugin": "npm:^8.41.0"
"@typescript-eslint/parser": "npm:^8.41.0" "@typescript-eslint/parser": "npm:^8.41.0"
@ -3634,7 +3634,7 @@ __metadata:
husky: "npm:^9.1.7" husky: "npm:^9.1.7"
jiti: "npm:^2.5.1" jiti: "npm:^2.5.1"
lint-staged: "npm:^16.1.5" lint-staged: "npm:^16.1.5"
lucide-react: "npm:^0.541.0" lucide-react: "npm:^0.542.0"
prettier: "npm:^3.6.2" prettier: "npm:^3.6.2"
react: "npm:^19.1.1" react: "npm:^19.1.1"
react-dom: "npm:^19.1.1" react-dom: "npm:^19.1.1"
@ -5005,12 +5005,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"lucide-react@npm:^0.541.0": "lucide-react@npm:^0.542.0":
version: 0.541.0 version: 0.542.0
resolution: "lucide-react@npm:0.541.0" resolution: "lucide-react@npm:0.542.0"
peerDependencies: peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/ffa23a9c9ead0832c7e0bb1eb7ed44e9f38bbf083d94b4d36a66eb23fa2099d4a52e1677c91eb7e8ea922621fa98e730f5d513bc56b3111b14b835bd8ab823ab checksum: 10c0/3ccdb898a480f0194f93abef0b6f0347bc2647db24051e65a17c1dbd22ca0d10a7636d9af68c92665b42b6c9e761567a0d65944fe8568fb0e30a7ea57281ccec
languageName: node languageName: node
linkType: hard linkType: hard