add more network settings, start detecting GPU and CPU capabilities

This commit is contained in:
Egor 2025-08-14 13:11:07 -07:00
parent 795bb9ec51
commit 867ced21f9
32 changed files with 1536 additions and 660 deletions

View file

@ -17,3 +17,4 @@
- Follow the ESLint configuration (includes SonarJS and security rules)
- Never create tests, docs or github workflows
- Stop asking me to run the "dev" script to test changes
- Try to move helper functions from component code to their own separate files to help minimize clutter

1
.gitignore vendored
View file

@ -6,3 +6,4 @@ release/
*.log
.DS_Store
Thumbs.db
.VSCodeCounter/

View file

@ -1,11 +1,6 @@
# Prettier-specific ignores (beyond .gitignore)
# Keep files that should be tracked by git but not formatted by prettier
# IDE/Editor config files that should exist in repo but not be formatted
.vscode/settings.json
# Coverage reports (if kept in repo for CI)
coverage/
# Auto-generated files that might be committed
package-lock.json

View file

@ -22,6 +22,8 @@
"bundler",
"bundling",
"can",
"clblast",
"clinfo",
"classList",
"className",
"cloneNode",
@ -70,6 +72,7 @@
"getDerivedStateFromError",
"getDerivedStateFromProps",
"getSnapshotBeforeUpdate",
"ggml",
"gguf",
"GGUF",
"gif",
@ -107,15 +110,21 @@
"lineheight",
"linted",
"localhost",
"lscpu",
"machdep",
"mainWindow",
"minified",
"minify",
"moz",
"multiuser",
"multiplayer",
"namespace",
"namespaces",
"newpackagename",
"nocertify",
"nocuda",
"nodeIntegration",
"noheader",
"nsis",
"nvidia",
"oldpc",
@ -143,12 +152,14 @@
"readonly",
"refactor",
"refactoring",
"remotetunnel",
"removeAttribute",
"removeChild",
"removeEventListener",
"repo",
"repos",
"rocm",
"rocminfo",
"rollup",
"sass",
"scrollbar",
@ -182,6 +193,7 @@
"treemap",
"treeshake",
"treeshaking",
"trycloudflare",
"tsconfig",
"tsx",
"ttf",
@ -205,12 +217,15 @@
"vite",
"vitejs",
"vitest",
"vulkan",
"vulkaninfo",
"wayland",
"webContents",
"webkit",
"webp",
"webpack",
"webSecurity",
"websearch",
"whitespace",
"wmic",
"woff",

37
package-lock.json generated
View file

@ -14,7 +14,8 @@
"jiti": "^2.5.1",
"lucide-react": "^0.539.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
"react-dom": "^19.1.1",
"systeminformation": "^5.27.7"
},
"devDependencies": {
"@cspell/eslint-plugin": "^9.2.0",
@ -22,6 +23,7 @@
"@types/node": "^24.2.1",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@types/systeminformation": "^3.23.1",
"@typescript-eslint/eslint-plugin": "^8.39.1",
"@typescript-eslint/parser": "^8.39.1",
"@vitejs/plugin-react": "^5.0.0",
@ -3281,6 +3283,13 @@
"@types/node": "*"
}
},
"node_modules/@types/systeminformation": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@types/systeminformation/-/systeminformation-3.23.1.tgz",
"integrity": "sha512-0/y5m3PQLhQL7UM8MEvwFuLoTT6k5bamcZyjap12S0iHb33ngKfDDqCSnIaCaKWvRE41R/9BsZov1aE6hvcpEg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/verror": {
"version": "1.10.11",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz",
@ -11428,6 +11437,32 @@
"url": "https://opencollective.com/synckit"
}
},
"node_modules/systeminformation": {
"version": "5.27.7",
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.27.7.tgz",
"integrity": "sha512-saaqOoVEEFaux4v0K8Q7caiauRwjXC4XbD2eH60dxHXbpKxQ8kH9Rf7Jh+nryKpOUSEFxtCdBlSUx0/lO6rwRg==",
"license": "MIT",
"os": [
"darwin",
"linux",
"win32",
"freebsd",
"openbsd",
"netbsd",
"sunos",
"android"
],
"bin": {
"systeminformation": "lib/cli.js"
},
"engines": {
"node": ">=8.0.0"
},
"funding": {
"type": "Buy me a coffee",
"url": "https://www.buymeacoffee.com/systeminfo"
}
},
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",

View file

@ -51,6 +51,7 @@
"@types/node": "^24.2.1",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@types/systeminformation": "^3.23.1",
"@typescript-eslint/eslint-plugin": "^8.39.1",
"@typescript-eslint/parser": "^8.39.1",
"@vitejs/plugin-react": "^5.0.0",
@ -80,7 +81,8 @@
"jiti": "^2.5.1",
"lucide-react": "^0.539.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
"react-dom": "^19.1.1",
"systeminformation": "^5.27.7"
},
"build": {
"appId": "com.friendly-kobold.app",

View file

@ -14,9 +14,9 @@ import {
useMantineColorScheme,
} from '@mantine/core';
import { Settings, ArrowLeft } from 'lucide-react';
import { DownloadScreen } from '@/screens/DownloadScreen';
import { LaunchScreen } from '@/screens/LaunchScreen';
import { InterfaceScreen } from '@/screens/InterfaceScreen';
import { DownloadScreen } from '@/components/screens/DownloadScreen';
import { LaunchScreen } from '@/components/screens/LaunchScreen';
import { InterfaceScreen } from '@/components/screens/InterfaceScreen';
import { UpdateDialog } from '@/components/UpdateDialog';
import { SettingsModal } from '@/components/SettingsModal';
import { ScreenTransition } from '@/components/ScreenTransition';

View file

@ -1,110 +0,0 @@
import { Select, Text, Badge, Group } from '@mantine/core';
import { File } from 'lucide-react';
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import type { ConfigFile } from '@/types';
interface ConfigFileSelectProps {
configFiles: ConfigFile[];
selectedFile: string | null;
loading?: boolean;
onFileSelection: (fileName: string) => void;
}
interface SelectItemProps extends ComponentPropsWithoutRef<'div'> {
label: string;
extension: string;
}
const getBadgeColor = (extension: string) => {
switch (extension.toLowerCase()) {
case '.kcpps':
return 'blue';
case '.kcppt':
return 'green';
default:
return 'gray';
}
};
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>
)
);
SelectItem.displayName = 'SelectItem';
export const ConfigFileSelect = ({
configFiles,
selectedFile,
loading = false,
onFileSelection,
}: ConfigFileSelectProps) => {
if (loading) {
return (
<Text c="dimmed" ta="center">
Loading configuration files...
</Text>
);
}
if (configFiles.length === 0) {
return (
<Text c="dimmed" ta="center">
No configuration files found in the installation directory.
<br />
Please ensure your .kcpps or .kcppt files are in the correct location.
</Text>
);
}
const selectData = configFiles.map((file) => {
const extension = file.name.split('.').pop() || '';
const nameWithoutExtension = file.name.replace(`.${extension}`, '');
return {
value: file.name,
label: nameWithoutExtension, // Clean label for selected value
extension: `.${extension}`, // Store extension separately
};
});
return (
<Select
label="Configuration File"
placeholder="Select a configuration file"
value={selectedFile}
onChange={(value) => value && onFileSelection(value)}
data={selectData}
leftSection={<File size={16} />}
searchable
clearable={false}
w="100%"
filter={({ options, search }) =>
options.filter((option) => {
if ('label' in option) {
return option.label
.toLowerCase()
.includes(search.toLowerCase().trim());
}
return false;
})
}
renderOption={({ option }) => {
// Find the original data item to get the extension
const dataItem = selectData.find((item) => item.value === option.value);
const extension = dataItem?.extension || '';
return <SelectItem label={option.label} extension={extension} />;
}}
/>
);
};

View file

@ -0,0 +1,44 @@
import { ActionIcon, Tooltip, useMantineColorScheme } from '@mantine/core';
import { Info } from 'lucide-react';
interface InfoTooltipProps {
label: string;
multiline?: boolean;
width?: number;
}
export const InfoTooltip = ({
label,
multiline = true,
width = 300,
}: InfoTooltipProps) => {
const { colorScheme } = useMantineColorScheme();
const isDark = colorScheme === 'dark';
return (
<Tooltip
label={label}
multiline={multiline}
w={width}
withArrow
color={isDark ? 'dark' : 'gray'}
styles={{
tooltip: {
backgroundColor: isDark
? 'var(--mantine-color-dark-6)'
: 'var(--mantine-color-gray-1)',
color: isDark
? 'var(--mantine-color-gray-0)'
: 'var(--mantine-color-dark-7)',
border: isDark
? '1px solid var(--mantine-color-dark-4)'
: '1px solid var(--mantine-color-gray-3)',
},
}}
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
);
};

View file

@ -1,5 +1,11 @@
import { useState, useEffect, useRef } from 'react';
import { Box, ScrollArea, Text, ActionIcon } from '@mantine/core';
import {
Box,
ScrollArea,
Text,
ActionIcon,
useMantineColorScheme,
} from '@mantine/core';
import { ChevronDown } from 'lucide-react';
interface TerminalTabProps {
@ -11,6 +17,7 @@ export const TerminalTab = ({
onServerReady,
serverOnly,
}: TerminalTabProps) => {
const { colorScheme } = useMantineColorScheme();
const [terminalContent, setTerminalContent] = useState<string>('');
const [isUserScrolling, setIsUserScrolling] = useState<boolean>(false);
const [shouldAutoScroll, setShouldAutoScroll] = useState<boolean>(true);
@ -105,7 +112,10 @@ export const TerminalTab = ({
height: '80vh',
display: 'flex',
flexDirection: 'column',
backgroundColor: 'var(--mantine-color-dark-8)',
backgroundColor:
colorScheme === 'dark'
? 'var(--mantine-color-dark-8)'
: 'var(--mantine-color-gray-0)',
borderRadius: 'inherit',
position: 'relative',
}}
@ -137,7 +147,10 @@ export const TerminalTab = ({
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
color: 'var(--mantine-color-gray-0)',
color:
colorScheme === 'dark'
? 'var(--mantine-color-gray-0)'
: 'var(--mantine-color-dark-9)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}

View file

@ -1,13 +1,5 @@
import {
Stack,
Text,
Group,
TextInput,
ActionIcon,
Tooltip,
Switch,
} from '@mantine/core';
import { Info } from 'lucide-react';
import { Stack, Text, Group, TextInput, Checkbox } from '@mantine/core';
import { InfoTooltip } from '@/components/InfoTooltip';
interface AdvancedTabProps {
additionalArguments: string;
@ -28,24 +20,7 @@ export const AdvancedTab = ({
<Text size="sm" fw={500}>
Additional arguments
</Text>
<Tooltip
label="Additional command line arguments to pass to the KoboldCPP binary. Leave this empty if you don't know what they are."
multiline
w={300}
withArrow
color="dark"
styles={{
tooltip: {
backgroundColor: 'var(--mantine-color-dark-6)',
color: 'var(--mantine-color-gray-0)',
border: '1px solid var(--mantine-color-dark-4)',
},
}}
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
<InfoTooltip label="Additional command line arguments to pass to the KoboldCPP binary. Leave this empty if you don't know what they are." />
</Group>
<TextInput
placeholder="Additional command line arguments"
@ -57,33 +32,14 @@ export const AdvancedTab = ({
</div>
<div>
<Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}>
Server-only mode
</Text>
<Tooltip
label="In server-only mode, the KoboldAI Lite web UI won't be displayed. Use this if you'll be using your own frontend to interact with the LLM."
multiline
w={300}
withArrow
color="dark"
styles={{
tooltip: {
backgroundColor: 'var(--mantine-color-dark-6)',
color: 'var(--mantine-color-gray-0)',
border: '1px solid var(--mantine-color-dark-4)',
},
}}
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
<Group gap="xs" align="center">
<Checkbox
checked={serverOnly}
onChange={(event) => onServerOnlyChange(event.currentTarget.checked)}
label="Server-only mode"
/>
<InfoTooltip label="In server-only mode, the KoboldAI Lite web UI won't be displayed. Use this if you'll be using your own frontend." />
</Group>
<Switch
checked={serverOnly}
onChange={(event) => onServerOnlyChange(event.currentTarget.checked)}
/>
</div>
</Stack>
);

View file

@ -1,6 +1,15 @@
import { Stack, Text, Group, Button, ActionIcon, Menu } from '@mantine/core';
import { RotateCcw, Save, Settings2 } from 'lucide-react';
import { ConfigFileSelect } from '@/components/ConfigFileSelect';
import {
Stack,
Text,
Group,
Button,
ActionIcon,
Menu,
Select,
Badge,
} from '@mantine/core';
import { RotateCcw, Save, Settings2, File } from 'lucide-react';
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import type { ConfigFile } from '@/types';
interface ConfigurationManagerProps {
@ -12,6 +21,39 @@ interface ConfigurationManagerProps {
onUpdateCurrent: () => void;
}
interface SelectItemProps extends ComponentPropsWithoutRef<'div'> {
label: string;
extension: string;
}
const getBadgeColor = (extension: string) => {
switch (extension.toLowerCase()) {
case '.kcpps':
return 'blue';
case '.kcppt':
return 'green';
default:
return 'gray';
}
};
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>
)
);
SelectItem.displayName = 'SelectItem';
export const ConfigurationManager = ({
configFiles,
selectedFile,
@ -43,16 +85,67 @@ export const ConfigurationManager = ({
</Menu.Item>
</Menu.Dropdown>
</Menu>
<ActionIcon variant="light" onClick={onRefresh} size="lg">
<ActionIcon
variant="light"
onClick={onRefresh}
size="lg"
aria-label="Refresh configuration files"
title="Refresh configuration files"
>
<RotateCcw size={16} />
</ActionIcon>
</Group>
</Group>
<ConfigFileSelect
configFiles={configFiles}
selectedFile={selectedFile}
onFileSelection={onFileSelection}
/>
{configFiles.length === 0 ? (
<Text c="dimmed" ta="center">
No configuration files found in the installation directory.
<br />
Please ensure your .kcpps or .kcppt files are in the correct location.
</Text>
) : (
(() => {
const selectData = configFiles.map((file) => {
const extension = file.name.split('.').pop() || '';
const nameWithoutExtension = file.name.replace(`.${extension}`, '');
return {
value: file.name,
label: nameWithoutExtension,
extension: `.${extension}`,
};
});
return (
<Select
placeholder="Select a configuration file"
value={selectedFile}
onChange={(value) => value && onFileSelection(value)}
data={selectData}
leftSection={<File size={16} />}
searchable
clearable={false}
w="100%"
filter={({ options, search }) =>
options.filter((option) => {
if ('label' in option) {
return option.label
.toLowerCase()
.includes(search.toLowerCase().trim());
}
return false;
})
}
renderOption={({ option }) => {
const dataItem = selectData.find(
(item) => item.value === option.value
);
const extension = dataItem?.extension || '';
return <SelectItem label={option.label} extension={extension} />;
}}
/>
);
})()
)}
</Stack>
);

View file

@ -4,12 +4,12 @@ import {
Group,
TextInput,
Button,
ActionIcon,
Tooltip,
Checkbox,
Slider,
} from '@mantine/core';
import { File, Info, Search } from 'lucide-react';
import { File, Search } from 'lucide-react';
import { InfoTooltip } from '@/components/InfoTooltip';
import { getInputValidationState } from '@/utils/validation';
interface GeneralTabProps {
modelPath: string;
@ -33,165 +33,144 @@ export const GeneralTab = ({
onGpuLayersChange,
onAutoGpuLayersChange,
onContextSizeChange,
}: GeneralTabProps) => (
<Stack gap="lg">
<div>
<Text size="sm" fw={500} mb="xs">
Model File *
</Text>
<Group gap="xs">
<TextInput
placeholder="Select a .gguf model file"
value={modelPath}
onChange={(event) => onModelPathChange(event.currentTarget.value)}
style={{ flex: 1 }}
required
/>
<Button
onClick={onSelectModelFile}
variant="light"
leftSection={<File size={16} />}
>
Browse
</Button>
<Button
onClick={() => {
window.electronAPI.app.openExternal(
'https://huggingface.co/models?pipeline_tag=text-generation&library=gguf&sort=trending'
);
}}
variant="outline"
leftSection={<Search size={16} />}
>
Search HF
</Button>
</Group>
</div>
}: GeneralTabProps) => {
const validationState = getInputValidationState(modelPath);
<div>
<Group justify="space-between" align="center" mb="xs">
<Group gap="xs" align="center">
<Text size="sm" fw={500}>
GPU Layers
</Text>
<Tooltip
label="The number of layer's to offload to your GPU's VRAM. Ideally the entire LLM should fit inside the VRAM for optimal performance."
multiline
w={300}
withArrow
color="dark"
styles={{
tooltip: {
backgroundColor: 'var(--mantine-color-dark-6)',
color: 'var(--mantine-color-gray-0)',
border: '1px solid var(--mantine-color-dark-4)',
},
}}
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
</Group>
<Group gap="lg" align="center">
<Group gap="xs" align="center">
<Checkbox
label="Auto"
checked={autoGpuLayers}
onChange={(event) =>
onAutoGpuLayersChange(event.currentTarget.checked)
const getInputColor = () => {
switch (validationState) {
case 'valid':
return 'green';
case 'invalid':
return 'red';
default:
return undefined;
}
};
const getHelperText = () => {
if (!modelPath.trim()) return undefined;
if (validationState === 'invalid') {
return 'Enter a valid URL or file path to the .gguf';
}
return undefined;
};
return (
<Stack gap="lg">
<div>
<Text size="sm" fw={500} mb="xs">
Text Model File *
</Text>
<Group gap="xs" align="flex-start">
<div style={{ flex: 1 }}>
<TextInput
placeholder="Select a .gguf model file or enter a direct URL to file"
value={modelPath}
onChange={(event) => onModelPathChange(event.currentTarget.value)}
required
color={getInputColor()}
error={
validationState === 'invalid' ? getHelperText() : undefined
}
size="sm"
/>
<Tooltip
label="Automatically try to allocate the GPU layers based on available VRAM."
multiline
w={300}
withArrow
color="dark"
styles={{
tooltip: {
backgroundColor: 'var(--mantine-color-dark-6)',
color: 'var(--mantine-color-gray-0)',
border: '1px solid var(--mantine-color-dark-4)',
},
}}
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
</div>
<Button
onClick={onSelectModelFile}
variant="light"
leftSection={<File size={16} />}
>
Browse
</Button>
<Button
onClick={() => {
window.electronAPI.app.openExternal(
'https://huggingface.co/models?pipeline_tag=text-generation&library=gguf&sort=trending'
);
}}
variant="outline"
leftSection={<Search size={16} />}
>
Search HF
</Button>
</Group>
</div>
<div>
<Group justify="space-between" align="center" mb="xs">
<Group gap="xs" align="center">
<Text size="sm" fw={500}>
GPU Layers
</Text>
<InfoTooltip label="The number of layer's to offload to your GPU's VRAM. Ideally the entire LLM should fit inside the VRAM for optimal performance." />
</Group>
<Group gap="lg" align="center">
<Group gap="xs" align="center">
<Checkbox
label="Auto"
checked={autoGpuLayers}
onChange={(event) =>
onAutoGpuLayersChange(event.currentTarget.checked)
}
size="sm"
/>
<InfoTooltip label="Automatically try to allocate the GPU layers based on available VRAM." />
</Group>
<TextInput
value={gpuLayers.toString()}
onChange={(event) =>
onGpuLayersChange(Number(event.target.value) || 0)
}
type="number"
min={0}
max={100}
step={1}
size="sm"
w={80}
disabled={autoGpuLayers}
/>
</Group>
</Group>
<Slider
value={gpuLayers}
min={0}
max={100}
step={1}
onChange={onGpuLayersChange}
disabled={autoGpuLayers}
/>
</div>
<div>
<Group justify="space-between" align="center" mb="xs">
<Group gap="xs" align="center">
<Text size="sm" fw={500}>
Context Size
</Text>
<InfoTooltip label="Controls the memory allocated for maximum context size. The larger the context, the larger the required memory." />
</Group>
<TextInput
value={gpuLayers.toString()}
value={contextSize?.toString() || ''}
onChange={(event) =>
onGpuLayersChange(Number(event.target.value) || 0)
onContextSizeChange(Number(event.target.value) || 256)
}
type="number"
min={0}
max={100}
step={1}
min={256}
max={131072}
step={256}
size="sm"
w={80}
disabled={autoGpuLayers}
w={100}
/>
</Group>
</Group>
<Slider
value={gpuLayers}
min={0}
max={100}
step={1}
onChange={onGpuLayersChange}
disabled={autoGpuLayers}
/>
</div>
<div>
<Group justify="space-between" align="center" mb="xs">
<Group gap="xs" align="center">
<Text size="sm" fw={500}>
Context Size
</Text>
<Tooltip
label="Controls the memory allocated for maximum context size. The larger the context, the larger the required memory."
multiline
w={300}
withArrow
color="dark"
styles={{
tooltip: {
backgroundColor: 'var(--mantine-color-dark-6)',
color: 'var(--mantine-color-gray-0)',
border: '1px solid var(--mantine-color-dark-4)',
},
}}
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
</Group>
<TextInput
value={contextSize?.toString() || ''}
onChange={(event) =>
onContextSizeChange(Number(event.target.value) || 256)
}
type="number"
<Slider
value={contextSize}
min={256}
max={131072}
step={256}
size="sm"
w={100}
step={1}
onChange={onContextSizeChange}
/>
</Group>
<Slider
value={contextSize}
min={256}
max={131072}
step={1}
onChange={onContextSizeChange}
/>
</div>
</Stack>
);
</div>
</Stack>
);
};

View file

@ -1,96 +1,150 @@
import {
Stack,
Text,
TextInput,
Group,
ActionIcon,
Tooltip,
} from '@mantine/core';
import { Info } from 'lucide-react';
import { Stack, Text, TextInput, Group, Checkbox } from '@mantine/core';
import { InfoTooltip } from '@/components/InfoTooltip';
interface NetworkTabProps {
port: number;
host: string;
multiuser: boolean;
multiplayer: boolean;
remotetunnel: boolean;
nocertify: boolean;
websearch: boolean;
onPortChange: (port: number) => void;
onHostChange: (host: string) => void;
onMultiuserChange: (multiuser: boolean) => void;
onMultiplayerChange: (multiplayer: boolean) => void;
onRemotetunnelChange: (remotetunnel: boolean) => void;
onNocertifyChange: (nocertify: boolean) => void;
onWebsearchChange: (websearch: boolean) => void;
}
export const NetworkTab = ({
port,
host,
multiuser,
multiplayer,
remotetunnel,
nocertify,
websearch,
onPortChange,
onHostChange,
onMultiuserChange,
onMultiplayerChange,
onRemotetunnelChange,
onNocertifyChange,
onWebsearchChange,
}: NetworkTabProps) => (
<Stack gap="lg">
{/* Port */}
<div>
<Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}>
Port
</Text>
<Tooltip
label="The port number on which KoboldCpp will listen for connections. Default is 5001."
multiline
w={300}
withArrow
color="dark"
styles={{
tooltip: {
backgroundColor: 'var(--mantine-color-dark-6)',
color: 'var(--mantine-color-gray-0)',
border: '1px solid var(--mantine-color-dark-4)',
},
}}
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
</Group>
<TextInput
placeholder="5001"
value={port.toString()}
onChange={(event) =>
onPortChange(Number(event.currentTarget.value) || 5001)
}
type="number"
min={1}
max={65535}
w={120}
/>
</div>
<Group gap="lg" align="flex-start">
<div>
<Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}>
Host
</Text>
<InfoTooltip label="The hostname or IP address on which KoboldCpp will bind its webserver to." />
</Group>
<TextInput
placeholder="localhost"
value={host}
onChange={(event) => onHostChange(event.currentTarget.value)}
style={{ maxWidth: 200 }}
/>
</div>
<div>
<Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}>
Port
</Text>
<InfoTooltip label="The port number on which KoboldCpp will listen for connections. Default is 5001." />
</Group>
<TextInput
placeholder="5001"
value={port.toString()}
onChange={(event) =>
onPortChange(Number(event.currentTarget.value) || 5001)
}
type="number"
min={1}
max={65535}
w={120}
/>
</div>
</Group>
{/* Host */}
<div>
<Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}>
Host
</Text>
<Tooltip
label="The hostname or IP address on which KoboldCpp will bind its webserver to."
multiline
w={300}
withArrow
color="dark"
styles={{
tooltip: {
backgroundColor: 'var(--mantine-color-dark-6)',
color: 'var(--mantine-color-gray-0)',
border: '1px solid var(--mantine-color-dark-4)',
},
}}
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
</Group>
<TextInput
placeholder="localhost"
value={host}
onChange={(event) => onHostChange(event.currentTarget.value)}
style={{ maxWidth: 200 }}
/>
<Text size="sm" fw={500} mb="md">
Network Options
</Text>
<Stack gap="md">
{/* First row: Multiuser and Multiplayer */}
<Group gap="lg" align="flex-start" wrap="nowrap">
<div style={{ minWidth: '250px' }}>
<Group gap="xs" align="center">
<Checkbox
checked={multiuser}
onChange={(event) =>
onMultiuserChange(event.currentTarget.checked)
}
label="Multiuser Mode"
/>
<InfoTooltip label="Allows requests by multiple different clients to be queued and handled in sequence." />
</Group>
</div>
<div style={{ minWidth: '250px' }}>
<Group gap="xs" align="center">
<Checkbox
checked={multiplayer}
onChange={(event) =>
onMultiplayerChange(event.currentTarget.checked)
}
label="Shared Multiplayer"
/>
<InfoTooltip label="Hosts a shared multiplayer session" />
</Group>
</div>
</Group>
{/* Second row: Remote Tunnel and No Certify */}
<Group gap="lg" align="flex-start" wrap="nowrap">
<div style={{ minWidth: '250px' }}>
<Group gap="xs" align="center">
<Checkbox
checked={remotetunnel}
onChange={(event) =>
onRemotetunnelChange(event.currentTarget.checked)
}
label="Remote Tunnel"
/>
<InfoTooltip label="Creates a trycloudflare tunnel. Allows you to access koboldcpp from other devices over an internet URL." />
</Group>
</div>
<div style={{ minWidth: '250px' }}>
<Group gap="xs" align="center">
<Checkbox
checked={nocertify}
onChange={(event) =>
onNocertifyChange(event.currentTarget.checked)
}
label="No Certify Mode (Insecure)"
/>
<InfoTooltip label="Allows insecure SSL connections. Use this if you have SSL cert errors and need to bypass certificate restrictions." />
</Group>
</div>
</Group>
{/* Third row: WebSearch (single item) */}
<Group gap="xs" align="center">
<Checkbox
checked={websearch}
onChange={(event) => onWebsearchChange(event.currentTarget.checked)}
label="Enable WebSearch"
/>
<InfoTooltip label="Enable the local search engine proxy so Web Searches can be done." />
</Group>
</Stack>
</div>
</Stack>
);

View file

@ -11,7 +11,7 @@ import {
isAssetRecommended,
sortAssetsByRecommendation,
} from '@/utils/assets';
import { ROCM } from '@/constants/app';
import { ROCM } from '@/constants';
import type { GitHubAsset, GitHubRelease } from '@/types';
interface DownloadScreenProps {

View file

@ -39,6 +39,11 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
additionalArguments,
port,
host,
multiuser,
multiplayer,
remotetunnel,
nocertify,
websearch,
parseAndApplyConfigFile,
loadSavedSettings,
loadConfigFromFile,
@ -51,6 +56,11 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
handleAdditionalArgumentsChange,
handlePortChange,
handleHostChange,
handleMultiuserChange,
handleMultiplayerChange,
handleRemotetunnelChange,
handleNocertifyChange,
handleWebsearchChange,
} = useLaunchConfig();
const loadConfigFiles = useCallback(async () => {
@ -131,6 +141,31 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
setHasUnsavedChanges(true);
};
const handleMultiuserChangeWithTracking = (multiuser: boolean) => {
handleMultiuserChange(multiuser);
setHasUnsavedChanges(true);
};
const handleMultiplayerChangeWithTracking = (multiplayer: boolean) => {
handleMultiplayerChange(multiplayer);
setHasUnsavedChanges(true);
};
const handleRemotetunnelChangeWithTracking = (remotetunnel: boolean) => {
handleRemotetunnelChange(remotetunnel);
setHasUnsavedChanges(true);
};
const handleNocertifyChangeWithTracking = (nocertify: boolean) => {
handleNocertifyChange(nocertify);
setHasUnsavedChanges(true);
};
const handleWebsearchChangeWithTracking = (websearch: boolean) => {
handleWebsearchChange(websearch);
setHasUnsavedChanges(true);
};
useEffect(() => {
void loadConfigFiles();
@ -179,6 +214,26 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
args.push('--host', host);
}
if (multiuser) {
args.push('--multiuser', '1');
}
if (multiplayer) {
args.push('--multiplayer');
}
if (remotetunnel) {
args.push('--remotetunnel');
}
if (nocertify) {
args.push('--nocertify');
}
if (websearch) {
args.push('--websearch');
}
if (additionalArguments.trim()) {
const additionalArgs = additionalArguments.trim().split(/\s+/);
args.push(...additionalArgs);
@ -256,55 +311,65 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
<Tabs.Tab value="general">General</Tabs.Tab>
<Tabs.Tab value="advanced">Advanced</Tabs.Tab>
<Tabs.Tab value="image">Image Generation</Tabs.Tab>
<Tabs.Tab value="network">Network</Tabs.Tab>
<Tabs.Tab value="image" disabled>
Image Generation
</Tabs.Tab>
<Tabs.Tab value="advanced">Advanced</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="general" pt="md">
<GeneralTab
modelPath={modelPath}
gpuLayers={gpuLayers}
autoGpuLayers={autoGpuLayers}
contextSize={contextSize}
onModelPathChange={handleModelPathChangeWithTracking}
onSelectModelFile={handleSelectModelFile}
onGpuLayersChange={handleGpuLayersChangeWithTracking}
onAutoGpuLayersChange={handleAutoGpuLayersChangeWithTracking}
onContextSizeChange={handleContextSizeChangeWithTracking}
/>
</Tabs.Panel>
<div style={{ minHeight: '300px' }}>
<Tabs.Panel value="general" pt="md">
<GeneralTab
modelPath={modelPath}
gpuLayers={gpuLayers}
autoGpuLayers={autoGpuLayers}
contextSize={contextSize}
onModelPathChange={handleModelPathChangeWithTracking}
onSelectModelFile={handleSelectModelFile}
onGpuLayersChange={handleGpuLayersChangeWithTracking}
onAutoGpuLayersChange={handleAutoGpuLayersChangeWithTracking}
onContextSizeChange={handleContextSizeChangeWithTracking}
/>
</Tabs.Panel>
<Tabs.Panel value="advanced" pt="md">
<AdvancedTab
additionalArguments={additionalArguments}
serverOnly={serverOnly}
onAdditionalArgumentsChange={
handleAdditionalArgumentsChangeWithTracking
}
onServerOnlyChange={handleServerOnlyChangeWithTracking}
/>
</Tabs.Panel>
<Tabs.Panel value="advanced" pt="md">
<AdvancedTab
additionalArguments={additionalArguments}
serverOnly={serverOnly}
onAdditionalArgumentsChange={
handleAdditionalArgumentsChangeWithTracking
}
onServerOnlyChange={handleServerOnlyChangeWithTracking}
/>
</Tabs.Panel>
<Tabs.Panel value="network" pt="md">
<NetworkTab
port={port}
host={host}
onPortChange={handlePortChangeWithTracking}
onHostChange={handleHostChangeWithTracking}
/>
</Tabs.Panel>
<Tabs.Panel value="network" pt="md">
<NetworkTab
port={port}
host={host}
multiuser={multiuser}
multiplayer={multiplayer}
remotetunnel={remotetunnel}
nocertify={nocertify}
websearch={websearch}
onPortChange={handlePortChangeWithTracking}
onHostChange={handleHostChangeWithTracking}
onMultiuserChange={handleMultiuserChangeWithTracking}
onMultiplayerChange={handleMultiplayerChangeWithTracking}
onRemotetunnelChange={handleRemotetunnelChangeWithTracking}
onNocertifyChange={handleNocertifyChangeWithTracking}
onWebsearchChange={handleWebsearchChangeWithTracking}
/>
</Tabs.Panel>
<Tabs.Panel value="image" pt="md">
<Stack gap="lg" align="center" py="xl">
<Text c="dimmed" ta="center">
Image generation configuration will be available in a future
update.
</Text>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="image" pt="md">
<Stack gap="lg" align="center" py="xl">
<Text c="dimmed" ta="center">
Image generation configuration will be available in a future
update.
</Text>
</Stack>
</Tabs.Panel>
</div>
</Tabs>
</Card>

View file

@ -2,10 +2,6 @@ export const APP_NAME = 'friendly-kobold';
export const CONFIG_FILE_NAME = 'config.json';
export const DIALOG_TITLES = {
SELECT_INSTALL_DIR: 'Select the Friendly Kobold Installation Directory',
} as const;
export const GITHUB_API = {
BASE_URL: 'https://api.github.com',
KOBOLDCPP_REPO: 'LostRuins/koboldcpp',

View file

@ -10,7 +10,13 @@ export const useLaunchConfig = () => {
const [additionalArguments, setAdditionalArguments] = useState<string>('');
const [port, setPort] = useState<number>(5001);
const [host, setHost] = useState<string>('localhost');
const [multiuser, setMultiuser] = useState<boolean>(false);
const [multiplayer, setMultiplayer] = useState<boolean>(false);
const [remotetunnel, setRemotetunnel] = useState<boolean>(false);
const [nocertify, setNocertify] = useState<boolean>(false);
const [websearch, setWebsearch] = useState<boolean>(false);
// eslint-disable-next-line sonarjs/cognitive-complexity
const parseAndApplyConfigFile = useCallback(async (configPath: string) => {
const configData =
await window.electronAPI.kobold.parseConfigFile(configPath);
@ -42,11 +48,46 @@ export const useLaunchConfig = () => {
} else {
setHost('localhost');
}
if (typeof configData.multiuser === 'number') {
setMultiuser(configData.multiuser === 1);
} else {
setMultiuser(false);
}
if (typeof configData.multiplayer === 'boolean') {
setMultiplayer(configData.multiplayer);
} else {
setMultiplayer(false);
}
if (typeof configData.remotetunnel === 'boolean') {
setRemotetunnel(configData.remotetunnel);
} else {
setRemotetunnel(false);
}
if (typeof configData.nocertify === 'boolean') {
setNocertify(configData.nocertify);
} else {
setNocertify(false);
}
if (typeof configData.websearch === 'boolean') {
setWebsearch(configData.websearch);
} else {
setWebsearch(false);
}
} else {
setGpuLayers(0);
setContextSize(2048);
setPort(5001);
setHost('localhost');
setMultiuser(false);
setMultiplayer(false);
setRemotetunnel(false);
setNocertify(false);
setWebsearch(false);
}
}, []);
@ -59,6 +100,11 @@ export const useLaunchConfig = () => {
setContextSize(2048);
setPort(5001);
setHost('localhost');
setMultiuser(false);
setMultiplayer(false);
setRemotetunnel(false);
setNocertify(false);
setWebsearch(false);
}, []);
const loadConfigFromFile = useCallback(
@ -136,6 +182,26 @@ export const useLaunchConfig = () => {
setHost(value);
}, []);
const handleMultiuserChange = useCallback((checked: boolean) => {
setMultiuser(checked);
}, []);
const handleMultiplayerChange = useCallback((checked: boolean) => {
setMultiplayer(checked);
}, []);
const handleRemotetunnelChange = useCallback((checked: boolean) => {
setRemotetunnel(checked);
}, []);
const handleNocertifyChange = useCallback((checked: boolean) => {
setNocertify(checked);
}, []);
const handleWebsearchChange = useCallback((checked: boolean) => {
setWebsearch(checked);
}, []);
return {
serverOnly,
gpuLayers,
@ -145,6 +211,11 @@ export const useLaunchConfig = () => {
additionalArguments,
port,
host,
multiuser,
multiplayer,
remotetunnel,
nocertify,
websearch,
parseAndApplyConfigFile,
loadSavedSettings,
@ -158,5 +229,10 @@ export const useLaunchConfig = () => {
handleAdditionalArgumentsChange,
handlePortChange,
handleHostChange,
handleMultiuserChange,
handleMultiplayerChange,
handleRemotetunnelChange,
handleNocertifyChange,
handleWebsearchChange,
};
};

View file

@ -7,16 +7,16 @@ import { WindowManager } from '@/main/managers/WindowManager';
import { ConfigManager } from '@/main/managers/ConfigManager';
import { KoboldCppManager } from '@/main/managers/KoboldCppManager';
import { GitHubService } from '@/main/services/GitHubService';
import { GPUService } from '@/main/services/GPUService';
import { HardwareService } from '@/main/services/HardwareService';
import { IPCHandlers } from '@/main/utils/IPCHandlers';
import { APP_NAME, CONFIG_FILE_NAME } from '@/constants/app';
import { APP_NAME, CONFIG_FILE_NAME } from '@/constants';
class FriendlyKoboldApp {
private windowManager: WindowManager;
private configManager: ConfigManager;
private koboldManager: KoboldCppManager;
private githubService: GitHubService;
private gpuService: GPUService;
private hardwareService: HardwareService;
private ipcHandlers: IPCHandlers;
constructor() {
@ -25,7 +25,7 @@ class FriendlyKoboldApp {
this.windowManager = new WindowManager(this.configManager);
this.githubService = new GitHubService();
this.gpuService = new GPUService();
this.hardwareService = new HardwareService();
this.koboldManager = new KoboldCppManager(
this.configManager,
this.githubService,
@ -35,7 +35,7 @@ class FriendlyKoboldApp {
this.koboldManager,
this.configManager,
this.githubService,
this.gpuService
this.hardwareService
);
}

View file

@ -12,7 +12,7 @@ import { dialog } from 'electron';
import { GitHubService } from '@/main/services/GitHubService';
import { ConfigManager } from '@/main/managers/ConfigManager';
import { WindowManager } from '@/main/managers/WindowManager';
import { DIALOG_TITLES, ROCM } from '@/constants/app';
import { ROCM } from '@/constants';
interface GitHubAsset {
name: string;
@ -397,7 +397,7 @@ export class KoboldCppManager {
async selectInstallDirectory(): Promise<string | null> {
const result = await dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
title: DIALOG_TITLES.SELECT_INSTALL_DIR,
title: 'Select the Friendly Kobold Installation Directory',
defaultPath: this.installDir,
buttonLabel: 'Select Directory',
});

View file

@ -0,0 +1,42 @@
import si from 'systeminformation';
export interface CPUCapabilities {
avx: boolean;
avx2: boolean;
cpuInfo: string[];
}
export class CPUService {
async detectCPUCapabilities(): Promise<CPUCapabilities> {
try {
const [cpu, flags] = await Promise.all([si.cpu(), si.cpuFlags()]);
const cpuInfo: string[] = [];
if (cpu.brand) {
cpuInfo.push(cpu.brand);
}
if (cpu.cores) {
cpuInfo.push(`${cpu.cores} cores`);
}
if (cpu.speed) {
cpuInfo.push(`${cpu.speed}GHz`);
}
const avx = flags.includes('avx') || flags.includes('AVX');
const avx2 = flags.includes('avx2') || flags.includes('AVX2');
return {
avx,
avx2,
cpuInfo: cpuInfo.length > 0 ? cpuInfo : ['CPU information unavailable'],
};
} catch (error) {
console.warn('CPU detection failed:', error);
return {
avx: false,
avx2: false,
cpuInfo: ['CPU detection failed'],
};
}
}
}

View file

@ -1,198 +1,335 @@
import si from 'systeminformation';
export interface GPUCapabilities {
cuda: {
supported: boolean;
devices: string[];
};
rocm: {
supported: boolean;
devices: string[];
};
vulkan: {
supported: boolean;
devices: string[];
};
clblast: {
supported: boolean;
devices: string[];
};
}
export class GPUService {
async detectGPU(): Promise<{
hasAMD: boolean;
hasNVIDIA: boolean;
gpuInfo: string[];
}> {
let gpuInfo: string[] = [];
let hasAMD = false;
let hasNVIDIA = false;
try {
const platform = process.platform;
const { spawn } = await import('child_process');
const graphics = await si.graphics();
if (platform === 'linux') {
try {
const lspci = spawn('lspci', ['-nn'], { timeout: 5000 });
let output = '';
let hasAMD = false;
let hasNVIDIA = false;
const gpuInfo: string[] = [];
lspci.stdout.on('data', (data) => {
output += data.toString();
});
await new Promise<void>((resolve, reject) => {
lspci.on('close', (code) => {
if (code === 0) {
const lines = output.split('\n');
const gpuLines = lines.filter(
(line) =>
line.toLowerCase().includes('vga') ||
line.toLowerCase().includes('3d') ||
line.toLowerCase().includes('display')
);
gpuInfo = gpuLines;
for (const line of gpuLines) {
const lineLower = line.toLowerCase();
if (
lineLower.includes('amd') ||
lineLower.includes('radeon') ||
lineLower.includes('ati')
) {
hasAMD = true;
}
if (
lineLower.includes('nvidia') ||
lineLower.includes('geforce') ||
lineLower.includes('gtx') ||
lineLower.includes('rtx')
) {
hasNVIDIA = true;
}
}
resolve();
} else {
reject(new Error(`lspci exited with code ${code}`));
}
});
lspci.on('error', (error) => {
reject(error);
});
setTimeout(() => {
try {
lspci.kill('SIGTERM');
} catch {
// Process already terminated
}
reject(new Error('lspci timeout'));
}, 5000);
});
} catch {
gpuInfo = ['GPU detection via lspci failed'];
for (const controller of graphics.controllers) {
if (controller.model) {
gpuInfo.push(controller.model);
}
} else if (platform === 'win32') {
try {
const wmic = spawn(
'wmic',
['path', 'win32_VideoController', 'get', 'name'],
{ timeout: 5000 }
);
let output = '';
wmic.stdout.on('data', (data) => {
output += data.toString();
});
const vendor = controller.vendor?.toLowerCase() || '';
const model = controller.model?.toLowerCase() || '';
await new Promise<void>((resolve, reject) => {
wmic.on('close', (code) => {
if (code === 0) {
const lines = output
.split('\n')
.filter((line) => line.trim() && !line.includes('Name'));
gpuInfo = lines.map((line) => line.trim());
for (const line of lines) {
const lineLower = line.toLowerCase();
if (
lineLower.includes('amd') ||
lineLower.includes('radeon') ||
lineLower.includes('ati')
) {
hasAMD = true;
}
if (
lineLower.includes('nvidia') ||
lineLower.includes('geforce') ||
lineLower.includes('gtx') ||
lineLower.includes('rtx')
) {
hasNVIDIA = true;
}
}
resolve();
} else {
reject(new Error(`wmic exited with code ${code}`));
}
});
wmic.on('error', (error) => {
reject(error);
});
setTimeout(() => {
try {
wmic.kill('SIGTERM');
} catch {
// Process already terminated
}
reject(new Error('wmic timeout'));
}, 5000);
});
} catch {
gpuInfo = ['GPU detection via wmic failed'];
if (
vendor.includes('amd') ||
vendor.includes('ati') ||
model.includes('radeon') ||
model.includes('amd')
) {
hasAMD = true;
}
} else if (platform === 'darwin') {
try {
const profiler = spawn('system_profiler', ['SPDisplaysDataType'], {
timeout: 5000,
});
let output = '';
profiler.stdout.on('data', (data) => {
output += data.toString();
});
await new Promise<void>((resolve, reject) => {
profiler.on('close', (code) => {
if (code === 0) {
gpuInfo = [output];
const outputLower = output.toLowerCase();
if (
outputLower.includes('amd') ||
outputLower.includes('radeon') ||
outputLower.includes('ati')
) {
hasAMD = true;
}
if (
outputLower.includes('nvidia') ||
outputLower.includes('geforce') ||
outputLower.includes('gtx') ||
outputLower.includes('rtx')
) {
hasNVIDIA = true;
}
resolve();
} else {
reject(new Error(`system_profiler exited with code ${code}`));
}
});
profiler.on('error', (error) => {
reject(error);
});
setTimeout(() => {
try {
profiler.kill('SIGTERM');
} catch {
// Process already terminated
}
reject(new Error('system_profiler timeout'));
}, 5000);
});
} catch {
gpuInfo = ['GPU detection via system_profiler failed'];
if (
vendor.includes('nvidia') ||
model.includes('nvidia') ||
model.includes('geforce') ||
model.includes('gtx') ||
model.includes('rtx')
) {
hasNVIDIA = true;
}
}
} catch {
gpuInfo = ['GPU detection failed'];
}
return { hasAMD, hasNVIDIA, gpuInfo };
return {
hasAMD,
hasNVIDIA,
gpuInfo:
gpuInfo.length > 0 ? gpuInfo : ['No GPU information available'],
};
} catch (error) {
console.warn('GPU detection failed:', error);
return {
hasAMD: false,
hasNVIDIA: false,
gpuInfo: ['GPU detection failed'],
};
}
}
async detectGPUCapabilities(): Promise<GPUCapabilities> {
const [cuda, rocm, vulkan, clblast] = await Promise.all([
this.detectCUDA(),
this.detectROCm(),
this.detectVulkan(),
this.detectCLBlast(),
]);
return { cuda, rocm, vulkan, clblast };
}
private async detectCUDA(): Promise<{
supported: boolean;
devices: string[];
}> {
try {
const { spawn } = await import('child_process');
const nvidia = spawn(
'nvidia-smi',
['--query-gpu=name,memory.total,memory.free', '--format=csv,noheader'],
{ timeout: 5000 }
);
let output = '';
nvidia.stdout.on('data', (data) => {
output += data.toString();
});
return new Promise((resolve) => {
nvidia.on('close', (code) => {
if (code === 0 && output.trim()) {
const devices = output
.trim()
.split('\n')
.map((line) => {
const parts = line.split(',');
return parts[0]?.trim() || 'Unknown NVIDIA GPU';
})
.filter(Boolean);
resolve({
supported: devices.length > 0,
devices,
});
} else {
resolve({ supported: false, devices: [] });
}
});
nvidia.on('error', () => {
resolve({ supported: false, devices: [] });
});
setTimeout(() => {
try {
nvidia.kill('SIGTERM');
} catch {
// Process already terminated
}
resolve({ supported: false, devices: [] });
}, 5000);
});
} catch {
return { supported: false, devices: [] };
}
}
private async detectROCm(): Promise<{
supported: boolean;
devices: string[];
}> {
try {
const { spawn } = await import('child_process');
const rocminfo = spawn('rocminfo', [], { timeout: 5000 });
let output = '';
rocminfo.stdout.on('data', (data) => {
output += data.toString();
});
return new Promise((resolve) => {
rocminfo.on('close', (code) => {
if (code === 0 && output.trim()) {
const devices: string[] = [];
const lines = output.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes('Marketing Name:')) {
const name = line.split('Marketing Name:')[1]?.trim();
if (name && !name.includes('CPU')) {
devices.push(name);
}
}
}
resolve({
supported: devices.length > 0,
devices,
});
} else {
resolve({ supported: false, devices: [] });
}
});
rocminfo.on('error', () => {
resolve({ supported: false, devices: [] });
});
setTimeout(() => {
try {
rocminfo.kill('SIGTERM');
} catch {
// Process already terminated
}
resolve({ supported: false, devices: [] });
}, 5000);
});
} catch {
return { supported: false, devices: [] };
}
}
private async detectVulkan(): Promise<{
supported: boolean;
devices: string[];
}> {
try {
const { spawn } = await import('child_process');
const vulkaninfo = spawn('vulkaninfo', ['--summary'], { timeout: 5000 });
let output = '';
vulkaninfo.stdout.on('data', (data) => {
output += data.toString();
});
return new Promise((resolve) => {
vulkaninfo.on('close', (code) => {
if (code === 0 && output.trim()) {
const devices: string[] = [];
const lines = output.split('\n');
for (const line of lines) {
if (line.includes('deviceName')) {
const name = line.split('=')[1]?.trim();
if (name) {
devices.push(name);
}
}
}
resolve({
supported: devices.length > 0,
devices,
});
} else {
resolve({ supported: false, devices: [] });
}
});
vulkaninfo.on('error', () => {
resolve({ supported: false, devices: [] });
});
setTimeout(() => {
try {
vulkaninfo.kill('SIGTERM');
} catch {
// Process already terminated
}
resolve({ supported: false, devices: [] });
}, 5000);
});
} catch {
return { supported: false, devices: [] };
}
}
private async detectCLBlast(): Promise<{
supported: boolean;
devices: string[];
}> {
try {
const { spawn } = await import('child_process');
const clinfo = spawn('clinfo', ['--json'], { timeout: 5000 });
let output = '';
clinfo.stdout.on('data', (data) => {
output += data.toString();
});
return new Promise((resolve) => {
clinfo.on('close', (code) => {
if (code === 0 && output.trim()) {
try {
const data = JSON.parse(output);
const devices: string[] = [];
if (data.platforms) {
for (const platform of data.platforms) {
if (platform.devices) {
for (const device of platform.devices) {
if (device.name && device.type !== 'CPU') {
devices.push(device.name);
}
}
}
}
}
resolve({
supported: devices.length > 0,
devices,
});
} catch {
// Failed to parse JSON, but clinfo ran successfully
// Try to extract device names from text output
const lines = output.split('\n');
const devices: string[] = [];
for (const line of lines) {
if (line.includes('Device Name') && !line.includes('CPU')) {
const name = line.split(':')[1]?.trim();
if (name) {
devices.push(name);
}
}
}
resolve({
supported: devices.length > 0,
devices,
});
}
} else {
resolve({ supported: false, devices: [] });
}
});
clinfo.on('error', () => {
resolve({ supported: false, devices: [] });
});
setTimeout(() => {
try {
clinfo.kill('SIGTERM');
} catch {
// Process already terminated
}
resolve({ supported: false, devices: [] });
}, 5000);
});
} catch {
return { supported: false, devices: [] };
}
}
}

View file

@ -1,5 +1,5 @@
import type { GitHubRelease } from '@/types/electron';
import { GITHUB_API } from '@/constants/app';
import { GITHUB_API } from '@/constants';
export class GitHubService {
private lastApiCall = 0;

View file

@ -0,0 +1,367 @@
import si from 'systeminformation';
import type {
CPUCapabilities,
GPUCapabilities,
BasicGPUInfo,
HardwareInfo,
} from '@/types/hardware';
export class HardwareService {
async detectCPU(): Promise<CPUCapabilities> {
try {
const [cpu, flags] = await Promise.all([si.cpu(), si.cpuFlags()]);
const cpuInfo: string[] = [];
if (cpu.brand) {
cpuInfo.push(cpu.brand);
}
if (cpu.cores) {
cpuInfo.push(`${cpu.cores} cores`);
}
if (cpu.speed) {
cpuInfo.push(`${cpu.speed}GHz`);
}
const avx = flags.includes('avx') || flags.includes('AVX');
const avx2 = flags.includes('avx2') || flags.includes('AVX2');
return {
avx,
avx2,
cpuInfo: cpuInfo.length > 0 ? cpuInfo : ['CPU information unavailable'],
};
} catch (error) {
console.warn('CPU detection failed:', error);
return {
avx: false,
avx2: false,
cpuInfo: ['CPU detection failed'],
};
}
}
async detectGPU(): Promise<BasicGPUInfo> {
try {
const graphics = await si.graphics();
let hasAMD = false;
let hasNVIDIA = false;
const gpuInfo: string[] = [];
for (const controller of graphics.controllers) {
if (controller.model) {
gpuInfo.push(controller.model);
}
const vendor = controller.vendor?.toLowerCase() || '';
const model = controller.model?.toLowerCase() || '';
if (
vendor.includes('amd') ||
vendor.includes('ati') ||
model.includes('radeon') ||
model.includes('amd')
) {
hasAMD = true;
}
if (
vendor.includes('nvidia') ||
model.includes('nvidia') ||
model.includes('geforce') ||
model.includes('gtx') ||
model.includes('rtx')
) {
hasNVIDIA = true;
}
}
return {
hasAMD,
hasNVIDIA,
gpuInfo:
gpuInfo.length > 0 ? gpuInfo : ['No GPU information available'],
};
} catch (error) {
console.warn('GPU detection failed:', error);
return {
hasAMD: false,
hasNVIDIA: false,
gpuInfo: ['GPU detection failed'],
};
}
}
async detectGPUCapabilities(): Promise<GPUCapabilities> {
const [cuda, rocm, vulkan, clblast] = await Promise.all([
this.detectCUDA(),
this.detectROCm(),
this.detectVulkan(),
this.detectCLBlast(),
]);
return { cuda, rocm, vulkan, clblast };
}
async detectAll(): Promise<HardwareInfo> {
const [cpu, gpu] = await Promise.all([this.detectCPU(), this.detectGPU()]);
return { cpu, gpu };
}
async detectAllWithCapabilities(): Promise<HardwareInfo> {
const [cpu, gpu, gpuCapabilities] = await Promise.all([
this.detectCPU(),
this.detectGPU(),
this.detectGPUCapabilities(),
]);
return { cpu, gpu, gpuCapabilities };
}
private async detectCUDA(): Promise<{
supported: boolean;
devices: string[];
}> {
try {
const { spawn } = await import('child_process');
const nvidia = spawn(
'nvidia-smi',
['--query-gpu=name,memory.total,memory.free', '--format=csv,noheader'],
{ timeout: 5000 }
);
let output = '';
nvidia.stdout.on('data', (data) => {
output += data.toString();
});
return new Promise((resolve) => {
nvidia.on('close', (code) => {
if (code === 0 && output.trim()) {
const devices = output
.trim()
.split('\n')
.map((line) => {
const parts = line.split(',');
return parts[0]?.trim() || 'Unknown NVIDIA GPU';
})
.filter(Boolean);
resolve({
supported: devices.length > 0,
devices,
});
} else {
resolve({ supported: false, devices: [] });
}
});
nvidia.on('error', () => {
resolve({ supported: false, devices: [] });
});
setTimeout(() => {
try {
nvidia.kill('SIGTERM');
} catch {
// Process already terminated
}
resolve({ supported: false, devices: [] });
}, 5000);
});
} catch {
return { supported: false, devices: [] };
}
}
private async detectROCm(): Promise<{
supported: boolean;
devices: string[];
}> {
try {
const { spawn } = await import('child_process');
const rocminfo = spawn('rocminfo', [], { timeout: 5000 });
let output = '';
rocminfo.stdout.on('data', (data) => {
output += data.toString();
});
return new Promise((resolve) => {
rocminfo.on('close', (code) => {
if (code === 0 && output.trim()) {
const devices: string[] = [];
const lines = output.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes('Marketing Name:')) {
const name = line.split('Marketing Name:')[1]?.trim();
if (name && !name.includes('CPU')) {
devices.push(name);
}
}
}
resolve({
supported: devices.length > 0,
devices,
});
} else {
resolve({ supported: false, devices: [] });
}
});
rocminfo.on('error', () => {
resolve({ supported: false, devices: [] });
});
setTimeout(() => {
try {
rocminfo.kill('SIGTERM');
} catch {
// Process already terminated
}
resolve({ supported: false, devices: [] });
}, 5000);
});
} catch {
return { supported: false, devices: [] };
}
}
private async detectVulkan(): Promise<{
supported: boolean;
devices: string[];
}> {
try {
const { spawn } = await import('child_process');
const vulkaninfo = spawn('vulkaninfo', ['--summary'], { timeout: 5000 });
let output = '';
vulkaninfo.stdout.on('data', (data) => {
output += data.toString();
});
return new Promise((resolve) => {
vulkaninfo.on('close', (code) => {
if (code === 0 && output.trim()) {
const devices: string[] = [];
const lines = output.split('\n');
for (const line of lines) {
if (line.includes('deviceName')) {
const name = line.split('=')[1]?.trim();
if (name) {
devices.push(name);
}
}
}
resolve({
supported: devices.length > 0,
devices,
});
} else {
resolve({ supported: false, devices: [] });
}
});
vulkaninfo.on('error', () => {
resolve({ supported: false, devices: [] });
});
setTimeout(() => {
try {
vulkaninfo.kill('SIGTERM');
} catch {
// Process already terminated
}
resolve({ supported: false, devices: [] });
}, 5000);
});
} catch {
return { supported: false, devices: [] };
}
}
private async detectCLBlast(): Promise<{
supported: boolean;
devices: string[];
}> {
try {
const { spawn } = await import('child_process');
const clinfo = spawn('clinfo', ['--json'], { timeout: 5000 });
let output = '';
clinfo.stdout.on('data', (data) => {
output += data.toString();
});
return new Promise((resolve) => {
// eslint-disable-next-line sonarjs/cognitive-complexity
clinfo.on('close', (code) => {
if (code === 0 && output.trim()) {
try {
const data = JSON.parse(output);
const devices: string[] = [];
if (data.platforms) {
for (const platform of data.platforms) {
if (platform.devices) {
for (const device of platform.devices) {
if (device.name && device.type !== 'CPU') {
devices.push(device.name);
}
}
}
}
}
resolve({
supported: devices.length > 0,
devices,
});
} catch {
// Failed to parse JSON, try text parsing
const lines = output.split('\n');
const devices: string[] = [];
for (const line of lines) {
if (line.includes('Device Name') && !line.includes('CPU')) {
const name = line.split(':')[1]?.trim();
if (name) {
devices.push(name);
}
}
}
resolve({
supported: devices.length > 0,
devices,
});
}
} else {
resolve({ supported: false, devices: [] });
}
});
clinfo.on('error', () => {
resolve({ supported: false, devices: [] });
});
setTimeout(() => {
try {
clinfo.kill('SIGTERM');
} catch {
// Process already terminated
}
resolve({ supported: false, devices: [] });
}, 5000);
});
} catch {
return { supported: false, devices: [] };
}
}
}

View file

@ -3,24 +3,24 @@ import { shell, app } from 'electron';
import { KoboldCppManager } from '@/main/managers/KoboldCppManager';
import { ConfigManager } from '@/main/managers/ConfigManager';
import { GitHubService } from '@/main/services/GitHubService';
import { GPUService } from '@/main/services/GPUService';
import { HardwareService } from '@/main/services/HardwareService';
export class IPCHandlers {
private koboldManager: KoboldCppManager;
private configManager: ConfigManager;
private githubService: GitHubService;
private gpuService: GPUService;
private hardwareService: HardwareService;
constructor(
koboldManager: KoboldCppManager,
configManager: ConfigManager,
githubService: GitHubService,
gpuService: GPUService
hardwareService: HardwareService
) {
this.koboldManager = koboldManager;
this.configManager = configManager;
this.githubService = githubService;
this.gpuService = gpuService;
this.hardwareService = hardwareService;
}
setupHandlers() {
@ -104,7 +104,21 @@ export class IPCHandlers {
this.koboldManager.selectInstallDirectory()
);
ipcMain.handle('kobold:detectGPU', () => this.gpuService.detectGPU());
ipcMain.handle('kobold:detectGPU', () => this.hardwareService.detectGPU());
ipcMain.handle('kobold:detectCPU', () => this.hardwareService.detectCPU());
ipcMain.handle('kobold:detectGPUCapabilities', () =>
this.hardwareService.detectGPUCapabilities()
);
ipcMain.handle('kobold:detectHardware', () =>
this.hardwareService.detectAll()
);
ipcMain.handle('kobold:detectAllCapabilities', () =>
this.hardwareService.detectAllWithCapabilities()
);
ipcMain.handle('kobold:getPlatform', () => ({
platform: process.platform,

View file

@ -23,6 +23,12 @@ const koboldAPI: KoboldAPI = {
getAllReleases: () => ipcRenderer.invoke('kobold:getAllReleases'),
getPlatform: () => ipcRenderer.invoke('kobold:getPlatform'),
detectGPU: () => ipcRenderer.invoke('kobold:detectGPU'),
detectCPU: () => ipcRenderer.invoke('kobold:detectCPU'),
detectGPUCapabilities: () =>
ipcRenderer.invoke('kobold:detectGPUCapabilities'),
detectHardware: () => ipcRenderer.invoke('kobold:detectHardware'),
detectAllCapabilities: () =>
ipcRenderer.invoke('kobold:detectAllCapabilities'),
getCurrentInstallDir: () => ipcRenderer.invoke('kobold:getCurrentInstallDir'),
selectInstallDirectory: () =>
ipcRenderer.invoke('kobold:selectInstallDirectory'),

View file

@ -1,3 +1,12 @@
import type {
CPUCapabilities,
GPUCapabilities,
BasicGPUInfo,
HardwareInfo,
PlatformInfo,
SystemCapabilities,
} from '@/types/hardware';
interface GitHubAsset {
name: string;
browser_download_url: string;
@ -53,12 +62,12 @@ export interface KoboldAPI {
getVersionFromBinary: (binaryPath: string) => Promise<string | null>;
getLatestRelease: () => Promise<GitHubRelease>;
getAllReleases: () => Promise<GitHubRelease[]>;
getPlatform: () => Promise<{ platform: string; arch: string }>;
detectGPU: () => Promise<{
hasAMD: boolean;
hasNVIDIA: boolean;
gpuInfo: string[];
}>;
getPlatform: () => Promise<PlatformInfo>;
detectGPU: () => Promise<BasicGPUInfo>;
detectCPU: () => Promise<CPUCapabilities>;
detectGPUCapabilities: () => Promise<GPUCapabilities>;
detectHardware: () => Promise<HardwareInfo>;
detectAllCapabilities: () => Promise<HardwareInfo>;
getCurrentInstallDir: () => Promise<string>;
selectInstallDirectory: () => Promise<string | null>;
downloadRelease: (

46
src/types/hardware.ts Normal file
View file

@ -0,0 +1,46 @@
export interface CPUCapabilities {
avx: boolean;
avx2: boolean;
cpuInfo: string[];
}
export interface GPUCapabilities {
cuda: {
supported: boolean;
devices: string[];
};
rocm: {
supported: boolean;
devices: string[];
};
vulkan: {
supported: boolean;
devices: string[];
};
clblast: {
supported: boolean;
devices: string[];
};
}
export interface BasicGPUInfo {
hasAMD: boolean;
hasNVIDIA: boolean;
gpuInfo: string[];
}
export interface HardwareInfo {
cpu: CPUCapabilities;
gpu: BasicGPUInfo;
gpuCapabilities?: GPUCapabilities;
}
export interface PlatformInfo {
platform: string;
arch: string;
}
export interface SystemCapabilities {
hardware: HardwareInfo;
platform: PlatformInfo;
}

View file

@ -46,3 +46,12 @@ export interface ROCmDownload {
url: string;
size: number;
}
export type {
CPUCapabilities,
GPUCapabilities,
BasicGPUInfo,
HardwareInfo,
PlatformInfo,
SystemCapabilities,
} from './hardware';

View file

@ -1,4 +1,4 @@
import { ASSET_SUFFIXES } from '@/constants/app';
import { ASSET_SUFFIXES } from '@/constants';
export const getAssetDescription = (assetName: string): string => {
const name = assetName.toLowerCase();

31
src/utils/validation.ts Normal file
View file

@ -0,0 +1,31 @@
export const isValidUrl = (string: string): boolean => {
try {
const url = new URL(string);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
};
export const isValidFilePath = (path: string): boolean => {
if (!path.trim()) return false;
const validExtensions = ['.gguf'];
const hasValidExtension = validExtensions.some((ext) =>
path.toLowerCase().endsWith(ext)
);
return hasValidExtension || path.includes('/') || path.includes('\\');
};
export const getInputValidationState = (
path: string
): 'valid' | 'invalid' | 'neutral' => {
if (!path.trim()) return 'neutral';
if (isValidUrl(path) || isValidFilePath(path)) {
return 'valid';
}
return 'invalid';
};