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.
-
+
## 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

@@ -72,12 +70,13 @@ The AUR package automatically handles installation, desktop integration, and sys

-### Seemless SillyTavern integration
+### Seamless SillyTavern integration

-
-
+### Seamless OpenWebUI 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
)}
+