diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fdddc11..89dbaa5 100755 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 diff --git a/.gitignore b/.gitignore index 98439fc..3db7030 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ release/ *.log .DS_Store Thumbs.db +.VSCodeCounter/ \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index 2f4cf59..a452af0 100644 --- a/.prettierignore +++ b/.prettierignore @@ -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 diff --git a/cspell.json b/cspell.json index 5897fde..5d31d49 100644 --- a/cspell.json +++ b/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", diff --git a/package-lock.json b/package-lock.json index c77e445..418b51a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 041d960..15fea32 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index 43a2d7f..f3f651a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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'; diff --git a/src/components/ConfigFileSelect.tsx b/src/components/ConfigFileSelect.tsx deleted file mode 100644 index c54204a..0000000 --- a/src/components/ConfigFileSelect.tsx +++ /dev/null @@ -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( - ({ label, extension, ...others }, ref) => ( -
- - - {label} - - - {extension} - - -
- ) -); - -SelectItem.displayName = 'SelectItem'; - -export const ConfigFileSelect = ({ - configFiles, - selectedFile, - loading = false, - onFileSelection, -}: ConfigFileSelectProps) => { - if (loading) { - return ( - - Loading configuration files... - - ); - } - - if (configFiles.length === 0) { - return ( - - No configuration files found in the installation directory. -
- Please ensure your .kcpps or .kcppt files are in the correct location. -
- ); - } - - 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 ( - value && onFileSelection(value)} + data={selectData} + leftSection={} + 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 ; + }} + /> + ); + })() + )} ); diff --git a/src/components/launch/GeneralTab.tsx b/src/components/launch/GeneralTab.tsx index 8281edb..32c16a0 100644 --- a/src/components/launch/GeneralTab.tsx +++ b/src/components/launch/GeneralTab.tsx @@ -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) => ( - -
- - Model File * - - - onModelPathChange(event.currentTarget.value)} - style={{ flex: 1 }} - required - /> - - - -
+}: GeneralTabProps) => { + const validationState = getInputValidationState(modelPath); -
- - - - GPU Layers - - - - - - - - - - - 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 ( + +
+ + Text Model File * + + +
+ onModelPathChange(event.currentTarget.value)} + required + color={getInputColor()} + error={ + validationState === 'invalid' ? getHelperText() : undefined } - size="sm" /> - - - - - +
+ + +
+
+ +
+ + + + GPU Layers + + + + + + + onAutoGpuLayersChange(event.currentTarget.checked) + } + size="sm" + /> + + + + onGpuLayersChange(Number(event.target.value) || 0) + } + type="number" + min={0} + max={100} + step={1} + size="sm" + w={80} + disabled={autoGpuLayers} + /> + + + +
+ +
+ + + + Context Size + + - 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} /> - - -
- -
- - - - Context Size - - - - - - - - - onContextSizeChange(Number(event.target.value) || 256) - } - type="number" + - - -
-
-); +
+
+ ); +}; diff --git a/src/components/launch/NetworkTab.tsx b/src/components/launch/NetworkTab.tsx index c4039e9..96ea472 100644 --- a/src/components/launch/NetworkTab.tsx +++ b/src/components/launch/NetworkTab.tsx @@ -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) => ( - {/* Port */} -
- - - Port - - - - - - - - - onPortChange(Number(event.currentTarget.value) || 5001) - } - type="number" - min={1} - max={65535} - w={120} - /> -
+ +
+ + + Host + + + + onHostChange(event.currentTarget.value)} + style={{ maxWidth: 200 }} + /> +
+ +
+ + + Port + + + + + onPortChange(Number(event.currentTarget.value) || 5001) + } + type="number" + min={1} + max={65535} + w={120} + /> +
+
- {/* Host */}
- - - Host - - - - - - - - onHostChange(event.currentTarget.value)} - style={{ maxWidth: 200 }} - /> + + Network Options + + + {/* First row: Multiuser and Multiplayer */} + +
+ + + onMultiuserChange(event.currentTarget.checked) + } + label="Multiuser Mode" + /> + + +
+ +
+ + + onMultiplayerChange(event.currentTarget.checked) + } + label="Shared Multiplayer" + /> + + +
+
+ + {/* Second row: Remote Tunnel and No Certify */} + +
+ + + onRemotetunnelChange(event.currentTarget.checked) + } + label="Remote Tunnel" + /> + + +
+ +
+ + + onNocertifyChange(event.currentTarget.checked) + } + label="No Certify Mode (Insecure)" + /> + + +
+
+ + {/* Third row: WebSearch (single item) */} + + onWebsearchChange(event.currentTarget.checked)} + label="Enable WebSearch" + /> + + +
); diff --git a/src/screens/DownloadScreen.tsx b/src/components/screens/DownloadScreen.tsx similarity index 99% rename from src/screens/DownloadScreen.tsx rename to src/components/screens/DownloadScreen.tsx index 87dd5fb..3687d68 100644 --- a/src/screens/DownloadScreen.tsx +++ b/src/components/screens/DownloadScreen.tsx @@ -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 { diff --git a/src/screens/InterfaceScreen.tsx b/src/components/screens/InterfaceScreen.tsx similarity index 100% rename from src/screens/InterfaceScreen.tsx rename to src/components/screens/InterfaceScreen.tsx diff --git a/src/screens/LaunchScreen.tsx b/src/components/screens/LaunchScreen.tsx similarity index 68% rename from src/screens/LaunchScreen.tsx rename to src/components/screens/LaunchScreen.tsx index dfdce3f..aaac74a 100644 --- a/src/screens/LaunchScreen.tsx +++ b/src/components/screens/LaunchScreen.tsx @@ -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) => { General - Advanced + Image Generation Network - - Image Generation - + Advanced - - - +
+ + + - - - + + + - - - + + + - - - - Image generation configuration will be available in a future - update. - - - + + + + Image generation configuration will be available in a future + update. + + + +
diff --git a/src/constants/app.ts b/src/constants/index.ts similarity index 88% rename from src/constants/app.ts rename to src/constants/index.ts index 8889b1c..009cc04 100644 --- a/src/constants/app.ts +++ b/src/constants/index.ts @@ -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', diff --git a/src/hooks/useLaunchConfig.ts b/src/hooks/useLaunchConfig.ts index 0b3ba91..5bc2c01 100644 --- a/src/hooks/useLaunchConfig.ts +++ b/src/hooks/useLaunchConfig.ts @@ -10,7 +10,13 @@ export const useLaunchConfig = () => { const [additionalArguments, setAdditionalArguments] = useState(''); const [port, setPort] = useState(5001); const [host, setHost] = useState('localhost'); + const [multiuser, setMultiuser] = useState(false); + const [multiplayer, setMultiplayer] = useState(false); + const [remotetunnel, setRemotetunnel] = useState(false); + const [nocertify, setNocertify] = useState(false); + const [websearch, setWebsearch] = useState(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, }; }; diff --git a/src/main/index.ts b/src/main/index.ts index cb26341..8ff5baa 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 ); } diff --git a/src/main/managers/KoboldCppManager.ts b/src/main/managers/KoboldCppManager.ts index a2f951b..3a64282 100644 --- a/src/main/managers/KoboldCppManager.ts +++ b/src/main/managers/KoboldCppManager.ts @@ -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 { 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', }); diff --git a/src/main/services/CPUService.ts b/src/main/services/CPUService.ts new file mode 100644 index 0000000..73cedad --- /dev/null +++ b/src/main/services/CPUService.ts @@ -0,0 +1,42 @@ +import si from 'systeminformation'; + +export interface CPUCapabilities { + avx: boolean; + avx2: boolean; + cpuInfo: string[]; +} + +export class CPUService { + async detectCPUCapabilities(): Promise { + 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'], + }; + } + } +} diff --git a/src/main/services/GPUService.ts b/src/main/services/GPUService.ts index 90aca88..82f05df 100644 --- a/src/main/services/GPUService.ts +++ b/src/main/services/GPUService.ts @@ -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((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((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((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 { + 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: [] }; + } } } diff --git a/src/main/services/GitHubService.ts b/src/main/services/GitHubService.ts index e2fb27c..92bc41f 100644 --- a/src/main/services/GitHubService.ts +++ b/src/main/services/GitHubService.ts @@ -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; diff --git a/src/main/services/HardwareService.ts b/src/main/services/HardwareService.ts new file mode 100644 index 0000000..2782db9 --- /dev/null +++ b/src/main/services/HardwareService.ts @@ -0,0 +1,367 @@ +import si from 'systeminformation'; +import type { + CPUCapabilities, + GPUCapabilities, + BasicGPUInfo, + HardwareInfo, +} from '@/types/hardware'; + +export class HardwareService { + async detectCPU(): Promise { + 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 { + 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 { + const [cuda, rocm, vulkan, clblast] = await Promise.all([ + this.detectCUDA(), + this.detectROCm(), + this.detectVulkan(), + this.detectCLBlast(), + ]); + + return { cuda, rocm, vulkan, clblast }; + } + + async detectAll(): Promise { + const [cpu, gpu] = await Promise.all([this.detectCPU(), this.detectGPU()]); + + return { cpu, gpu }; + } + + async detectAllWithCapabilities(): Promise { + 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: [] }; + } + } +} diff --git a/src/main/utils/IPCHandlers.ts b/src/main/utils/IPCHandlers.ts index 7cc9827..2c63774 100644 --- a/src/main/utils/IPCHandlers.ts +++ b/src/main/utils/IPCHandlers.ts @@ -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, diff --git a/src/preload/index.ts b/src/preload/index.ts index 08a0a30..f253233 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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'), diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 26d69cc..6c04ec3 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -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; getLatestRelease: () => Promise; getAllReleases: () => Promise; - getPlatform: () => Promise<{ platform: string; arch: string }>; - detectGPU: () => Promise<{ - hasAMD: boolean; - hasNVIDIA: boolean; - gpuInfo: string[]; - }>; + getPlatform: () => Promise; + detectGPU: () => Promise; + detectCPU: () => Promise; + detectGPUCapabilities: () => Promise; + detectHardware: () => Promise; + detectAllCapabilities: () => Promise; getCurrentInstallDir: () => Promise; selectInstallDirectory: () => Promise; downloadRelease: ( diff --git a/src/types/hardware.ts b/src/types/hardware.ts new file mode 100644 index 0000000..b534421 --- /dev/null +++ b/src/types/hardware.ts @@ -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; +} diff --git a/src/types/index.ts b/src/types/index.ts index 5f2e426..9da9756 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -46,3 +46,12 @@ export interface ROCmDownload { url: string; size: number; } + +export type { + CPUCapabilities, + GPUCapabilities, + BasicGPUInfo, + HardwareInfo, + PlatformInfo, + SystemCapabilities, +} from './hardware'; diff --git a/src/utils/assets.ts b/src/utils/assets.ts index 5934afa..083af03 100644 --- a/src/utils/assets.ts +++ b/src/utils/assets.ts @@ -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(); diff --git a/src/utils/validation.ts b/src/utils/validation.ts new file mode 100644 index 0000000..d7df0b7 --- /dev/null +++ b/src/utils/validation.ts @@ -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'; +};