mirror of
https://github.com/lone-cloud/gerbil
synced 2026-06-03 19:54:44 -07:00
add more network settings, start detecting GPU and CPU capabilities
This commit is contained in:
parent
795bb9ec51
commit
867ced21f9
32 changed files with 1536 additions and 660 deletions
1
.github/copilot-instructions.md
vendored
1
.github/copilot-instructions.md
vendored
|
|
@ -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
1
.gitignore
vendored
|
|
@ -6,3 +6,4 @@ release/
|
|||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.VSCodeCounter/
|
||||
|
|
@ -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
|
||||
|
|
|
|||
15
cspell.json
15
cspell.json
|
|
@ -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
37
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
44
src/components/InfoTooltip.tsx
Normal file
44
src/components/InfoTooltip.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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',
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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',
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
42
src/main/services/CPUService.ts
Normal file
42
src/main/services/CPUService.ts
Normal 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'],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
367
src/main/services/HardwareService.ts
Normal file
367
src/main/services/HardwareService.ts
Normal 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: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
21
src/types/electron.d.ts
vendored
21
src/types/electron.d.ts
vendored
|
|
@ -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
46
src/types/hardware.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -46,3 +46,12 @@ export interface ROCmDownload {
|
|||
url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type {
|
||||
CPUCapabilities,
|
||||
GPUCapabilities,
|
||||
BasicGPUInfo,
|
||||
HardwareInfo,
|
||||
PlatformInfo,
|
||||
SystemCapabilities,
|
||||
} from './hardware';
|
||||
|
|
|
|||
|
|
@ -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
31
src/utils/validation.ts
Normal 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';
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue