From 09f69c7c262526fe79f3980d6433f07acfe1c4cc Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 15 Aug 2025 23:22:56 -0700 Subject: [PATCH] image generation implementation, standardized tooltips, cache all hardware detection data, better folder structure --- .github/copilot-instructions.md | 13 +- README.md | 6 +- cspell.json | 9 + package-lock.json | 11 +- package.json | 4 +- src/App.tsx | 22 +- src/components/InfoTooltip.tsx | 41 +-- src/components/StyledTooltip.tsx | 35 +++ .../{ => settings}/SettingsModal.tsx | 8 +- src/components/settings/VersionsTab.tsx | 137 +++------- src/hooks/useKoboldVersions.ts | 195 +++++++++++++++ src/hooks/useLaunchConfig.ts | 187 +++++++++++++- src/main/managers/KoboldCppManager.ts | 47 ++++ src/main/services/HardwareService.ts | 38 ++- src/main/utils/IPCHandlers.ts | 6 + src/preload/index.ts | 29 +++ .../Download.tsx} | 171 +++++-------- .../Interface/ImageGenerationTab.tsx} | 15 +- src/screens/Interface/ServerTab.tsx | 64 +++++ .../Interface}/TerminalTab.tsx | 0 .../Interface/index.tsx} | 28 ++- .../launch => screens/Launch}/AdvancedTab.tsx | 31 ++- src/screens/Launch/BackendSelector.tsx | 213 ++++++++++++++++ .../Launch}/ConfigurationManager.tsx | 64 ++--- .../launch => screens/Launch}/GeneralTab.tsx | 152 +----------- src/screens/Launch/ImageGenerationTab.tsx | 233 ++++++++++++++++++ .../launch => screens/Launch}/NetworkTab.tsx | 0 .../Launch}/SaveConfigModal.tsx | 0 .../Launch/index.tsx} | 136 ++++++++-- src/types/electron.d.ts | 29 +++ src/utils/imageModelPresets.ts | 54 ++++ 31 files changed, 1466 insertions(+), 512 deletions(-) create mode 100644 src/components/StyledTooltip.tsx rename src/components/{ => settings}/SettingsModal.tsx (94%) create mode 100644 src/hooks/useKoboldVersions.ts rename src/{components/screens/DownloadScreen.tsx => screens/Download.tsx} (61%) rename src/{components/interface/ChatTab.tsx => screens/Interface/ImageGenerationTab.tsx} (74%) create mode 100644 src/screens/Interface/ServerTab.tsx rename src/{components/interface => screens/Interface}/TerminalTab.tsx (100%) rename src/{components/screens/InterfaceScreen.tsx => screens/Interface/index.tsx} (70%) rename src/{components/launch => screens/Launch}/AdvancedTab.tsx (99%) create mode 100644 src/screens/Launch/BackendSelector.tsx rename src/{components/launch => screens/Launch}/ConfigurationManager.tsx (72%) rename src/{components/launch => screens/Launch}/GeneralTab.tsx (52%) create mode 100644 src/screens/Launch/ImageGenerationTab.tsx rename src/{components/launch => screens/Launch}/NetworkTab.tsx (100%) rename src/{components/launch => screens/Launch}/SaveConfigModal.tsx (100%) rename src/{components/screens/LaunchScreen.tsx => screens/Launch/index.tsx} (75%) create mode 100644 src/utils/imageModelPresets.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 89dbaa5..8658abf 100755 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,14 +1,5 @@ # Copilot Instructions for FriendlyKobold -## Code Style Preferences - -### Comments - -- Minimize comments in code - only add them when the code logic is genuinely confusing or complex -- Remove obvious/redundant comments that just describe what the code does -- Prefer self-documenting code with clear variable and function names -- Focus on "why" not "what" when comments are necessary - ### General Coding - Follow existing TypeScript/React patterns in the codebase @@ -18,3 +9,7 @@ - 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 + +### Scripting + +- when debugging: try to run script commands that will work for both bash and fish shells diff --git a/README.md b/README.md index b591977..6d7d505 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A koboldcpp manager. - download and keep up-to-date your [koboldcpp](https://github.com/LostRuins/koboldcpp/releases) binary - better surface the ROCm-specific builds of koboldcpp from YellowRoseCx and from [koboldai.org](https://koboldai.org/cpplinuxrocm) - manage the koboldcpp binary to prevent it from running in the background indefinitely -- automatically unpack all downloaded koboldcpp binaries for significantly faster operations +- automatically unpack all downloaded koboldcpp binaries for significantly faster operation and reduced RAM+HDD utilization (up to ~4GB less RAM usage for ROCm) ### Prerequisites @@ -34,6 +34,10 @@ A koboldcpp manager. Additional configurations have been written to help with ideal Wayland support, but as per current Electron guidelines, the user should set `ELECTRON_OZONE_PLATFORM_HINT` to `wayland` in their environment variable according to the [Electron Environment Variables documentation](https://www.electronjs.org/docs/latest/api/environment-variables#electron_ozone_platform_hint-linux). +### Future features + +Not all koboldcpp features have currently been ported over the UI. As a workaround one may use the "Additional arguments" on the "Advanced" tab of the launcher to provide additional command line arguments if you know them. + ### Future considerations It would make a lot of sense to transition this project to Tauri from Electron. The app size should drop from ~80MB to ~10MB; however, users on obsolete OSes (with outdated WebViews) will very likely encounter issues. In addition, I would need to learn Rust to rewrite the BE (Electron main code), but at least we can re-use all the React code. The app would be much smaller, faster and memory efficient, but not work for some users. I think it's a worthy tradeoff. diff --git a/cspell.json b/cspell.json index dd592b6..5b8f3f3 100644 --- a/cspell.json +++ b/cspell.json @@ -106,6 +106,13 @@ "kobold", "KOBOLDAI", "koboldcpp", + "sdmodel", + "sdt5xxl", + "sdclipl", + "sdclipg", + "sdphotomaker", + "sdvae", + "sdapi", "less", "letterspacing", "libvk", @@ -183,6 +190,8 @@ "subdomain", "svg", "swiftshader", + "safetensors", + "SDXL", "tabler", "Tauri", "temp", diff --git a/package-lock.json b/package-lock.json index a5884dd..1bc12cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "dependencies": { "@emotion/react": "^11.14.0", "@mantine/core": "^8.2.4", - "jiti": "^2.5.1", "lucide-react": "^0.539.0", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -20,7 +19,7 @@ "devDependencies": { "@cspell/eslint-plugin": "^9.2.0", "@eslint/js": "^9.33.0", - "@types/node": "^24.2.1", + "@types/node": "^24.3.0", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@types/systeminformation": "^3.54.1", @@ -41,6 +40,7 @@ "eslint-plugin-sonarjs": "^3.0.4", "globals": "^16.3.0", "husky": "^9.1.7", + "jiti": "^2.5.1", "lint-staged": "^16.1.5", "prettier": "^3.6.2", "rollup-plugin-visualizer": "^6.0.3", @@ -3227,9 +3227,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.2.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz", - "integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==", + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", "dev": true, "license": "MIT", "dependencies": { @@ -8393,6 +8393,7 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" diff --git a/package.json b/package.json index 19666dd..de62cc6 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "devDependencies": { "@cspell/eslint-plugin": "^9.2.0", "@eslint/js": "^9.33.0", - "@types/node": "^24.2.1", + "@types/node": "^24.3.0", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@types/systeminformation": "^3.54.1", @@ -69,6 +69,7 @@ "eslint-plugin-sonarjs": "^3.0.4", "globals": "^16.3.0", "husky": "^9.1.7", + "jiti": "^2.5.1", "lint-staged": "^16.1.5", "prettier": "^3.6.2", "rollup-plugin-visualizer": "^6.0.3", @@ -79,7 +80,6 @@ "dependencies": { "@emotion/react": "^11.14.0", "@mantine/core": "^8.2.4", - "jiti": "^2.5.1", "lucide-react": "^0.539.0", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/src/App.tsx b/src/App.tsx index 294cf0f..deb2bf2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,6 @@ import { AppShell, Group, ActionIcon, - Tooltip, rem, Loader, Center, @@ -15,12 +14,13 @@ import { useMantineColorScheme, } from '@mantine/core'; import { Settings, ArrowLeft } from 'lucide-react'; -import { DownloadScreen } from '@/components/screens/DownloadScreen'; -import { LaunchScreen } from '@/components/screens/LaunchScreen'; -import { InterfaceScreen } from '@/components/screens/InterfaceScreen'; +import { DownloadScreen } from '@/screens/Download'; +import { LaunchScreen } from '@/screens/Launch'; +import { InterfaceScreen } from '@/screens/Interface'; import { UpdateDialog } from '@/components/UpdateDialog'; -import { SettingsModal } from '@/components/SettingsModal'; +import { SettingsModal } from '@/components/settings/SettingsModal'; import { ScreenTransition } from '@/components/ScreenTransition'; +import { StyledTooltip } from '@/components/StyledTooltip'; import type { UpdateInfo, InstalledVersion } from '@/types'; type Screen = 'download' | 'launch' | 'interface'; @@ -34,6 +34,8 @@ export const App = () => { const [activeInterfaceTab, setActiveInterfaceTab] = useState( 'terminal' ); + const [isImageGenerationMode, setIsImageGenerationMode] = + useState(false); const [currentVersion, setCurrentVersion] = useState( null ); @@ -316,7 +318,7 @@ export const App = () => { justifyContent: 'flex-end', }} > - + { > - + @@ -362,7 +364,10 @@ export const App = () => { isActive={currentScreen === 'launch'} shouldAnimate={hasInitialized} > - + { diff --git a/src/components/InfoTooltip.tsx b/src/components/InfoTooltip.tsx index 9446791..18e6702 100644 --- a/src/components/InfoTooltip.tsx +++ b/src/components/InfoTooltip.tsx @@ -1,5 +1,6 @@ -import { ActionIcon, Tooltip, useMantineColorScheme } from '@mantine/core'; +import { ActionIcon } from '@mantine/core'; import { Info } from 'lucide-react'; +import { StyledTooltip } from './StyledTooltip'; interface InfoTooltipProps { label: string; @@ -11,34 +12,10 @@ export const InfoTooltip = ({ label, multiline = true, width = 300, -}: InfoTooltipProps) => { - const { colorScheme } = useMantineColorScheme(); - const isDark = colorScheme === 'dark'; - - return ( - - - - - - ); -}; +}: InfoTooltipProps) => ( + + + + + +); diff --git a/src/components/StyledTooltip.tsx b/src/components/StyledTooltip.tsx new file mode 100644 index 0000000..4302e44 --- /dev/null +++ b/src/components/StyledTooltip.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react'; +import { Tooltip, useMantineColorScheme } from '@mantine/core'; +import type { TooltipProps } from '@mantine/core'; + +interface StyledTooltipProps extends Omit { + children: ReactNode; +} + +export const StyledTooltip = ({ children, ...props }: StyledTooltipProps) => { + const { colorScheme } = useMantineColorScheme(); + const isDark = colorScheme === 'dark'; + + return ( + + {children} + + ); +}; diff --git a/src/components/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx similarity index 94% rename from src/components/SettingsModal.tsx rename to src/components/settings/SettingsModal.tsx index e680271..7bb256a 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -1,9 +1,9 @@ import { useState, useEffect } from 'react'; import { Modal, Tabs, Text, Group, rem } from '@mantine/core'; import { Settings, Palette, SlidersHorizontal, GitBranch } from 'lucide-react'; -import { GeneralTab } from './settings/GeneralTab'; -import { VersionsTab } from './settings/VersionsTab'; -import { AppearanceTab } from './settings/AppearanceTab'; +import { GeneralTab } from './GeneralTab'; +import { VersionsTab } from './VersionsTab'; +import { AppearanceTab } from './AppearanceTab'; interface SettingsModalProps { opened: boolean; @@ -28,6 +28,8 @@ export const SettingsModal = ({ useEffect(() => { if (opened) { + setActiveTab('general'); + const originalOverflow = document.body.style.overflow; const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; diff --git a/src/components/settings/VersionsTab.tsx b/src/components/settings/VersionsTab.tsx index d9b7f17..8cfa0e3 100644 --- a/src/components/settings/VersionsTab.tsx +++ b/src/components/settings/VersionsTab.tsx @@ -11,13 +11,13 @@ import { } from '@mantine/core'; import { RotateCcw } from 'lucide-react'; import { DownloadCard } from '@/components/DownloadCard'; -import { isAssetCompatibleWithPlatform } from '@/utils/platform'; -import { getAssetDescription } from '@/utils/assets'; -import type { - InstalledVersion, - GitHubAsset, - GitHubRelease, -} from '@/types/electron'; +import { + getAssetDescription, + sortAssetsByRecommendation, + isAssetRecommended, +} from '@/utils/assets'; +import { useKoboldVersions } from '@/hooks/useKoboldVersions'; +import type { InstalledVersion } from '@/types/electron'; interface VersionInfo { name: string; @@ -31,30 +31,26 @@ interface VersionInfo { } export const VersionsTab = () => { + const { + platformInfo, + latestRelease, + filteredAssets, + rocmDownload, + loadingPlatform, + loadingRemote, + downloading, + downloadProgress, + loadRemoteVersions, + handleDownload: sharedHandleDownload, + } = useKoboldVersions(); + const [installedVersions, setInstalledVersions] = useState< InstalledVersion[] >([]); const [currentVersion, setCurrentVersion] = useState( null ); - const [availableAssets, setAvailableAssets] = useState([]); - const [rocmDownload, setRocmDownload] = useState<{ - name: string; - url: string; - size: number; - version?: string; - } | null>(null); - const [latestRelease, setLatestRelease] = useState( - null - ); - const [userPlatform, setUserPlatform] = useState(''); - const [loadingInstalled, setLoadingInstalled] = useState(true); - const [loadingRemote, setLoadingRemote] = useState(true); - const [downloading, setDownloading] = useState(null); - const [downloadProgress, setDownloadProgress] = useState<{ - [key: string]: number; - }>({}); const loadInstalledVersions = useCallback(async () => { setLoadingInstalled(true); @@ -97,68 +93,9 @@ export const VersionsTab = () => { } }, []); - const loadRemoteVersions = useCallback(async () => { - if (!userPlatform) return; - setLoadingRemote(true); - - try { - const [release, rocm] = await Promise.all([ - window.electronAPI.kobold.getLatestRelease(), - window.electronAPI.kobold.getROCmDownload(), - ]); - - if (release) { - setLatestRelease(release); - const compatibleAssets = release.assets.filter((asset) => - isAssetCompatibleWithPlatform(asset.name, userPlatform) - ); - setAvailableAssets(compatibleAssets); - } - - setRocmDownload(rocm); - } catch (error) { - console.error('Failed to load remote versions:', error); - } finally { - setLoadingRemote(false); - } - }, [userPlatform]); - - useEffect(() => { - const loadPlatform = async () => { - try { - const platform = await window.electronAPI.kobold.getPlatform(); - setUserPlatform(platform.platform); - } catch (error) { - console.error('Failed to load platform:', error); - } - }; - - loadPlatform(); - }, []); - useEffect(() => { loadInstalledVersions(); - if (userPlatform) { - loadRemoteVersions(); - } - }, [userPlatform, loadInstalledVersions, loadRemoteVersions]); - - useEffect(() => { - const handleProgress = (progress: number) => { - if (downloading) { - setDownloadProgress((prev) => ({ - ...prev, - [downloading]: progress, - })); - } - }; - - window.electronAPI.kobold.onDownloadProgress?.(handleProgress); - - return () => { - window.electronAPI.kobold.removeAllListeners?.('download-progress'); - }; - }, [downloading]); + }, [loadInstalledVersions]); const getDisplayNameFromPath = ( installedVersion: InstalledVersion @@ -181,7 +118,7 @@ export const VersionsTab = () => { const getAllVersions = (): VersionInfo[] => { const versions: VersionInfo[] = []; - availableAssets.forEach((asset) => { + filteredAssets.forEach((asset) => { const installedVersion = installedVersions.find((v) => { const displayName = getDisplayNameFromPath(v); return displayName === asset.name; @@ -260,38 +197,28 @@ export const VersionsTab = () => { } }); - return versions; + return sortAssetsByRecommendation(versions, platformInfo.hasAMDGPU); }; const handleDownload = async (version: VersionInfo) => { - setDownloading(version.name); - setDownloadProgress((prev) => ({ ...prev, [version.name]: 0 })); - try { - let result; + let success; if (version.isROCm) { - result = await window.electronAPI.kobold.downloadROCm(); + success = await sharedHandleDownload('rocm'); } else { - const asset = availableAssets.find((a) => a.name === version.name); + const asset = filteredAssets.find((a) => a.name === version.name); if (!asset) { throw new Error('Asset not found'); } - result = await window.electronAPI.kobold.downloadRelease(asset); + success = await sharedHandleDownload('asset', asset); } - if (result.success) { + if (success) { await loadInstalledVersions(); } } catch (error) { console.error('Failed to download:', error); - } finally { - setDownloading(null); - setDownloadProgress((prev) => { - const newProgress = { ...prev }; - delete newProgress[version.name]; - return newProgress; - }); } }; @@ -321,7 +248,7 @@ export const VersionsTab = () => { } }; - const isLoading = loadingInstalled || loadingRemote; + const isLoading = loadingInstalled || loadingPlatform || loadingRemote; if (isLoading) { return ( @@ -329,7 +256,7 @@ export const VersionsTab = () => { - {loadingInstalled && loadingRemote + {loadingInstalled && (loadingPlatform || loadingRemote) ? 'Loading versions...' : loadingInstalled ? 'Scanning installed versions...' @@ -374,6 +301,10 @@ export const VersionsTab = () => { } version={version.version} description={getAssetDescription(version.name)} + isRecommended={isAssetRecommended( + version.name, + platformInfo.hasAMDGPU + )} isCurrent={version.isCurrent} isInstalled={version.isInstalled} isDownloading={isDownloading} diff --git a/src/hooks/useKoboldVersions.ts b/src/hooks/useKoboldVersions.ts new file mode 100644 index 0000000..7c0ac21 --- /dev/null +++ b/src/hooks/useKoboldVersions.ts @@ -0,0 +1,195 @@ +import { useState, useEffect, useCallback } from 'react'; +import { filterAssetsByPlatform } from '@/utils/platform'; +import type { GitHubAsset, GitHubRelease } from '@/types'; + +interface PlatformInfo { + platform: string; + hasAMDGPU: boolean; + hasROCm: boolean; +} + +interface ROCmDownload { + name: string; + url: string; + size: number; + version?: string; +} + +interface UseKoboldVersionsReturn { + platformInfo: PlatformInfo; + latestRelease: GitHubRelease | null; + filteredAssets: GitHubAsset[]; + rocmDownload: ROCmDownload | null; + loadingPlatform: boolean; + loadingRemote: boolean; + downloading: string | null; + downloadProgress: Record; + loadRemoteVersions: () => Promise; + handleDownload: ( + type: 'asset' | 'rocm', + asset?: GitHubAsset + ) => Promise; + setDownloading: (value: string | null) => void; + setDownloadProgress: ( + value: + | Record + | ((prev: Record) => Record) + ) => void; +} + +export const useKoboldVersions = (): UseKoboldVersionsReturn => { + const [platformInfo, setPlatformInfo] = useState({ + platform: '', + hasAMDGPU: false, + hasROCm: false, + }); + + const [latestRelease, setLatestRelease] = useState( + null + ); + const [filteredAssets, setFilteredAssets] = useState([]); + const [rocmDownload, setRocmDownload] = useState(null); + + const [loadingPlatform, setLoadingPlatform] = useState(true); + const [loadingRemote, setLoadingRemote] = useState(true); + + const [downloading, setDownloading] = useState(null); + const [downloadProgress, setDownloadProgress] = useState< + Record + >({}); + + const loadPlatformInfo = useCallback(async () => { + setLoadingPlatform(true); + + try { + const platform = await window.electronAPI.kobold.getPlatform(); + + let hasAMDGPU = false; + let hasROCm = false; + + try { + const gpuInfo = await window.electronAPI.kobold.detectGPU(); + hasAMDGPU = gpuInfo.hasAMD; + + if (gpuInfo.hasAMD) { + const rocmInfo = await window.electronAPI.kobold.detectROCm(); + hasROCm = rocmInfo.supported; + } + } catch (gpuError) { + console.warn('GPU detection failed:', gpuError); + } + + setPlatformInfo({ + platform: platform.platform, + hasAMDGPU, + hasROCm, + }); + } catch (error) { + console.error('Failed to load platform info:', error); + } finally { + setLoadingPlatform(false); + } + }, []); + + const loadRemoteVersions = useCallback(async () => { + if (!platformInfo.platform) return; + + setLoadingRemote(true); + + try { + const [release, rocm] = await Promise.all([ + window.electronAPI.kobold.getLatestRelease(), + window.electronAPI.kobold.getROCmDownload(), + ]); + + setLatestRelease(release); + setRocmDownload(rocm); + + if (release) { + const filtered = filterAssetsByPlatform( + release.assets, + platformInfo.platform + ); + setFilteredAssets(filtered); + } + } catch (error) { + console.error('Failed to load remote versions:', error); + } finally { + setLoadingRemote(false); + } + }, [platformInfo.platform]); + + const handleDownload = useCallback( + async (type: 'asset' | 'rocm', asset?: GitHubAsset): Promise => { + if (type === 'asset' && !asset) return false; + + const downloadName = + type === 'asset' ? asset!.name : rocmDownload?.name || 'rocm'; + + setDownloading(downloadName); + setDownloadProgress((prev) => ({ ...prev, [downloadName]: 0 })); + + try { + const result = + type === 'rocm' + ? await window.electronAPI.kobold.downloadROCm() + : await window.electronAPI.kobold.downloadRelease(asset!); + + return result.success !== false; + } catch (error) { + console.error(`Failed to download ${type}:`, error); + return false; + } finally { + setDownloading(null); + setDownloadProgress((prev) => { + const newProgress = { ...prev }; + delete newProgress[downloadName]; + return newProgress; + }); + } + }, + [rocmDownload?.name] + ); + + useEffect(() => { + loadPlatformInfo(); + }, [loadPlatformInfo]); + + useEffect(() => { + if (platformInfo.platform) { + loadRemoteVersions(); + } + }, [platformInfo.platform, loadRemoteVersions]); + + useEffect(() => { + const handleProgress = (progress: number) => { + if (downloading) { + setDownloadProgress((prev) => ({ + ...prev, + [downloading]: progress, + })); + } + }; + + window.electronAPI.kobold.onDownloadProgress?.(handleProgress); + + return () => { + window.electronAPI.kobold.removeAllListeners?.('download-progress'); + }; + }, [downloading]); + + return { + platformInfo, + latestRelease, + filteredAssets, + rocmDownload, + loadingPlatform, + loadingRemote, + downloading, + downloadProgress, + loadRemoteVersions, + handleDownload, + setDownloading, + setDownloadProgress, + }; +}; diff --git a/src/hooks/useLaunchConfig.ts b/src/hooks/useLaunchConfig.ts index f872fc6..f01cf3e 100644 --- a/src/hooks/useLaunchConfig.ts +++ b/src/hooks/useLaunchConfig.ts @@ -1,5 +1,9 @@ import { useState, useCallback } from 'react'; import type { ConfigFile } from '@/types'; +import { + getPresetByName, + type ImageModelPreset, +} from '@/utils/imageModelPresets'; export const useLaunchConfig = () => { const [serverOnly, setServerOnly] = useState(false); @@ -21,10 +25,24 @@ export const useLaunchConfig = () => { const [failsafe, setFailsafe] = useState(false); const [backend, setBackend] = useState('cpu'); + const [sdmodel, setSdmodel] = useState(''); + const [sdt5xxl, setSdt5xxl] = useState( + 'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/t5xxl_fp8_e4m3fn.safetensors?download=true' + ); + const [sdclipl, setSdclipl] = useState( + 'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/clip_l.safetensors?download=true' + ); + const [sdclipg, setSdclipg] = useState(''); + const [sdphotomaker, setSdphotomaker] = useState(''); + const [sdvae, setSdvae] = useState( + 'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/ae.safetensors?download=true' + ); + // eslint-disable-next-line sonarjs/cognitive-complexity const parseAndApplyConfigFile = useCallback(async (configPath: string) => { const configData = await window.electronAPI.kobold.parseConfigFile(configPath); + if (configData) { if (typeof configData.gpulayers === 'number') { setGpuLayers(configData.gpulayers); @@ -124,7 +142,32 @@ export const useLaunchConfig = () => { } else { setBackend('cpu'); } + + if (typeof configData.sdmodel === 'string') { + setSdmodel(configData.sdmodel); + } + + if (typeof configData.sdt5xxl === 'string') { + setSdt5xxl(configData.sdt5xxl); + } + + if (typeof configData.sdclipl === 'string') { + setSdclipl(configData.sdclipl); + } + + if (typeof configData.sdclipg === 'string') { + setSdclipg(configData.sdclipg); + } + + if (typeof configData.sdphotomaker === 'string') { + setSdphotomaker(configData.sdphotomaker); + } + + if (typeof configData.sdvae === 'string') { + setSdvae(configData.sdvae); + } } else { + const cpuCapabilities = await window.electronAPI.kobold.detectCPU(); setGpuLayers(0); setContextSize(2048); setPort(5001); @@ -136,14 +179,30 @@ export const useLaunchConfig = () => { setWebsearch(false); setNoshift(false); setFlashattention(false); - setNoavx2(false); - setFailsafe(false); + setNoavx2(!cpuCapabilities.avx2); + setFailsafe(!cpuCapabilities.avx && !cpuCapabilities.avx2); setBackend('cpu'); + + setSdmodel(''); + setSdt5xxl( + 'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/t5xxl_fp8_e4m3fn.safetensors?download=true' + ); + setSdclipl( + 'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/clip_l.safetensors?download=true' + ); + setSdclipg(''); + setSdphotomaker(''); + setSdvae( + 'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/ae.safetensors?download=true' + ); } }, []); const loadSavedSettings = useCallback(async () => { - const savedServerOnly = await window.electronAPI.config.getServerOnly(); + const [savedServerOnly, cpuCapabilities] = await Promise.all([ + window.electronAPI.config.getServerOnly(), + window.electronAPI.kobold.detectCPU(), + ]); setServerOnly(savedServerOnly); setModelPath(''); @@ -158,9 +217,22 @@ export const useLaunchConfig = () => { setWebsearch(false); setNoshift(false); setFlashattention(false); - setNoavx2(false); - setFailsafe(false); + setNoavx2(!cpuCapabilities.avx2); + setFailsafe(!cpuCapabilities.avx && !cpuCapabilities.avx2); setBackend('cpu'); + + setSdmodel(''); + setSdt5xxl( + 'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/t5xxl_fp8_e4m3fn.safetensors?download=true' + ); + setSdclipl( + 'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/clip_l.safetensors?download=true' + ); + setSdclipg(''); + setSdphotomaker(''); + setSdvae( + 'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/ae.safetensors?download=true' + ); }, []); const loadConfigFromFile = useCallback( @@ -278,6 +350,91 @@ export const useLaunchConfig = () => { setBackend(backend); }, []); + const handleSdmodelChange = useCallback((path: string) => { + setSdmodel(path); + }, []); + + const handleSelectSdmodelFile = useCallback(async () => { + const filePath = await window.electronAPI.kobold.selectModelFile(); + if (filePath) { + setSdmodel(filePath); + } + }, []); + + const handleSdt5xxlChange = useCallback((path: string) => { + setSdt5xxl(path); + }, []); + + const handleSelectSdt5xxlFile = useCallback(async () => { + const filePath = await window.electronAPI.kobold.selectModelFile(); + if (filePath) { + setSdt5xxl(filePath); + } + }, []); + + const handleSdcliplChange = useCallback((path: string) => { + setSdclipl(path); + }, []); + + const handleSelectSdcliplFile = useCallback(async () => { + const filePath = await window.electronAPI.kobold.selectModelFile(); + if (filePath) { + setSdclipl(filePath); + } + }, []); + + const handleSdclipgChange = useCallback((path: string) => { + setSdclipg(path); + }, []); + + const handleSelectSdclipgFile = useCallback(async () => { + const filePath = await window.electronAPI.kobold.selectModelFile(); + if (filePath) { + setSdclipg(filePath); + } + }, []); + + const handleSdphotomakerChange = useCallback((path: string) => { + setSdphotomaker(path); + }, []); + + const handleSelectSdphotomakerFile = useCallback(async () => { + const filePath = await window.electronAPI.kobold.selectModelFile(); + if (filePath) { + setSdphotomaker(filePath); + } + }, []); + + const handleSdvaeChange = useCallback((path: string) => { + setSdvae(path); + }, []); + + const handleSelectSdvaeFile = useCallback(async () => { + const filePath = await window.electronAPI.kobold.selectModelFile(); + if (filePath) { + setSdvae(filePath); + } + }, []); + + const applyImageModelPreset = useCallback((preset: ImageModelPreset) => { + setSdmodel(preset.sdmodel); + setSdt5xxl(preset.sdt5xxl); + setSdclipl(preset.sdclipl); + setSdclipg(preset.sdclipg); + setSdphotomaker(preset.sdphotomaker); + setSdvae(preset.sdvae); + }, []); + + const handleApplyPreset = useCallback( + (presetName: string) => { + const preset = getPresetByName(presetName); + if (preset) { + applyImageModelPreset(preset); + } + }, + [applyImageModelPreset] + ); + return { serverOnly, gpuLayers, @@ -297,6 +454,12 @@ export const useLaunchConfig = () => { noavx2, failsafe, backend, + sdmodel, + sdt5xxl, + sdclipl, + sdclipg, + sdphotomaker, + sdvae, parseAndApplyConfigFile, loadSavedSettings, @@ -320,5 +483,19 @@ export const useLaunchConfig = () => { handleNoavx2Change, handleFailsafeChange, handleBackendChange, + handleSdmodelChange, + handleSelectSdmodelFile, + handleSdt5xxlChange, + handleSelectSdt5xxlFile, + handleSdcliplChange, + handleSelectSdcliplFile, + handleSdclipgChange, + handleSelectSdclipgFile, + handleSdphotomakerChange, + handleSelectSdphotomakerFile, + handleSdvaeChange, + handleSelectSdvaeFile, + applyImageModelPreset, + handleApplyPreset, }; }; diff --git a/src/main/managers/KoboldCppManager.ts b/src/main/managers/KoboldCppManager.ts index dd3fdaf..63ff99e 100644 --- a/src/main/managers/KoboldCppManager.ts +++ b/src/main/managers/KoboldCppManager.ts @@ -7,6 +7,7 @@ import { createWriteStream, chmodSync, readFileSync, + writeFileSync, unlinkSync, } from 'fs'; import { dialog } from 'electron'; @@ -334,6 +335,52 @@ export class KoboldCppManager { } } + async saveConfigFile( + configName: string, + configData: { + gpulayers?: number; + contextsize?: number; + model_param?: string; + port?: number; + host?: string; + multiuser?: number; + multiplayer?: boolean; + remotetunnel?: boolean; + nocertify?: boolean; + websearch?: boolean; + noshift?: boolean; + flashattention?: boolean; + noavx2?: boolean; + failsafe?: boolean; + usecuda?: boolean; + usevulkan?: boolean; + useclblast?: boolean; + sdmodel?: string; + sdt5xxl?: string; + sdclipl?: string; + sdclipg?: string; + sdphotomaker?: string; + sdvae?: string; + [key: string]: unknown; + } + ): Promise { + try { + if (!this.installDir) { + console.error('No install directory found'); + return false; + } + + const configFileName = `${configName}.json`; + const configPath = join(this.installDir, configFileName); + + writeFileSync(configPath, JSON.stringify(configData, null, 2), 'utf-8'); + return true; + } catch (error) { + console.error('Error saving config file:', error); + return false; + } + } + async selectModelFile(): Promise { try { const mainWindow = this.windowManager.getMainWindow(); diff --git a/src/main/services/HardwareService.ts b/src/main/services/HardwareService.ts index e148006..7e03a10 100644 --- a/src/main/services/HardwareService.ts +++ b/src/main/services/HardwareService.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-comments/disallowComments */ import si from 'systeminformation'; import type { CPUCapabilities, @@ -7,7 +8,15 @@ import type { } from '@/types/hardware'; export class HardwareService { + private cpuCapabilitiesCache: CPUCapabilities | null = null; + private basicGPUInfoCache: BasicGPUInfo | null = null; + private gpuCapabilitiesCache: GPUCapabilities | null = null; + async detectCPU(): Promise { + if (this.cpuCapabilitiesCache) { + return this.cpuCapabilitiesCache; + } + try { const [cpu, flags] = await Promise.all([si.cpu(), si.cpuFlags()]); @@ -25,22 +34,30 @@ export class HardwareService { const avx = flags.includes('avx') || flags.includes('AVX'); const avx2 = flags.includes('avx2') || flags.includes('AVX2'); - return { + this.cpuCapabilitiesCache = { avx, avx2, cpuInfo: cpuInfo.length > 0 ? cpuInfo : ['CPU information unavailable'], }; + + return this.cpuCapabilitiesCache; } catch (error) { console.warn('CPU detection failed:', error); - return { + const fallbackCapabilities = { avx: false, avx2: false, cpuInfo: ['CPU detection failed'], }; + this.cpuCapabilitiesCache = fallbackCapabilities; + return fallbackCapabilities; } } async detectGPU(): Promise { + if (this.basicGPUInfoCache) { + return this.basicGPUInfoCache; + } + try { const graphics = await si.graphics(); @@ -76,23 +93,33 @@ export class HardwareService { } } - return { + this.basicGPUInfoCache = { hasAMD, hasNVIDIA, gpuInfo: gpuInfo.length > 0 ? gpuInfo : ['No GPU information available'], }; + + return this.basicGPUInfoCache; } catch (error) { console.warn('GPU detection failed:', error); - return { + const fallbackGPUInfo = { hasAMD: false, hasNVIDIA: false, gpuInfo: ['GPU detection failed'], }; + this.basicGPUInfoCache = fallbackGPUInfo; + return fallbackGPUInfo; } } async detectGPUCapabilities(): Promise { + // WARNING: we're not worrying about the users that update their system + // during runtime and not restart. Should we be though? + if (this.gpuCapabilitiesCache) { + return this.gpuCapabilitiesCache; + } + const [cuda, rocm, vulkan, clblast] = await Promise.all([ this.detectCUDA(), this.detectROCm(), @@ -100,7 +127,8 @@ export class HardwareService { this.detectCLBlast(), ]); - return { cuda, rocm, vulkan, clblast }; + this.gpuCapabilitiesCache = { cuda, rocm, vulkan, clblast }; + return this.gpuCapabilitiesCache; } async detectAll(): Promise { diff --git a/src/main/utils/IPCHandlers.ts b/src/main/utils/IPCHandlers.ts index d6179d8..2c1e9d6 100644 --- a/src/main/utils/IPCHandlers.ts +++ b/src/main/utils/IPCHandlers.ts @@ -78,6 +78,12 @@ export class IPCHandlers { this.koboldManager.getConfigFiles() ); + ipcMain.handle( + 'kobold:saveConfigFile', + async (_event, configName, configData) => + this.koboldManager.saveConfigFile(configName, configData) + ); + ipcMain.handle('kobold:getSelectedConfig', () => this.configManager.getSelectedConfig() ); diff --git a/src/preload/index.ts b/src/preload/index.ts index b6bb122..142db7f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -40,6 +40,35 @@ const koboldAPI: KoboldAPI = { openInstallDialog: () => ipcRenderer.invoke('kobold:openInstallDialog'), checkForUpdates: () => ipcRenderer.invoke('kobold:checkForUpdates'), getConfigFiles: () => ipcRenderer.invoke('kobold:getConfigFiles'), + saveConfigFile: ( + configName: string, + configData: { + gpulayers?: number; + contextsize?: number; + model_param?: string; + port?: number; + host?: string; + multiuser?: number; + multiplayer?: boolean; + remotetunnel?: boolean; + nocertify?: boolean; + websearch?: boolean; + noshift?: boolean; + flashattention?: boolean; + noavx2?: boolean; + failsafe?: boolean; + usecuda?: boolean; + usevulkan?: boolean; + useclblast?: boolean; + sdmodel?: string; + sdt5xxl?: string; + sdclipl?: string; + sdclipg?: string; + sdphotomaker?: string; + sdvae?: string; + [key: string]: unknown; + } + ) => ipcRenderer.invoke('kobold:saveConfigFile', configName, configData), getSelectedConfig: () => ipcRenderer.invoke('kobold:getSelectedConfig'), setSelectedConfig: (configName: string) => ipcRenderer.invoke('kobold:setSelectedConfig', configName), diff --git a/src/components/screens/DownloadScreen.tsx b/src/screens/Download.tsx similarity index 61% rename from src/components/screens/DownloadScreen.tsx rename to src/screens/Download.tsx index 3c844b3..e5056e9 100644 --- a/src/components/screens/DownloadScreen.tsx +++ b/src/screens/Download.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useCallback } from 'react'; import { Card, Text, @@ -7,13 +7,10 @@ import { Stack, Container, Badge, - Tooltip, } from '@mantine/core'; import { DownloadCard } from '@/components/DownloadCard'; -import { - getPlatformDisplayName, - filterAssetsByPlatform, -} from '@/utils/platform'; +import { StyledTooltip } from '@/components/StyledTooltip'; +import { getPlatformDisplayName } from '@/utils/platform'; import { formatFileSize } from '@/utils/fileSize'; import { isAssetRecommended, @@ -21,127 +18,59 @@ import { getAssetDescription, } from '@/utils/assets'; import { ROCM } from '@/constants'; -import type { GitHubAsset, GitHubRelease } from '@/types'; +import { useKoboldVersions } from '@/hooks/useKoboldVersions'; +import type { GitHubAsset } from '@/types'; interface DownloadScreenProps { onDownloadComplete: () => void; } export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => { - const [latestRelease, setLatestRelease] = useState( - null - ); - const [filteredAssets, setFilteredAssets] = useState([]); - const [userPlatform, setUserPlatform] = useState(''); - const [hasAMDGPU, setHasAMDGPU] = useState(false); - const [hasROCm, setHasROCm] = useState(false); - const [loading, setLoading] = useState(true); - const [downloading, setDownloading] = useState(false); - const [downloadProgress, setDownloadProgress] = useState(0); + const { + platformInfo, + latestRelease, + filteredAssets, + rocmDownload, + loadingPlatform, + loadingRemote, + downloading, + downloadProgress, + handleDownload: sharedHandleDownload, + } = useKoboldVersions(); + const [downloadingType, setDownloadingType] = useState< 'asset' | 'rocm' | null >(null); const [downloadingAsset, setDownloadingAsset] = useState(null); - const [rocmDownload, setRocmDownload] = useState<{ - name: string; - url: string; - size: number; - version?: string; - } | null>(null); - const loadLatestReleaseAndPlatform = useCallback(async () => { - try { - setLoading(true); - - const [platformInfo, releaseData, rocmDownloadInfo] = await Promise.all([ - window.electronAPI.kobold.getPlatform(), - window.electronAPI.kobold.getLatestRelease(), - window.electronAPI.kobold.getROCmDownload(), - ]); - - setUserPlatform(platformInfo.platform); - setLatestRelease(releaseData); - setRocmDownload(rocmDownloadInfo); - - try { - const gpuInfo = await window.electronAPI.kobold.detectGPU(); - setHasAMDGPU(gpuInfo.hasAMD); - - if (gpuInfo.hasAMD) { - const rocmInfo = await window.electronAPI.kobold.detectROCm(); - setHasROCm(rocmInfo.supported); - } - } catch (gpuError) { - console.warn( - 'GPU detection failed, proceeding without GPU info:', - gpuError - ); - setHasAMDGPU(false); - setHasROCm(false); - } - - if (releaseData) { - const filtered = filterAssetsByPlatform( - releaseData.assets, - platformInfo.platform - ); - setFilteredAssets(filtered); - } else { - console.error( - 'GitHub API is currently unavailable. Please try again later.' - ); - } - } catch (err) { - console.error('Failed to load release information:', err); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - loadLatestReleaseAndPlatform(); - - window.electronAPI.kobold.onDownloadProgress((progress: number) => { - setDownloadProgress(progress); - }); - - return () => { - window.electronAPI.kobold.removeAllListeners('download-progress'); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const loading = loadingPlatform || loadingRemote; const handleDownload = useCallback( async (type: 'asset' | 'rocm', asset?: GitHubAsset) => { if (type === 'asset' && !asset) return; - setDownloading(true); setDownloadingType(type); setDownloadingAsset(type === 'asset' ? asset!.name : null); - setDownloadProgress(0); try { - await (type === 'rocm' - ? window.electronAPI.kobold.downloadROCm() - : await window.electronAPI.kobold.downloadRelease(asset!)); + const success = await sharedHandleDownload(type, asset); - onDownloadComplete(); + if (success) { + onDownloadComplete(); - setTimeout(() => { - setDownloading(false); - setDownloadingType(null); - setDownloadingAsset(null); - setDownloadProgress(0); - }, 200); + setTimeout(() => { + setDownloadingType(null); + setDownloadingAsset(null); + }, 200); + } } catch (error) { console.error(`Failed to download ${type}:`, error); - setDownloading(false); + } finally { setDownloadingType(null); setDownloadingAsset(null); - setDownloadProgress(0); } }, - [onDownloadComplete] + [sharedHandleDownload, onDownloadComplete] ); const renderROCmCard = () => { @@ -152,10 +81,17 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => { name={ROCM.BINARY_NAME} size={formatFileSize(ROCM.SIZE_BYTES)} description={getAssetDescription(ROCM.BINARY_NAME)} - isRecommended={isAssetRecommended(ROCM.BINARY_NAME, hasAMDGPU)} - isDownloading={downloading && downloadingType === 'rocm'} - downloadProgress={downloadingType === 'rocm' ? downloadProgress : 0} - disabled={downloading && downloadingType !== 'rocm'} + isRecommended={isAssetRecommended( + ROCM.BINARY_NAME, + platformInfo.hasAMDGPU + )} + isDownloading={Boolean(downloading) && downloadingType === 'rocm'} + downloadProgress={ + downloadingType === 'rocm' + ? downloadProgress[ROCM.BINARY_NAME] || 0 + : 0 + } + disabled={Boolean(downloading) && downloadingType !== 'rocm'} onDownload={(e) => { e.stopPropagation(); handleDownload('rocm'); @@ -208,7 +144,7 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => { {filteredAssets.length > 0 || rocmDownload ? ( - {hasAMDGPU && !hasROCm && ( + {platformInfo.hasAMDGPU && !platformInfo.hasROCm && (
{ AMD GPU Detected - { > ROCm Not Found - +
For best performance with your AMD GPU, consider @@ -243,11 +179,13 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
)} - {rocmDownload && hasAMDGPU && renderROCmCard()} + {rocmDownload && + platformInfo.hasAMDGPU && + renderROCmCard()} {sortAssetsByRecommendation( filteredAssets, - hasAMDGPU + platformInfo.hasAMDGPU ).map((asset) => ( { description={getAssetDescription(asset.name)} isRecommended={isAssetRecommended( asset.name, - hasAMDGPU + platformInfo.hasAMDGPU )} isDownloading={ - downloading && + Boolean(downloading) && downloadingType === 'asset' && downloadingAsset === asset.name } downloadProgress={ - downloading && + Boolean(downloading) && downloadingType === 'asset' && downloadingAsset === asset.name - ? downloadProgress + ? downloadProgress[asset.name] || 0 : 0 } disabled={ - downloading && downloadingAsset !== asset.name + Boolean(downloading) && + downloadingAsset !== asset.name } onDownload={(e) => { e.stopPropagation(); @@ -280,7 +219,9 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => { /> ))} - {rocmDownload && !hasAMDGPU && renderROCmCard()} + {rocmDownload && + !platformInfo.hasAMDGPU && + renderROCmCard()}
) : ( @@ -288,7 +229,7 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => { No downloads available No downloads available for your platform ( - {getPlatformDisplayName(userPlatform)}). + {getPlatformDisplayName(platformInfo.platform)}).
diff --git a/src/components/interface/ChatTab.tsx b/src/screens/Interface/ImageGenerationTab.tsx similarity index 74% rename from src/components/interface/ChatTab.tsx rename to src/screens/Interface/ImageGenerationTab.tsx index cb83500..7f3a350 100644 --- a/src/components/interface/ChatTab.tsx +++ b/src/screens/Interface/ImageGenerationTab.tsx @@ -1,12 +1,15 @@ import { useRef } from 'react'; import { Box, Text, Stack } from '@mantine/core'; -interface ChatTabProps { +interface ImageGenerationTabProps { serverUrl?: string; isServerReady?: boolean; } -export const ChatTab = ({ serverUrl, isServerReady }: ChatTabProps) => { +export const ImageGenerationTab = ({ + serverUrl, + isServerReady, +}: ImageGenerationTabProps) => { const iframeRef = useRef(null); if (!isServerReady || !serverUrl) { @@ -25,19 +28,21 @@ export const ChatTab = ({ serverUrl, isServerReady }: ChatTabProps) => { Waiting for KoboldCpp server to start... - The chat interface will load automatically when ready + The image generation interface will load automatically when ready ); } + const imageGenerationUrl = `${serverUrl}/sdapi/v1`; + return (