diff --git a/package.json b/package.json index a00442c..e998aa0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gerbil", "productName": "Gerbil", - "version": "1.6.0", + "version": "1.6.1", "description": "Run Large Language Models locally", "main": "out/main/index.js", "homepage": "./", diff --git a/src/components/App/StatusBar.tsx b/src/components/App/StatusBar.tsx index 3013112..7e7a783 100644 --- a/src/components/App/StatusBar.tsx +++ b/src/components/App/StatusBar.tsx @@ -128,7 +128,7 @@ export const StatusBar = ({ maxDataPoints = 60 }: StatusBarProps) => { ))} ) : ( - + )} diff --git a/src/components/settings/AboutTab.tsx b/src/components/settings/AboutTab.tsx index 8505900..997b700 100644 --- a/src/components/settings/AboutTab.tsx +++ b/src/components/settings/AboutTab.tsx @@ -1,9 +1,4 @@ import { useState, useEffect } from 'react'; -import { - createSoftwareItems, - createDriverItems, - createHardwareItems, -} from '@/utils/systemInfo'; import { Text, Stack, @@ -15,11 +10,9 @@ import { Button, rem, } from '@mantine/core'; -import { Github, FolderOpen, FileText } from 'lucide-react'; +import { Github } from 'lucide-react'; import { useLogoClickSounds } from '@/hooks/useLogoClickSounds'; import { PRODUCT_NAME, GITHUB_API } from '@/constants'; -import { InfoCard } from '@/components/InfoCard'; -import type { HardwareInfo } from '@/types/hardware'; import type { SystemVersionInfo } from '@/types/electron'; import icon from '/icon.png'; @@ -28,7 +21,6 @@ export const AboutTab = () => { const [versionInfo, setVersionInfo] = useState( null ); - const [hardwareInfo, setHardwareInfo] = useState(null); const { handleLogoClick, getLogoStyles } = useLogoClickSounds(); useEffect(() => { @@ -39,34 +31,7 @@ export const AboutTab = () => { } }; - const loadHardwareInfo = async () => { - try { - const [cpu, gpu, gpuCapabilities, gpuMemory, systemMemory] = - await Promise.all([ - window.electronAPI.kobold.detectCPU(), - window.electronAPI.kobold.detectGPU(), - window.electronAPI.kobold.detectGPUCapabilities(), - window.electronAPI.kobold.detectGPUMemory(), - window.electronAPI.kobold.detectSystemMemory(), - ]); - - setHardwareInfo({ - cpu, - gpu, - gpuCapabilities, - gpuMemory, - systemMemory, - }); - } catch (error) { - window.electronAPI.logs.logError( - 'Failed to load hardware info', - error as Error - ); - } - }; - loadVersionInfo(); - loadHardwareInfo(); }, []); if (!versionInfo) { @@ -77,10 +42,6 @@ export const AboutTab = () => { ); } - const softwareItems = createSoftwareItems(versionInfo); - const driverItems = hardwareInfo ? createDriverItems(hardwareInfo) : []; - const hardwareItems = hardwareInfo ? createHardwareItems(hardwareInfo) : []; - const actionButtons = [ { icon: Github, @@ -88,16 +49,6 @@ export const AboutTab = () => { onClick: () => window.electronAPI.app.openExternal(GITHUB_API.GERBIL_GITHUB_URL), }, - { - icon: FolderOpen, - label: 'Show Logs', - onClick: () => window.electronAPI.app.showLogsFolder(), - }, - { - icon: FileText, - label: 'View Config', - onClick: () => window.electronAPI.app.viewConfigFile(), - }, ]; return ( @@ -157,15 +108,18 @@ export const AboutTab = () => { - - - - - + + + About {PRODUCT_NAME} + + + {PRODUCT_NAME} is a user-friendly desktop application that makes it + easy to run large language models locally on your machine. Whether + you're looking to chat with AI models, generate images, or + explore different interfaces like SillyTavern and Open WebUI,{' '} + {PRODUCT_NAME} provides a streamlined experience for local AI. + + ); }; diff --git a/src/components/settings/AppearanceTab.tsx b/src/components/settings/AppearanceTab.tsx index 8c7c47c..1e56c09 100644 --- a/src/components/settings/AppearanceTab.tsx +++ b/src/components/settings/AppearanceTab.tsx @@ -11,14 +11,21 @@ import { import { Sun, Moon, Monitor } from 'lucide-react'; import { useState, useEffect } from 'react'; import { usePreferencesStore } from '@/stores/preferences'; +import { ZOOM } from '@/constants'; +import { FrontendInterfaceSelector } from '@/components/settings/FrontendInterfaceSelector'; import { zoomLevelToPercentage, percentageToZoomLevel, isValidZoomPercentage, } from '@/utils/zoom'; -import { ZOOM } from '@/constants'; -export const AppearanceTab = () => { +interface AppearanceTabProps { + isOnInterfaceScreen?: boolean; +} + +export const AppearanceTab = ({ + isOnInterfaceScreen = false, +}: AppearanceTabProps) => { const { rawColorScheme, resolvedColorScheme, @@ -70,6 +77,9 @@ export const AppearanceTab = () => { return ( +
+ +
Theme @@ -127,7 +137,6 @@ export const AppearanceTab = () => { ]} />
-
Zoom Level diff --git a/src/components/settings/FrontendInterfaceSelector.tsx b/src/components/settings/FrontendInterfaceSelector.tsx new file mode 100644 index 0000000..01ba2eb --- /dev/null +++ b/src/components/settings/FrontendInterfaceSelector.tsx @@ -0,0 +1,270 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { Text, Box, Anchor, rem } from '@mantine/core'; +import { Monitor } from 'lucide-react'; +import { usePreferencesStore } from '@/stores/preferences'; +import type { FrontendPreference } from '@/types'; +import { FRONTENDS } from '@/constants'; +import { Select } from '@/components/Select'; + +interface FrontendRequirement { + id: string; + name: string; + url: string; +} + +interface FrontendConfig { + value: string; + label: string; + badges: string[]; + requirements?: FrontendRequirement[]; + requirementCheck?: () => Promise; +} + +interface FrontendInterfaceSelectorProps { + isOnInterfaceScreen?: boolean; +} + +export const FrontendInterfaceSelector = ({ + isOnInterfaceScreen = false, +}: FrontendInterfaceSelectorProps) => { + const { frontendPreference, setFrontendPreference } = usePreferencesStore(); + + const [frontendRequirements, setFrontendRequirements] = useState< + Map + >(new Map()); + + const frontendConfigs: FrontendConfig[] = useMemo( + () => [ + { + value: 'koboldcpp', + label: 'Built-in', + badges: ['Text', 'Image'], + }, + { + value: 'sillytavern', + label: FRONTENDS.SILLYTAVERN, + badges: ['Text', 'Image'], + requirements: [ + { + id: 'nodejs', + name: 'Node.js', + url: 'https://nodejs.org/', + }, + ], + requirementCheck: () => + window.electronAPI.dependencies.isNpxAvailable(), + }, + { + value: 'openwebui', + label: FRONTENDS.OPENWEBUI, + badges: ['Text', 'Image'], + requirements: [ + { + id: 'uv', + name: 'uv', + url: 'https://docs.astral.sh/uv/getting-started/installation/', + }, + ], + requirementCheck: () => window.electronAPI.dependencies.isUvAvailable(), + }, + { + value: 'comfyui', + label: FRONTENDS.COMFYUI, + badges: ['Image'], + requirements: [ + { + id: 'uv', + name: 'uv', + url: 'https://docs.astral.sh/uv/getting-started/installation/', + }, + ], + requirementCheck: () => window.electronAPI.dependencies.isUvAvailable(), + }, + ], + [] + ); + + const checkAllFrontendRequirements = useCallback(async () => { + const requirementResults = new Map(); + + for (const config of frontendConfigs) { + if (config.requirementCheck) { + const isAvailable = await config.requirementCheck(); + requirementResults.set(config.value, isAvailable); + } else { + requirementResults.set(config.value, true); + } + } + + setFrontendRequirements(requirementResults); + + const currentFrontendConfig = frontendConfigs.find( + (config) => config.value === frontendPreference + ); + if (currentFrontendConfig && !requirementResults.get(frontendPreference)) { + setFrontendPreference('koboldcpp'); + } + }, [frontendConfigs, frontendPreference, setFrontendPreference]); + + const getSelectedFrontendConfig = () => + frontendConfigs.find((config) => config.value === frontendPreference); + + const getUnmetRequirements = () => { + const selectedConfig = getSelectedFrontendConfig(); + if (!selectedConfig || !selectedConfig.requirements) return []; + + const isAvailable = frontendRequirements.get(selectedConfig.value) ?? true; + return isAvailable ? [] : selectedConfig.requirements; + }; + + const getUnmetRequirementsForFrontend = (frontendValue: string) => { + const config = frontendConfigs.find((c) => c.value === frontendValue); + if (!config || !config.requirements) return []; + + const isAvailable = frontendRequirements.get(frontendValue) ?? true; + return isAvailable ? [] : config.requirements; + }; + + const isFrontendAvailable = (frontendValue: string) => + frontendRequirements.get(frontendValue) ?? true; + + const handleFrontendPreferenceChange = (value: string | null) => { + setFrontendPreference(value as FrontendPreference); + }; + + const renderDisabledFrontendWarnings = () => { + const disabledFrontends = frontendConfigs.filter( + (config) => !isFrontendAvailable(config.value) + ); + + if (disabledFrontends.length === 0) { + return null; + } + + const requirementGroups = new Map(); + + disabledFrontends.forEach((config) => { + const unmetReqs = getUnmetRequirementsForFrontend(config.value); + const reqKey = unmetReqs.map((req) => req.id).join(','); + if (!requirementGroups.has(reqKey)) { + requirementGroups.set(reqKey, []); + } + requirementGroups.get(reqKey)!.push(config.label); + }); + + return ( + + {Array.from(requirementGroups.entries()).map( + ([reqKey, frontendLabels]) => { + const firstDisabledFrontend = disabledFrontends.find( + (config) => + getUnmetRequirementsForFrontend(config.value) + .map((req) => req.id) + .join(',') === reqKey + ); + const unmetReqs = firstDisabledFrontend + ? getUnmetRequirementsForFrontend(firstDisabledFrontend.value) + : []; + + const frontendText = + frontendLabels.length === 1 + ? frontendLabels[0] + : frontendLabels.length === 2 + ? `${frontendLabels[0]} and ${frontendLabels[1]}` + : `${frontendLabels.slice(0, -1).join(', ')}, and ${frontendLabels[frontendLabels.length - 1]}`; + + const isAre = frontendLabels.length === 1 ? 'is' : 'are'; + + return ( + + {frontendText} {isAre} disabled - requires{' '} + {unmetReqs.map((req, index) => ( + + + {req.name} + + {index < unmetReqs.length - 1 ? ', ' : ''} + + ))} + + ); + } + )} + + ); + }; + + useEffect(() => { + const initialize = async () => { + await checkAllFrontendRequirements(); + }; + initialize(); + + const handleFocus = () => { + checkAllFrontendRequirements(); + }; + + window.addEventListener('focus', handleFocus); + return () => window.removeEventListener('focus', handleFocus); + }, [checkAllFrontendRequirements]); + + useEffect(() => { + if (frontendPreference) { + checkAllFrontendRequirements(); + } + }, [frontendPreference, checkAllFrontendRequirements]); + + return ( + <> + + Frontend Interface + + + {getUnmetRequirements().length === 0 && ( + + Choose which frontend interface to use for interacting with AI models + + )} + + {getUnmetRequirements().length > 0 && ( + + {getSelectedFrontendConfig()?.label} requires{' '} + {getUnmetRequirements().map((req, index) => ( + + + {req.name} + + {index < getUnmetRequirements().length - 1 ? ', ' : ''} + + ))}{' '} + to be installed on your system + + )} + + ({ - value: config.value, - label: config.label, - disabled: !isFrontendAvailable(config.value), - }))} - leftSection={} - /> - - - {(() => { - const disabledFrontends = frontendConfigs.filter( - (config) => !isFrontendAvailable(config.value) - ); - - const requirementGroups = new Map(); - - disabledFrontends.forEach((config) => { - const unmetReqs = getUnmetRequirementsForFrontend(config.value); - const reqKey = unmetReqs.map((req) => req.id).join(','); - if (!requirementGroups.has(reqKey)) { - requirementGroups.set(reqKey, []); - } - requirementGroups.get(reqKey)!.push(config.label); - }); - - return Array.from(requirementGroups.entries()).map( - ([reqKey, frontendLabels]) => { - const firstDisabledFrontend = disabledFrontends.find( - (config) => - getUnmetRequirementsForFrontend(config.value) - .map((req) => req.id) - .join(',') === reqKey - ); - const unmetReqs = firstDisabledFrontend - ? getUnmetRequirementsForFrontend(firstDisabledFrontend.value) - : []; - - const frontendText = - frontendLabels.length === 1 - ? frontendLabels[0] - : frontendLabels.length === 2 - ? `${frontendLabels[0]} and ${frontendLabels[1]}` - : `${frontendLabels.slice(0, -1).join(', ')}, and ${frontendLabels[frontendLabels.length - 1]}`; - - const isAre = frontendLabels.length === 1 ? 'is' : 'are'; - - return ( - - {frontendText} {isAre} disabled - requires{' '} - {unmetReqs.map((req, index) => ( - - - {req.name} - - {index < unmetReqs.length - 1 ? ', ' : ''} - - ))} - - ); - } - ); - })()} - -
- -
- - System Performance + Status Bar - Monitor CPU, memory, and GPU usage in the status bar + Control what information is displayed in the status bar
+ +
+ + Troubleshooting + + + Diagnostic tools and configuration access + + + + + +
); }; diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index f097778..8ee5735 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -5,11 +5,13 @@ import { Palette, SlidersHorizontal, GitBranch, + Monitor, Info, } from 'lucide-react'; import { GeneralTab } from '@/components/settings/GeneralTab'; import { VersionsTab } from '@/components/settings/VersionsTab'; import { AppearanceTab } from '@/components/settings/AppearanceTab'; +import { SystemTab } from '@/components/settings/SystemTab'; import { AboutTab } from '@/components/settings/AboutTab'; import type { Screen } from '@/types'; import { Modal } from '@/components/Modal'; @@ -129,6 +131,14 @@ export const SettingsModal = ({ > Appearance + + } + > + System + } @@ -138,7 +148,7 @@ export const SettingsModal = ({ - + {showVersionsTab && ( @@ -148,7 +158,11 @@ export const SettingsModal = ({ )} - + + + + + diff --git a/src/components/settings/SystemTab.tsx b/src/components/settings/SystemTab.tsx new file mode 100644 index 0000000..2346059 --- /dev/null +++ b/src/components/settings/SystemTab.tsx @@ -0,0 +1,81 @@ +import { useState, useEffect } from 'react'; +import { + createSoftwareItems, + createDriverItems, + createHardwareItems, +} from '@/utils/systemInfo'; +import { Stack, Text, Center } from '@mantine/core'; +import { InfoCard } from '@/components/InfoCard'; +import type { HardwareInfo } from '@/types/hardware'; +import type { SystemVersionInfo } from '@/types/electron'; + +export const SystemTab = () => { + const [versionInfo, setVersionInfo] = useState( + null + ); + const [hardwareInfo, setHardwareInfo] = useState(null); + + useEffect(() => { + const loadVersionInfo = async () => { + const info = await window.electronAPI.app.getVersionInfo(); + if (info) { + setVersionInfo(info); + } + }; + + const loadHardwareInfo = async () => { + try { + const [cpu, gpu, gpuCapabilities, gpuMemory, systemMemory] = + await Promise.all([ + window.electronAPI.kobold.detectCPU(), + window.electronAPI.kobold.detectGPU(), + window.electronAPI.kobold.detectGPUCapabilities(), + window.electronAPI.kobold.detectGPUMemory(), + window.electronAPI.kobold.detectSystemMemory(), + ]); + + setHardwareInfo({ + cpu, + gpu, + gpuCapabilities, + gpuMemory, + systemMemory, + }); + } catch (error) { + window.electronAPI.logs.logError( + 'Failed to load hardware info', + error as Error + ); + } + }; + + loadVersionInfo(); + loadHardwareInfo(); + }, []); + + if (!versionInfo) { + return ( +
+ Loading system information... +
+ ); + } + + const softwareItems = createSoftwareItems(versionInfo); + const driverItems = hardwareInfo ? createDriverItems(hardwareInfo) : []; + const hardwareItems = hardwareInfo ? createHardwareItems(hardwareInfo) : []; + + return ( + + + + + + + + ); +}; diff --git a/src/main/modules/config.ts b/src/main/modules/config.ts index 7788c09..ff2fdba 100644 --- a/src/main/modules/config.ts +++ b/src/main/modules/config.ts @@ -51,10 +51,6 @@ async function saveConfig() { return success !== null; } -function saveConfigAsync() { - saveConfig(); -} - export async function initialize() { configPath = getConfigDir(); config = await loadConfig(); @@ -85,9 +81,9 @@ function getDefaultInstallDir() { export const getInstallDir = () => config.installDir || getDefaultInstallDir(); -export function setInstallDir(dir: string) { +export async function setInstallDir(dir: string) { config.installDir = dir; - saveConfigAsync(); + await saveConfig(); } export function getCurrentKoboldBinary() { @@ -95,23 +91,23 @@ export function getCurrentKoboldBinary() { return path ? path.trim() : path; } -export function setCurrentKoboldBinary(binaryPath: string) { +export async function setCurrentKoboldBinary(binaryPath: string) { config.currentKoboldBinary = binaryPath; - saveConfigAsync(); + await saveConfig(); } export const getSelectedConfig = () => config.selectedConfig; -export function setSelectedConfig(configName: string) { +export async function setSelectedConfig(configName: string) { config.selectedConfig = configName; - saveConfigAsync(); + await saveConfig(); } export const getColorScheme = () => config.colorScheme || 'auto'; -export function setColorScheme(colorScheme: MantineColorScheme) { +export async function setColorScheme(colorScheme: MantineColorScheme) { config.colorScheme = colorScheme; - saveConfigAsync(); + await saveConfig(); } export function getBackgroundColor() { @@ -128,42 +124,42 @@ export function getBackgroundColor() { export const getWindowBounds = () => config.windowBounds; -export function setWindowBounds(bounds: WindowBounds) { +export async function setWindowBounds(bounds: WindowBounds) { config.windowBounds = bounds; - saveConfigAsync(); + await saveConfig(); } export const getFrontendPreference = () => config.frontendPreference; -export function setFrontendPreference(preference: FrontendPreference) { +export async function setFrontendPreference(preference: FrontendPreference) { config.frontendPreference = preference; - saveConfigAsync(); + await saveConfig(); } export const getHasSeenWelcome = () => config.hasSeenWelcome; -export function setHasSeenWelcome(hasSeenWelcome: boolean) { +export async function setHasSeenWelcome(hasSeenWelcome: boolean) { config.hasSeenWelcome = hasSeenWelcome; - saveConfigAsync(); + await saveConfig(); } export const getSkipEjectConfirmation = () => config.skipEjectConfirmation; -export function setSkipEjectConfirmation(skipEjectConfirmation: boolean) { +export async function setSkipEjectConfirmation(skipEjectConfirmation: boolean) { config.skipEjectConfirmation = skipEjectConfirmation; - saveConfigAsync(); + await saveConfig(); } export const getDismissedUpdates = () => config.dismissedUpdates || []; -export function setDismissedUpdates(dismissedUpdates: DismissedUpdate[]) { +export async function setDismissedUpdates(dismissedUpdates: DismissedUpdate[]) { config.dismissedUpdates = dismissedUpdates; - saveConfigAsync(); + await saveConfig(); } export const getZoomLevel = () => config.zoomLevel; -export function setZoomLevel(zoomLevel: number) { +export async function setZoomLevel(zoomLevel: number) { config.zoomLevel = zoomLevel; - saveConfigAsync(); + await saveConfig(); } diff --git a/src/utils/node/fs.ts b/src/utils/node/fs.ts index a7b44e1..0ddfab0 100644 --- a/src/utils/node/fs.ts +++ b/src/utils/node/fs.ts @@ -1,5 +1,6 @@ -import { readFile, writeFile, access, mkdir } from 'fs/promises'; +import { readFile, writeFile, access, mkdir, rename } from 'fs/promises'; import { constants } from 'fs'; +import { dirname } from 'path'; export const pathExists = async (path: string) => { try { @@ -20,8 +21,14 @@ export const readJsonFile = async (path: string) => { }; export const writeJsonFile = async (path: string, data: unknown) => { + const dir = dirname(path); + + await ensureDir(dir); + const content = JSON.stringify(data, null, 2); - await writeFile(path, content, 'utf-8'); + const tempPath = `${path}.tmp`; + await writeFile(tempPath, content, 'utf-8'); + await rename(tempPath, path); }; export const ensureDir = async (path: string) => { diff --git a/src/utils/systemInfo.ts b/src/utils/systemInfo.ts index 5633dd7..7172f70 100644 --- a/src/utils/systemInfo.ts +++ b/src/utils/systemInfo.ts @@ -48,6 +48,13 @@ export const createDriverItems = (hardwareInfo: HardwareInfo) => { ]; } + if (gpuCapabilities.cuda.driverVersion) { + items.push({ + label: 'NVIDIA', + value: gpuCapabilities.cuda.driverVersion, + }); + } + if (gpuCapabilities.cuda.devices.length > 0) { items.push({ label: 'CUDA', @@ -55,13 +62,13 @@ export const createDriverItems = (hardwareInfo: HardwareInfo) => { ? gpuCapabilities.cuda.version : 'Available', }); + } - if (gpuCapabilities.cuda.driverVersion) { - items.push({ - label: 'NVIDIA', - value: gpuCapabilities.cuda.driverVersion, - }); - } + if (gpuCapabilities.rocm.driverVersion) { + items.push({ + label: 'AMD', + value: gpuCapabilities.rocm.driverVersion, + }); } if (gpuCapabilities.rocm.devices.length > 0) { @@ -71,13 +78,6 @@ export const createDriverItems = (hardwareInfo: HardwareInfo) => { ? gpuCapabilities.rocm.version : 'Available', }); - - if (gpuCapabilities.rocm.driverVersion) { - items.push({ - label: 'AMD', - value: gpuCapabilities.rocm.driverVersion, - }); - } } if (gpuCapabilities.vulkan.devices.length > 0) {