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

View file

@ -26,7 +26,7 @@ interface DownloadCardProps {
disabled?: boolean;
hasUpdate?: boolean;
newerVersion?: string;
onDownload?: (e: MouseEvent<HTMLButtonElement>) => void;
onDownload: (e: MouseEvent<HTMLButtonElement>) => void;
onMakeCurrent?: () => void;
onUpdate?: (e: MouseEvent<HTMLButtonElement>) => void;
}
@ -51,32 +51,13 @@ export const DownloadCard = ({
getInitialValueInEffect: false,
});
const isDark = computedColorScheme === 'dark';
const renderActionButton = () => {
if (hasUpdate && onUpdate) {
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>
);
}
const renderActionButtons = () => {
const buttons = [];
if (!isInstalled && onDownload) {
return (
if (!isInstalled) {
buttons.push(
<Button
key="download"
variant="filled"
size="xs"
onClick={onDownload}
@ -96,14 +77,42 @@ export const DownloadCard = ({
}
if (isInstalled && !isCurrent && onMakeCurrent) {
return (
<Button variant="light" size="xs" onClick={onMakeCurrent}>
buttons.push(
<Button
key="makeCurrent"
variant="light"
size="xs"
onClick={onMakeCurrent}
>
Make Current
</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 (
@ -157,7 +166,7 @@ export const DownloadCard = ({
</Group>
</div>
{renderActionButton()}
{renderActionButtons()}
</Group>
{isDownloading && downloadProgress !== undefined && (

View file

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

View file

@ -1,37 +1,38 @@
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;
devices?: string[];
disabled?: boolean;
}
export const BackendSelectItem = forwardRef<
HTMLDivElement,
BackendSelectItemProps
>(({ label, devices, ...others }, ref) => (
<div ref={ref} {...others}>
<Group justify="space-between" wrap="nowrap">
<Text size="sm" truncate>
{label}
</Text>
{devices && devices.length > 0 && (
<Group gap={4}>
{devices.slice(0, 2).map((device, index) => (
<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>
export const BackendSelectItem = ({
label,
devices,
disabled = false,
}: BackendSelectItemProps) => (
<Group justify="space-between" wrap="nowrap">
<Text size="sm" truncate>
{label}
{disabled && (
<Text component="span" size="xs" ml="xs">
(Compatible device(s) not found)
</Text>
)}
</Group>
</div>
));
BackendSelectItem.displayName = 'BackendSelectItem';
</Text>
{devices && devices.length > 0 && (
<Group gap={4}>
{devices.slice(0, 2).map((device, index) => (
<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 { useLaunchConfig } from '@/hooks/useLaunchConfig';
interface BackendSelectorProps {
onBackendsReady?: () => void;
}
export const BackendSelector = ({ onBackendsReady }: BackendSelectorProps) => {
export const BackendSelector = () => {
const {
backend,
gpuLayers,
@ -20,39 +16,95 @@ export const BackendSelector = ({ onBackendsReady }: BackendSelectorProps) => {
} = useLaunchConfig();
const [availableBackends, setAvailableBackends] = useState<
Array<{ value: string; label: string; devices?: string[] }>
Array<{
value: string;
label: string;
devices?: string[];
disabled?: boolean;
}>
>([]);
const hasInitialized = useRef(false);
useEffect(() => {
const loadBackends = async () => {
try {
const [cpuCapabilitiesResult, backends] = await Promise.all([
window.electronAPI.kobold.detectCPU(),
window.electronAPI.kobold.getAvailableBackends(),
]);
const [cpuCapabilitiesResult, binarySupport, gpuCapabilities] =
await Promise.all([
window.electronAPI.kobold.detectCPU(),
window.electronAPI.kobold.detectBackendSupport(),
window.electronAPI.kobold.detectGPUCapabilities(),
]);
const cpuBackend = backends.find((b) => b.value === 'cpu');
if (cpuBackend) {
cpuBackend.devices = cpuCapabilitiesResult.devices;
if (!binarySupport) {
setAvailableBackends([]);
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);
hasInitialized.current = true;
if (onBackendsReady) {
onBackendsReady();
}
} catch (error) {
window.electronAPI.logs.logError(
'Failed to detect available backends:',
error as Error
);
setAvailableBackends([]);
if (onBackendsReady) {
onBackendsReady();
}
}
};
@ -66,7 +118,7 @@ export const BackendSelector = ({ onBackendsReady }: BackendSelectorProps) => {
});
return cleanup;
}, [onBackendsReady]);
}, []);
return (
<div>
@ -89,6 +141,7 @@ export const BackendSelector = ({ onBackendsReady }: BackendSelectorProps) => {
data={availableBackends.map((b) => ({
value: b.value,
label: b.label,
disabled: b.disabled,
}))}
disabled={availableBackends.length === 0}
renderOption={({ option }) => {
@ -99,6 +152,7 @@ export const BackendSelector = ({ onBackendsReady }: BackendSelectorProps) => {
<BackendSelectItem
label={backendData?.label || option.label.split(' (')[0]}
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 { useLaunchConfig } from '@/hooks/useLaunchConfig';
interface GeneralTabProps {
onBackendsReady?: () => void;
}
export const GeneralTab = ({ onBackendsReady }: GeneralTabProps) => {
export const GeneralTab = () => {
const {
modelPath,
contextSize,
@ -19,7 +15,7 @@ export const GeneralTab = ({ onBackendsReady }: GeneralTabProps) => {
return (
<Stack gap="md">
<BackendSelector onBackendsReady={onBackendsReady} />
<BackendSelector />
<ModelFileField
label="Text Model File"

View file

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

View file

@ -1,4 +1,4 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { getDisplayNameFromPath } from '@/utils/versionUtils';
import { compareVersions } from '@/utils';
import type { InstalledVersion, DownloadItem } from '@/types/electron';
@ -12,8 +12,52 @@ export const useUpdateChecker = () => {
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [isChecking, setIsChecking] = 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 () => {
if (!dismissedUpdatesLoaded) {
return;
}
setIsChecking(true);
try {
@ -59,11 +103,15 @@ export const useUpdateChecker = () => {
compareVersions(matchingDownload.version, currentVersion.version) > 0;
if (hasUpdate) {
setUpdateInfo({
currentVersion,
availableUpdate: matchingDownload,
});
setShowUpdateModal(true);
const updateKey = `${currentVersion.path}-${matchingDownload.version}`;
if (!dismissedUpdates.has(updateKey)) {
setUpdateInfo({
currentVersion,
availableUpdate: matchingDownload,
});
setShowUpdateModal(true);
}
}
}
} catch (error) {
@ -74,12 +122,18 @@ export const useUpdateChecker = () => {
} finally {
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);
setUpdateInfo(null);
}, []);
}, [updateInfo, dismissedUpdates, saveDismissedUpdates]);
return {
updateInfo,

View file

@ -9,7 +9,7 @@ export const getAssetDescription = (assetName: string): string => {
}
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)) {

View file

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