diff --git a/.gitignore b/.gitignore index 5d338e1..a3aeb29 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ clip_l.safetensors t5xxl_fp8_e4m3fn.safetensors flux1-kontext-dev-Q3_K_S.gguf gemma-3-4b-it.Q8_0.gguf +.webui_secret_key diff --git a/README.md b/README.md index 626b868..f0ca275 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,20 @@ A desktop app to easily run Large Language Models locally. -Gerbil Icon +Gerbil Icon ## Core Features -- **Run LLMs locally** powered by [KoboldCpp](https://github.com/LostRuins/koboldcpp) +- **Run LLMs locally** powered by [KoboldCpp](https://github.com/LostRuins/koboldcpp) which itself is a highly modified fork of [llama.cpp](https://github.com/ggml-org/llama.cpp) - **Cross-platform desktop app** - Native support for Windows, macOS, and Linux (including Wayland) - **Automatic updates** - Download and keep your KoboldCpp binary up-to-date effortlessly - **Smart process management** - Prevents runaway background processes and system resource waste - **Optimized performance** - Automatically unpacks binaries for faster operation and reduced memory usage - **Image generation support** - Built-in presets for Flux and Chroma image generation workflows - **SillyTavern integration** - Seamlessly launch SillyTavern for advanced character interactions (requires [Node.js](https://nodejs.org/)) +- **OpenWebUI integration** - Launch OpenWebUI for a modern web-based chat interface (requires [uv](https://docs.astral.sh/uv/getting-started/installation/)) - **Privacy-focused** - Everything runs locally on your machine, no data sent to external servers ## Installation @@ -49,9 +50,6 @@ The AUR package automatically handles installation, desktop integration, and sys ## Screenshots - -
- ### Download & Setup Download Interface @@ -72,12 +70,13 @@ The AUR package automatically handles installation, desktop integration, and sys Image Generation -### Seemless SillyTavern integration +### Seamless SillyTavern integration SillyTavern integration -
- +### Seamless OpenWebUI integration + +SillyTavern integration ### Future features @@ -147,3 +146,5 @@ You can use the CLI mode on Windows in exactly the same way as in the Linux/macO ## License AGPL v3 License - see LICENSE file for details + +# test diff --git a/package.json b/package.json index 1087f10..84b02d4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gerbil", "productName": "Gerbil", - "version": "0.9.6", + "version": "0.9.7", "description": "Run Large Language Models locally", "main": "out/main/index.js", "homepage": "./", @@ -56,11 +56,11 @@ "@types/node": "^24.3.0", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", - "@typescript-eslint/eslint-plugin": "^8.41.0", - "@typescript-eslint/parser": "^8.41.0", + "@typescript-eslint/eslint-plugin": "^8.42.0", + "@typescript-eslint/parser": "^8.42.0", "@vitejs/plugin-react": "^5.0.2", "cross-env": "^10.0.0", - "electron": "^37.4.0", + "electron": "^38.0.0", "electron-builder": "^26.0.12", "electron-vite": "^4.0.0", "eslint": "^9.34.0", @@ -116,15 +116,6 @@ ] } ], - "extraResources": [ - { - "from": "src/main/templates", - "to": "templates", - "filter": [ - "**/*" - ] - } - ], "mac": { "compression": "maximum", "category": "public.app-category.productivity", diff --git a/screenshots/openwebui.png b/screenshots/openwebui.png new file mode 100644 index 0000000..93badba Binary files /dev/null and b/screenshots/openwebui.png differ diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index 8b72bfd..022c62a 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -165,12 +165,20 @@ export const TitleBar = ({ data={[ { value: 'chat', - label: - frontendPreference === 'sillytavern' - ? FRONTENDS.SILLYTAVERN - : isImageGenerationMode - ? FRONTENDS.STABLE_UI - : FRONTENDS.KOBOLDAI_LITE, + label: (() => { + if (frontendPreference === 'sillytavern') { + return FRONTENDS.SILLYTAVERN; + } + if ( + frontendPreference === 'openwebui' && + !isImageGenerationMode + ) { + return FRONTENDS.OPENWEBUI; + } + return isImageGenerationMode + ? FRONTENDS.STABLE_UI + : FRONTENDS.KOBOLDAI_LITE; + })(), }, { value: 'terminal', label: 'Terminal' }, { value: 'eject', label: 'Eject' }, diff --git a/src/components/screens/Interface/ServerTab.tsx b/src/components/screens/Interface/ServerTab.tsx index cafafae..14e0b4f 100644 --- a/src/components/screens/Interface/ServerTab.tsx +++ b/src/components/screens/Interface/ServerTab.tsx @@ -1,5 +1,10 @@ import { Box, Text, Stack } from '@mantine/core'; -import { SILLYTAVERN, FRONTENDS, TITLEBAR_HEIGHT } from '@/constants'; +import { + SILLYTAVERN, + OPENWEBUI, + FRONTENDS, + TITLEBAR_HEIGHT, +} from '@/constants'; import { useLaunchConfigStore } from '@/stores/launchConfig'; import type { FrontendPreference } from '@/types'; @@ -46,6 +51,9 @@ export const ServerTab = ({ if (frontendPreference === 'sillytavern') { iframeUrl = SILLYTAVERN.PROXY_URL; title = FRONTENDS.SILLYTAVERN; + } else if (frontendPreference === 'openwebui' && !isImageGenerationMode) { + iframeUrl = OPENWEBUI.URL; + title = FRONTENDS.OPENWEBUI; } else { iframeUrl = isImageGenerationMode ? `${serverUrl}/sdui` : serverUrl; title = isImageGenerationMode diff --git a/src/components/screens/Interface/TerminalTab.tsx b/src/components/screens/Interface/TerminalTab.tsx index f496883..c2df986 100644 --- a/src/components/screens/Interface/TerminalTab.tsx +++ b/src/components/screens/Interface/TerminalTab.tsx @@ -22,7 +22,7 @@ export const TerminalTab = ({ onServerReady, frontendPreference = 'koboldcpp', }: TerminalTabProps) => { - const { host, port } = useLaunchConfigStore(); + const { host, port, isImageGenerationMode } = useLaunchConfigStore(); const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: false, }); @@ -68,20 +68,22 @@ export const TerminalTab = ({ const serverHost = host || 'localhost'; const serverPort = port || 5001; + let signalToCheck: string = SERVER_READY_SIGNALS.KOBOLDCPP; + if (frontendPreference === 'sillytavern') { - if (newData.includes(SERVER_READY_SIGNALS.SILLYTAVERN)) { - setTimeout( - () => onServerReady(`http://${serverHost}:${serverPort}`), - 1500 - ); - } - } else { - if (newData.includes(SERVER_READY_SIGNALS.KOBOLDCPP)) { - setTimeout( - () => onServerReady(`http://${serverHost}:${serverPort}`), - 1500 - ); - } + signalToCheck = SERVER_READY_SIGNALS.SILLYTAVERN; + } else if ( + frontendPreference === 'openwebui' && + !isImageGenerationMode + ) { + signalToCheck = SERVER_READY_SIGNALS.OPENWEBUI; + } + + if (newData.includes(signalToCheck)) { + setTimeout( + () => onServerReady(`http://${serverHost}:${serverPort}`), + 1500 + ); } } @@ -90,7 +92,7 @@ export const TerminalTab = ({ }); return cleanup; - }, [onServerReady, host, port, frontendPreference]); + }, [onServerReady, host, port, frontendPreference, isImageGenerationMode]); const scrollToBottom = () => { if (viewportRef.current) { diff --git a/src/components/settings/GeneralTab.tsx b/src/components/settings/GeneralTab.tsx index 88be870..fa00b07 100644 --- a/src/components/settings/GeneralTab.tsx +++ b/src/components/settings/GeneralTab.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { tryExecute, safeExecute } from '@/utils/logger'; import { Stack, @@ -8,6 +8,7 @@ import { Button, rem, Select, + Box, Anchor, } from '@mantine/core'; import { Folder, FolderOpen, Monitor } from 'lucide-react'; @@ -15,43 +16,125 @@ import styles from '@/styles/layout.module.css'; import type { FrontendPreference } from '@/types'; import { FRONTENDS } from '@/constants'; +interface FrontendRequirement { + id: string; + name: string; + url: string; +} + +interface FrontendConfig { + value: string; + label: string; + requirements?: FrontendRequirement[]; + requirementCheck?: () => Promise; +} + export const GeneralTab = () => { const [installDir, setInstallDir] = useState(''); const [FrontendPreference, setFrontendPreference] = useState('koboldcpp'); - const [isNpxAvailable, setIsNpxAvailable] = useState(true); + const [frontendRequirements, setFrontendRequirements] = useState< + Map + >(new Map()); + + const frontendConfigs: FrontendConfig[] = useMemo( + () => [ + { value: 'koboldcpp', label: 'KoboldCpp (Built-in)' }, + { + value: 'sillytavern', + label: FRONTENDS.SILLYTAVERN, + requirements: [ + { + id: 'nodejs', + name: 'Node.js', + url: 'https://nodejs.org/', + }, + ], + requirementCheck: () => window.electronAPI.sillytavern.isNpxAvailable(), + }, + { + value: 'openwebui', + label: FRONTENDS.OPENWEBUI, + requirements: [ + { + id: 'uv', + name: 'uv', + url: 'https://docs.astral.sh/uv/getting-started/installation/', + }, + ], + requirementCheck: () => window.electronAPI.openwebui.isUvAvailable(), + }, + ], + [] + ); + + const checkAllFrontendRequirements = useCallback(async () => { + const requirementResults = new Map(); + + for (const config of frontendConfigs) { + if (config.requirementCheck) { + const isAvailable = await safeExecute( + config.requirementCheck, + `Failed to check requirements for ${config.label}:` + ); + requirementResults.set(config.value, isAvailable ?? false); + } else { + requirementResults.set(config.value, true); + } + } + + setFrontendRequirements(requirementResults); + + const currentFrontendConfig = frontendConfigs.find( + (config) => config.value === FrontendPreference + ); + if (currentFrontendConfig && !requirementResults.get(FrontendPreference)) { + await tryExecute( + () => window.electronAPI.config.set('frontendPreference', 'koboldcpp'), + 'Failed to reset frontend preference:' + ); + setFrontendPreference('koboldcpp'); + } + }, [frontendConfigs, FrontendPreference]); useEffect(() => { const initialize = async () => { - await Promise.all([loadCurrentInstallDir(), loadFrontendPreference()]); + await Promise.all([ + loadCurrentInstallDir(), + loadFrontendPreference(), + checkAllFrontendRequirements(), + ]); }; initialize(); - }, []); + }, [checkAllFrontendRequirements]); + + 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; useEffect(() => { - const checkNpxAvailability = async () => { - const available = await safeExecute( - () => window.electronAPI.sillytavern.isNpxAvailable(), - 'Failed to check npx availability:' - ); - - const isAvailable = available ?? false; - setIsNpxAvailable(isAvailable); - - if (!isAvailable && FrontendPreference === 'sillytavern') { - await tryExecute( - () => - window.electronAPI.config.set('frontendPreference', 'koboldcpp'), - 'Failed to reset frontend preference:' - ); - setFrontendPreference('koboldcpp'); - } - }; - if (FrontendPreference) { - checkNpxAvailability(); + checkAllFrontendRequirements(); } - }, [FrontendPreference]); + }, [FrontendPreference, checkAllFrontendRequirements]); const loadCurrentInstallDir = async () => { const currentDir = await safeExecute( @@ -86,14 +169,15 @@ export const GeneralTab = () => { }; const handleFrontendPreferenceChange = async (value: string | null) => { - if (!value || (value !== 'koboldcpp' && value !== 'sillytavern')) return; + if (!value || !['koboldcpp', 'sillytavern', 'openwebui'].includes(value)) + return; const success = await tryExecute( () => window.electronAPI.config.set('frontendPreference', value), 'Failed to save frontend preference:' ); if (success) { - setFrontendPreference(value); + setFrontendPreference(value as FrontendPreference); } }; @@ -130,43 +214,76 @@ export const GeneralTab = () => { Frontend Interface - {isNpxAvailable && ( + + {getUnmetRequirements().length === 0 && ( Choose which frontend interface to use for interacting with AI models )} - {!isNpxAvailable && ( + {getUnmetRequirements().length > 0 && ( - Custom frontends require{' '} - { - e.preventDefault(); - window.electronAPI.app.openExternal('https://nodejs.org/'); - }} - c="red" - td="underline" - > - Node.js - {' '} + {getSelectedFrontendConfig()?.label} requires{' '} + {getUnmetRequirements().map((req, index) => ( + + { + e.preventDefault(); + window.electronAPI.app.openExternal(req.url); + }} + c="red" + td="underline" + > + {req.name} + + {index < getUnmetRequirements().length - 1 ? ', ' : ''} + + ))}{' '} to be installed on your system )} +