fully refactoring from classes to functions, don't allow switching frontends when launched

This commit is contained in:
Egor 2025-09-09 02:30:42 -07:00
parent e56b581109
commit 4d0bbf31ca
34 changed files with 3001 additions and 3835 deletions

View file

@ -143,6 +143,7 @@ const config = [
'arrow-body-style': ['error', 'as-needed'], 'arrow-body-style': ['error', 'as-needed'],
'prefer-arrow-callback': ['error', { allowNamedFunctions: false }], 'prefer-arrow-callback': ['error', { allowNamedFunctions: false }],
'object-shorthand': ['error', 'always'],
'no-console': 'error', 'no-console': 'error',

View file

@ -1,7 +1,7 @@
{ {
"name": "gerbil", "name": "gerbil",
"productName": "Gerbil", "productName": "Gerbil",
"version": "1.0.6", "version": "1.1.0",
"description": "Run Large Language Models locally", "description": "Run Large Language Models locally",
"main": "out/main/index.js", "main": "out/main/index.js",
"homepage": "./", "homepage": "./",
@ -67,9 +67,10 @@
"@mantine/hooks": "^8.3.0", "@mantine/hooks": "^8.3.0",
"axios": "^1.11.0", "axios": "^1.11.0",
"execa": "^9.6.0", "execa": "^9.6.0",
"lucide-react": "^0.542.0", "lucide-react": "^0.543.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-error-boundary": "^6.0.0",
"systeminformation": "^5.27.8", "systeminformation": "^5.27.8",
"winston": "^3.17.0", "winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0", "winston-daily-rotate-file": "^5.0.0",
@ -90,15 +91,7 @@
"out/**/*", "out/**/*",
"dist/**/*" "dist/**/*"
], ],
"extraFiles": [ "extraFiles": [],
{
"from": "assets/gerbil.desktop",
"to": "assets/gerbil.desktop",
"filter": [
"**/*"
]
}
],
"mac": { "mac": {
"compression": "maximum", "compression": "maximum",
"category": "public.app-category.productivity", "category": "public.app-category.productivity",
@ -163,6 +156,12 @@
"StartupWMClass": "Gerbil" "StartupWMClass": "Gerbil"
} }
}, },
"extraFiles": [
{
"from": "assets/gerbil.desktop",
"to": "assets/gerbil.desktop"
}
],
"target": [ "target": [
{ {
"target": "AppImage", "target": "AppImage",

View file

@ -224,6 +224,7 @@ export const App = () => {
/> />
</AppShell.Main> </AppShell.Main>
<SettingsModal <SettingsModal
isOnInterfaceScreen={currentScreen === 'interface'}
opened={settingsModalOpen} opened={settingsModalOpen}
onClose={async () => { onClose={async () => {
setSettingsModalOpen(false); setSettingsModalOpen(false);

File diff suppressed because one or more lines are too long

View file

@ -1,37 +1,20 @@
import { Component, ReactNode, ErrorInfo } from 'react'; import { ReactNode } from 'react';
import { Center, Stack, Text, Button, Alert, rem } from '@mantine/core'; import { Center, Stack, Text, Button, Alert, rem } from '@mantine/core';
import { AlertTriangle, FolderOpen } from 'lucide-react'; import { AlertTriangle, FolderOpen } from 'lucide-react';
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
import { safeExecute } from '@/utils/logger'; import { safeExecute } from '@/utils/logger';
interface Props { interface Props {
children: ReactNode; children: ReactNode;
} }
interface State { const ErrorFallback = ({
hasError: boolean; error,
error?: Error; resetErrorBoundary,
} }: {
error: Error;
export class ErrorBoundary extends Component<Props, State> { resetErrorBoundary: () => void;
constructor(props: Props) { }) => (
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, _errorInfo: ErrorInfo) {
window.electronAPI?.logs?.logError(
'App crashed with unhandled error:',
error
);
}
render() {
if (this.state.hasError) {
return (
<Center h="100%" style={{ minHeight: '25rem' }}> <Center h="100%" style={{ minHeight: '25rem' }}>
<Stack align="center" gap="lg" maw={500}> <Stack align="center" gap="lg" maw={500}>
<AlertTriangle size={64} color="var(--mantine-color-red-6)" /> <AlertTriangle size={64} color="var(--mantine-color-red-6)" />
@ -42,20 +25,13 @@ export class ErrorBoundary extends Component<Props, State> {
<Alert color="red" title="Something went wrong" w="100%"> <Alert color="red" title="Something went wrong" w="100%">
<Text size="sm" mb="md"> <Text size="sm" mb="md">
The application encountered an unexpected error and crashed. You The application encountered an unexpected error and crashed. You can
can view the error details in the logs folder. view the error details in the logs folder.
</Text> </Text>
{this.state.error && ( <Text size="xs" c="dimmed" style={{ fontFamily: 'monospace' }} mb="sm">
<Text {error.message}
size="xs"
c="dimmed"
style={{ fontFamily: 'monospace' }}
mb="sm"
>
{this.state.error.message}
</Text> </Text>
)}
<Button <Button
variant="light" variant="light"
@ -75,13 +51,7 @@ export class ErrorBoundary extends Component<Props, State> {
</Alert> </Alert>
<Stack gap="sm" w="100%"> <Stack gap="sm" w="100%">
<Button <Button onClick={resetErrorBoundary} variant="filled" fullWidth>
onClick={() => {
this.setState({ hasError: false, error: undefined });
}}
variant="filled"
fullWidth
>
Try Again Try Again
</Button> </Button>
@ -99,8 +69,17 @@ export class ErrorBoundary extends Component<Props, State> {
</Stack> </Stack>
</Center> </Center>
); );
}
return this.props.children; export const ErrorBoundary = ({ children }: Props) => (
} <ReactErrorBoundary
} FallbackComponent={ErrorFallback}
onError={(error) => {
window.electronAPI?.logs?.logError(
'App crashed with unhandled error:',
error
);
}}
>
{children}
</ReactErrorBoundary>
);

View file

@ -152,7 +152,7 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
}; };
const buildConfigData = () => ({ const buildConfigData = () => ({
autoGpuLayers: autoGpuLayers, autoGpuLayers,
gpulayers: gpuLayers, gpulayers: gpuLayers,
contextsize: contextSize, contextsize: contextSize,
model, model,

View file

@ -28,7 +28,13 @@ interface FrontendConfig {
requirementCheck?: () => Promise<boolean>; requirementCheck?: () => Promise<boolean>;
} }
export const GeneralTab = () => { interface GeneralTabProps {
isOnInterfaceScreen?: boolean;
}
export const GeneralTab = ({
isOnInterfaceScreen = false,
}: GeneralTabProps) => {
const [installDir, setInstallDir] = useState<string>(''); const [installDir, setInstallDir] = useState<string>('');
const [FrontendPreference, setFrontendPreference] = const [FrontendPreference, setFrontendPreference] =
useState<FrontendPreference>('koboldcpp'); useState<FrontendPreference>('koboldcpp');
@ -245,6 +251,7 @@ export const GeneralTab = () => {
<Select <Select
value={FrontendPreference} value={FrontendPreference}
onChange={handleFrontendPreferenceChange} onChange={handleFrontendPreferenceChange}
disabled={isOnInterfaceScreen}
data={frontendConfigs.map((config) => ({ data={frontendConfigs.map((config) => ({
value: config.value, value: config.value,
label: config.label, label: config.label,

View file

@ -18,12 +18,14 @@ interface SettingsModalProps {
opened: boolean; opened: boolean;
onClose: () => void; onClose: () => void;
currentScreen?: Screen; currentScreen?: Screen;
isOnInterfaceScreen?: boolean;
} }
export const SettingsModal = ({ export const SettingsModal = ({
opened, opened,
onClose, onClose,
currentScreen, currentScreen,
isOnInterfaceScreen = false,
}: SettingsModalProps) => { }: SettingsModalProps) => {
const [activeTab, setActiveTab] = useState('general'); const [activeTab, setActiveTab] = useState('general');
@ -140,7 +142,7 @@ export const SettingsModal = ({
</Tabs.List> </Tabs.List>
<Tabs.Panel value="general"> <Tabs.Panel value="general">
<GeneralTab /> <GeneralTab isOnInterfaceScreen={isOnInterfaceScreen} />
</Tabs.Panel> </Tabs.Panel>
{showVersionsTab && ( {showVersionsTab && (

View file

@ -24,6 +24,8 @@ export const DEFAULT_CONTEXT_SIZE = 4096;
export const DEFAULT_MODEL_URL = export const DEFAULT_MODEL_URL =
'https://huggingface.co/MaziyarPanahi/gemma-3-4b-it-GGUF/resolve/main/gemma-3-4b-it.Q8_0.gguf?download=true'; 'https://huggingface.co/MaziyarPanahi/gemma-3-4b-it-GGUF/resolve/main/gemma-3-4b-it.Q8_0.gguf?download=true';
export const DEFAULT_AUTO_GPU_LAYERS = false;
export const SILLYTAVERN = { export const SILLYTAVERN = {
PORT: 3000, PORT: 3000,
PROXY_PORT: 3001, PROXY_PORT: 3001,

View file

@ -5,8 +5,7 @@ import { terminateProcess } from '@/utils/process';
import { pathExists, readJsonFile } from '@/utils/fs'; import { pathExists, readJsonFile } from '@/utils/fs';
import { getConfigDir } from '@/utils/path'; import { getConfigDir } from '@/utils/path';
export class LightweightCliHandler { async function getCurrentKoboldBinary(): Promise<string | null> {
private async getCurrentKoboldBinary(): Promise<string | null> {
try { try {
const configPath = getConfigDir(); const configPath = getConfigDir();
if (!(await pathExists(configPath))) { if (!(await pathExists(configPath))) {
@ -22,8 +21,8 @@ export class LightweightCliHandler {
} }
} }
async handleCliMode(args: string[]): Promise<void> { export async function handleCliMode(args: string[]): Promise<void> {
const currentBinary = await this.getCurrentKoboldBinary(); const currentBinary = await getCurrentKoboldBinary();
if (!currentBinary) { if (!currentBinary) {
console.error( console.error(
@ -98,4 +97,3 @@ export class LightweightCliHandler {
} }
}); });
} }
}

View file

@ -1,45 +1,30 @@
import { app } from 'electron'; import { app } from 'electron';
import { getWindowManager } from '@/main/managers/WindowManager'; import {
import { getConfigManager } from '@/main/managers/ConfigManager'; createMainWindow,
import { getLogManager } from '@/main/managers/LogManager'; cleanup as cleanupWindow,
import { getKoboldCppManager } from '@/main/managers/KoboldCppManager'; } from '@/main/modules/window';
import { getSillyTavernManager } from '@/main/managers/SillyTavernManager'; import {
import { getOpenWebUIManager } from '@/main/managers/OpenWebUIManager'; initialize as initializeConfig,
import { getHardwareManager } from '@/main/managers/HardwareManager'; getInstallDir,
import { getBinaryManager } from '@/main/managers/BinaryManager'; } from '@/main/modules/config';
import { IPCHandlers } from '@/main/ipc'; import { logError } from '@/main/modules/logging';
import { cleanup } from '@/main/modules/koboldcpp';
import { getSillyTavernManager } from '@/main/modules/sillytavern';
import { cleanup as cleanupOpenWebUI } from '@/main/modules/openwebui';
import { setupIPCHandlers } from '@/main/ipc';
import { ensureDir } from '@/utils/fs'; import { ensureDir } from '@/utils/fs';
import { getConfigDir } from '@/utils/path';
export class GerbilApp { export async function initializeApp(): Promise<void> {
private ipcHandlers: IPCHandlers; const installDir = getInstallDir();
constructor() {
this.ipcHandlers = new IPCHandlers(
getKoboldCppManager(),
getConfigManager(getConfigDir()),
getHardwareManager(),
getBinaryManager(),
getLogManager(),
getSillyTavernManager(),
getOpenWebUIManager(),
getWindowManager()
);
}
private async ensureInstallDirectory(): Promise<void> {
const installDir = getConfigManager().getInstallDir();
await ensureDir(installDir);
}
async initialize(): Promise<void> {
await app.whenReady(); await app.whenReady();
await getConfigManager().initialize(); await initializeConfig();
await this.ensureInstallDirectory(); await ensureDir(installDir);
getWindowManager().createMainWindow(); setupIPCHandlers();
this.ipcHandlers.setupHandlers();
createMainWindow();
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
@ -54,9 +39,9 @@ export class GerbilApp {
try { try {
const cleanupPromises = [ const cleanupPromises = [
getKoboldCppManager().cleanup(), cleanup(),
getSillyTavernManager().cleanup(), getSillyTavernManager().cleanup(),
getOpenWebUIManager().cleanup(), cleanupOpenWebUI(),
]; ];
const timeoutPromise = new Promise<void>((resolve) => { const timeoutPromise = new Promise<void>((resolve) => {
@ -67,10 +52,10 @@ export class GerbilApp {
await Promise.race([Promise.all(cleanupPromises), timeoutPromise]); await Promise.race([Promise.all(cleanupPromises), timeoutPromise]);
} catch (error) { } catch (error) {
getLogManager().logError('Error during cleanup:', error as Error); logError('Error during cleanup:', error as Error);
} }
getWindowManager().cleanup(); cleanupWindow();
app.exit(0); app.exit(0);
}); });
@ -79,11 +64,4 @@ export class GerbilApp {
event.preventDefault(); event.preventDefault();
app.exit(0); app.exit(0);
}); });
app.on('activate', () => {
if (!getWindowManager().getMainWindow()) {
getWindowManager().createMainWindow();
}
});
}
} }

View file

@ -23,9 +23,8 @@ if (process.argv[1] === '--version') {
import('./cli') import('./cli')
.then(async (cliModule) => { .then(async (cliModule) => {
const args = process.argv.slice(process.argv.indexOf('--cli') + 1); const args = process.argv.slice(process.argv.indexOf('--cli') + 1);
const handler = new cliModule.LightweightCliHandler();
try { try {
await handler.handleCliMode(args); await cliModule.handleCliMode(args);
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error('CLI mode error:', error); console.error('CLI mode error:', error);
@ -39,8 +38,7 @@ if (process.argv[1] === '--version') {
}); });
} else { } else {
import('./gui').then((guiModule) => { import('./gui').then((guiModule) => {
const app = new guiModule.GerbilApp(); guiModule.initializeApp().catch((error: unknown) => {
app.initialize().catch((error: unknown) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error('Failed to initialize Gerbil:', error); console.error('Failed to initialize Gerbil:', error);
}); });

View file

@ -1,65 +1,63 @@
import { ipcMain, shell, app } from 'electron'; import { ipcMain, shell, app } from 'electron';
import * as os from 'os'; import * as os from 'os';
import type { KoboldCppManager } from '@/main/managers/KoboldCppManager'; import {
import type { ConfigManager } from '@/main/managers/ConfigManager'; launchKoboldCpp,
import type { LogManager } from '@/main/managers/LogManager'; downloadRelease,
import type { SillyTavernManager } from '@/main/managers/SillyTavernManager'; getInstalledVersions,
import type { OpenWebUIManager } from '@/main/managers/OpenWebUIManager'; getCurrentVersion,
import type { WindowManager } from '@/main/managers/WindowManager'; getConfigFiles,
import { HardwareManager } from '@/main/managers/HardwareManager'; saveConfigFile,
import { BinaryManager } from '@/main/managers/BinaryManager'; setCurrentVersion,
selectInstallDirectory,
stopKoboldCpp,
parseConfigFile,
selectModelFile,
} from '@/main/modules/koboldcpp';
import {
get as getConfig,
set as setConfig,
getSelectedConfig,
setSelectedConfig,
getInstallDir,
} from '@/main/modules/config';
import { logError, getLogsDirectory } from '@/main/modules/logging';
import { getSillyTavernManager } from '@/main/modules/sillytavern';
import {
startFrontend as startOpenWebUIFrontend,
stopFrontend as stopOpenWebUIFrontend,
isUvAvailable,
} from '@/main/modules/openwebui';
import { getMainWindow } from '@/main/modules/window';
import {
detectGPU,
detectCPU,
detectGPUCapabilities,
detectGPUMemory,
detectROCm,
} from '@/main/modules/hardware';
import {
detectBackendSupport,
getAvailableBackends,
} from '@/main/modules/binary';
import type { FrontendPreference } from '@/types'; import type { FrontendPreference } from '@/types';
export class IPCHandlers { async function launchKoboldCppWithCustomFrontends(args: string[] = []) {
private koboldManager: KoboldCppManager;
private configManager: ConfigManager;
private logManager: LogManager;
private sillyTavernManager: SillyTavernManager;
private openWebUIManager: OpenWebUIManager;
private hardwareManager: HardwareManager;
private binaryManager: BinaryManager;
private windowManager: WindowManager;
constructor(
koboldManager: KoboldCppManager,
configManager: ConfigManager,
hardwareManager: HardwareManager,
binaryManager: BinaryManager,
logManager: LogManager,
sillyTavernManager: SillyTavernManager,
openWebUIManager: OpenWebUIManager,
windowManager: WindowManager
) {
this.koboldManager = koboldManager;
this.configManager = configManager;
this.logManager = logManager;
this.sillyTavernManager = sillyTavernManager;
this.openWebUIManager = openWebUIManager;
this.hardwareManager = hardwareManager;
this.binaryManager = binaryManager;
this.windowManager = windowManager;
}
private async launchKoboldCppWithCustomFrontends(args: string[] = []) {
try { try {
const frontendPreference = (await this.configManager.get( const frontendPreference = (await getConfig(
'frontendPreference' 'frontendPreference'
)) as FrontendPreference; )) as FrontendPreference;
const result = await this.koboldManager.launchKoboldCpp( const result = await launchKoboldCpp(args, frontendPreference);
args,
frontendPreference
);
if (frontendPreference === 'sillytavern') { if (frontendPreference === 'sillytavern') {
this.sillyTavernManager.startFrontend(args); getSillyTavernManager().startFrontend(args);
} else if (frontendPreference === 'openwebui') { } else if (frontendPreference === 'openwebui') {
this.openWebUIManager.startFrontend(args); startOpenWebUIFrontend(args);
} }
return result; return result;
} catch (error) { } catch (error) {
this.logManager.logError('Error in enhanced launch:', error as Error); logError('Error in enhanced launch:', error as Error);
return { return {
success: false, success: false,
error: (error as Error).message, error: (error as Error).message,
@ -67,102 +65,77 @@ export class IPCHandlers {
} }
} }
setupHandlers() { export function setupIPCHandlers() {
ipcMain.handle('kobold:downloadRelease', async (_, asset) => ({ ipcMain.handle('kobold:downloadRelease', async (_, asset) => ({
success: true, success: true,
path: await this.koboldManager.downloadRelease(asset), path: await downloadRelease(asset),
})); }));
ipcMain.handle('kobold:getInstalledVersions', () => ipcMain.handle('kobold:getInstalledVersions', () => getInstalledVersions());
this.koboldManager.getInstalledVersions()
);
ipcMain.handle('kobold:getCurrentVersion', () => ipcMain.handle('kobold:getCurrentVersion', () => getCurrentVersion());
this.koboldManager.getCurrentVersion()
);
ipcMain.handle('kobold:getConfigFiles', () => ipcMain.handle('kobold:getConfigFiles', () => getConfigFiles());
this.koboldManager.getConfigFiles()
);
ipcMain.handle('kobold:saveConfigFile', async (_, configName, configData) => ipcMain.handle('kobold:saveConfigFile', async (_, configName, configData) =>
this.koboldManager.saveConfigFile(configName, configData) saveConfigFile(configName, configData)
); );
ipcMain.handle('kobold:getSelectedConfig', () => ipcMain.handle('kobold:getSelectedConfig', () => getSelectedConfig());
this.configManager.getSelectedConfig()
);
ipcMain.handle('kobold:setSelectedConfig', (_, configName) => ipcMain.handle('kobold:setSelectedConfig', (_, configName) =>
this.configManager.setSelectedConfig(configName) setSelectedConfig(configName)
); );
ipcMain.handle('kobold:setCurrentVersion', (_, version) => ipcMain.handle('kobold:setCurrentVersion', (_, version) =>
this.koboldManager.setCurrentVersion(version) setCurrentVersion(version)
); );
ipcMain.handle('kobold:getCurrentInstallDir', () => ipcMain.handle('kobold:getCurrentInstallDir', () => getInstallDir());
this.configManager.getInstallDir()
);
ipcMain.handle('kobold:selectInstallDirectory', () => ipcMain.handle('kobold:selectInstallDirectory', () =>
this.koboldManager.selectInstallDirectory() selectInstallDirectory()
); );
ipcMain.handle('kobold:detectGPU', () => this.hardwareManager.detectGPU()); ipcMain.handle('kobold:detectGPU', () => detectGPU());
ipcMain.handle('kobold:detectCPU', () => this.hardwareManager.detectCPU()); ipcMain.handle('kobold:detectCPU', () => detectCPU());
ipcMain.handle('kobold:detectGPUCapabilities', () => ipcMain.handle('kobold:detectGPUCapabilities', () => detectGPUCapabilities());
this.hardwareManager.detectGPUCapabilities()
);
ipcMain.handle('kobold:detectGPUMemory', () => ipcMain.handle('kobold:detectGPUMemory', () => detectGPUMemory());
this.hardwareManager.detectGPUMemory()
);
ipcMain.handle('kobold:detectROCm', () => ipcMain.handle('kobold:detectROCm', () => detectROCm());
this.hardwareManager.detectROCm()
);
ipcMain.handle('kobold:detectBackendSupport', () => ipcMain.handle('kobold:detectBackendSupport', () => detectBackendSupport());
this.binaryManager.detectBackendSupport(this.koboldManager)
);
ipcMain.handle( ipcMain.handle('kobold:getAvailableBackends', (_, includeDisabled = false) =>
'kobold:getAvailableBackends', getAvailableBackends(includeDisabled)
(_, includeDisabled = false) =>
this.binaryManager.getAvailableBackends(
this.koboldManager,
includeDisabled
)
); );
ipcMain.handle('kobold:getPlatform', () => process.platform); ipcMain.handle('kobold:getPlatform', () => process.platform);
ipcMain.handle('kobold:launchKoboldCpp', (_, args) => ipcMain.handle('kobold:launchKoboldCpp', (_, args) =>
this.launchKoboldCppWithCustomFrontends(args) launchKoboldCppWithCustomFrontends(args)
); );
ipcMain.handle('kobold:stopKoboldCpp', () => { ipcMain.handle('kobold:stopKoboldCpp', () => {
this.koboldManager.stopKoboldCpp(); stopKoboldCpp();
this.sillyTavernManager.stopFrontend(); getSillyTavernManager().stopFrontend();
this.openWebUIManager.stopFrontend(); stopOpenWebUIFrontend();
}); });
ipcMain.handle('kobold:parseConfigFile', (_, filePath) => ipcMain.handle('kobold:parseConfigFile', (_, filePath) =>
this.koboldManager.parseConfigFile(filePath) parseConfigFile(filePath)
); );
ipcMain.handle('kobold:selectModelFile', (_, title) => ipcMain.handle('kobold:selectModelFile', (_, title) =>
this.koboldManager.selectModelFile(title) selectModelFile(title)
); );
ipcMain.handle('config:get', (_, key) => this.configManager.get(key)); ipcMain.handle('config:get', (_, key) => getConfig(key));
ipcMain.handle('config:set', (_, key, value) => ipcMain.handle('config:set', (_, key, value) => setConfig(key, value));
this.configManager.set(key, value)
);
ipcMain.handle('app:getVersion', () => app.getVersion()); ipcMain.handle('app:getVersion', () => app.getVersion());
@ -179,11 +152,11 @@ export class IPCHandlers {
ipcMain.handle('app:showLogsFolder', async () => { ipcMain.handle('app:showLogsFolder', async () => {
try { try {
const logsDir = this.logManager.getLogsDirectory(); const logsDir = getLogsDirectory();
await shell.openPath(logsDir); await shell.openPath(logsDir);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
this.logManager.logError('Failed to open logs folder:', error as Error); logError('Failed to open logs folder:', error as Error);
throw new Error( throw new Error(
`Failed to open logs folder: ${(error as Error).message}` `Failed to open logs folder: ${(error as Error).message}`
); );
@ -191,12 +164,12 @@ export class IPCHandlers {
}); });
ipcMain.handle('app:minimizeWindow', () => { ipcMain.handle('app:minimizeWindow', () => {
const mainWindow = this.windowManager.getMainWindow(); const mainWindow = getMainWindow();
mainWindow?.minimize(); mainWindow?.minimize();
}); });
ipcMain.handle('app:maximizeWindow', () => { ipcMain.handle('app:maximizeWindow', () => {
const mainWindow = this.windowManager.getMainWindow(); const mainWindow = getMainWindow();
if (mainWindow?.isMaximized()) { if (mainWindow?.isMaximized()) {
mainWindow.restore(); mainWindow.restore();
} else { } else {
@ -205,12 +178,12 @@ export class IPCHandlers {
}); });
ipcMain.handle('app:closeWindow', () => { ipcMain.handle('app:closeWindow', () => {
const mainWindow = this.windowManager.getMainWindow(); const mainWindow = getMainWindow();
mainWindow?.close(); mainWindow?.close();
}); });
ipcMain.handle('app:getZoomLevel', async () => { ipcMain.handle('app:getZoomLevel', async () => {
const mainWindow = this.windowManager.getMainWindow(); const mainWindow = getMainWindow();
if (mainWindow) { if (mainWindow) {
return mainWindow.webContents.getZoomLevel(); return mainWindow.webContents.getZoomLevel();
} }
@ -218,17 +191,17 @@ export class IPCHandlers {
}); });
ipcMain.handle('app:setZoomLevel', async (_, level: number) => { ipcMain.handle('app:setZoomLevel', async (_, level: number) => {
const mainWindow = this.windowManager.getMainWindow(); const mainWindow = getMainWindow();
if (mainWindow) { if (mainWindow) {
mainWindow.webContents.setZoomLevel(level); mainWindow.webContents.setZoomLevel(level);
await this.configManager.set('zoomLevel', level); await setConfig('zoomLevel', level);
} }
}); });
const mainWindow = this.windowManager.getMainWindow(); const mainWindow = getMainWindow();
if (mainWindow) { if (mainWindow) {
mainWindow.webContents.once('did-finish-load', async () => { mainWindow.webContents.once('did-finish-load', async () => {
const savedZoomLevel = await this.configManager.get('zoomLevel'); const savedZoomLevel = await getConfig('zoomLevel');
if (typeof savedZoomLevel === 'number') { if (typeof savedZoomLevel === 'number') {
mainWindow.webContents.setZoomLevel(savedZoomLevel); mainWindow.webContents.setZoomLevel(savedZoomLevel);
} }
@ -236,15 +209,12 @@ export class IPCHandlers {
} }
ipcMain.handle('logs:logError', (_, message: string, error?: Error) => { ipcMain.handle('logs:logError', (_, message: string, error?: Error) => {
this.logManager.logError(message, error); logError(message, error);
}); });
ipcMain.handle('sillytavern:isNpxAvailable', () => ipcMain.handle('sillytavern:isNpxAvailable', () =>
this.sillyTavernManager.isNpxAvailable() getSillyTavernManager().isNpxAvailable()
); );
ipcMain.handle('openwebui:isUvAvailable', () => ipcMain.handle('openwebui:isUvAvailable', () => isUvAvailable());
this.openWebUIManager.isUvAvailable()
);
}
} }

View file

@ -1,206 +0,0 @@
import { join, dirname } from 'path';
import { pathExists } from '@/utils/fs';
import { getLogManager } from '@/main/managers/LogManager';
import { getHardwareManager } from '@/main/managers/HardwareManager';
import type { BackendOption, BackendSupport } from '@/types';
import type { KoboldCppManager } from '@/main/managers/KoboldCppManager';
export class BinaryManager {
private backendSupportCache = new Map<string, BackendSupport>();
private availableBackendsCache = new Map<string, BackendOption[]>();
private async detectBackendSupportFromPath(
koboldBinaryPath: string
): Promise<BackendSupport> {
if (this.backendSupportCache.has(koboldBinaryPath)) {
return this.backendSupportCache.get(koboldBinaryPath)!;
}
const support: BackendSupport = {
rocm: false,
vulkan: false,
clblast: false,
noavx2: false,
failsafe: false,
cuda: false,
};
try {
const binaryDir = dirname(koboldBinaryPath);
const internalDir = join(binaryDir, '_internal');
const platform = process.platform;
const libExtension = platform === 'win32' ? '.dll' : '.so';
const hasKoboldCppLib = async (name: string): Promise<boolean> => {
const filename = `${name}${libExtension}`;
if (platform === 'win32') {
return (
(await pathExists(join(binaryDir, filename))) ||
(await pathExists(join(internalDir, filename)))
);
} else {
return (
(await pathExists(join(internalDir, filename))) ||
(await pathExists(join(binaryDir, filename)))
);
}
};
support.rocm = await hasKoboldCppLib('koboldcpp_hipblas');
support.vulkan = await hasKoboldCppLib('koboldcpp_vulkan');
support.clblast = await hasKoboldCppLib('koboldcpp_clblast');
support.noavx2 = await hasKoboldCppLib('koboldcpp_noavx2');
support.failsafe = await hasKoboldCppLib('koboldcpp_failsafe');
support.cuda = await hasKoboldCppLib('koboldcpp_cublas');
} catch (error) {
getLogManager().logError(
'Error detecting backend support:',
error as Error
);
}
this.backendSupportCache.set(koboldBinaryPath, support);
return support;
}
async detectBackendSupport(
koboldManager: KoboldCppManager
): Promise<BackendSupport | null> {
try {
const currentBinaryInfo = await koboldManager.getCurrentBinaryInfo();
if (!currentBinaryInfo?.path) {
return null;
}
return this.detectBackendSupportFromPath(currentBinaryInfo.path);
} catch (error) {
getLogManager().logError(
'Error detecting current binary backend support:',
error as Error
);
return null;
}
}
// eslint-disable-next-line sonarjs/cognitive-complexity
async getAvailableBackends(
koboldManager: KoboldCppManager,
includeDisabled = false
): Promise<BackendOption[]> {
try {
const hardwareManager = getHardwareManager();
const [currentBinaryInfo, hardwareCapabilities, cpuCapabilities] =
await Promise.all([
koboldManager.getCurrentBinaryInfo(),
hardwareManager.detectGPUCapabilities(),
includeDisabled ? hardwareManager.detectCPU() : Promise.resolve(null),
]);
if (!currentBinaryInfo?.path) {
return [{ value: 'cpu', label: 'CPU' }];
}
const cacheKey = `${currentBinaryInfo.path}:${includeDisabled}`;
if (this.availableBackendsCache.has(cacheKey)) {
return this.availableBackendsCache.get(cacheKey)!;
}
const backendSupport = await this.detectBackendSupport(koboldManager);
if (!backendSupport) {
return [];
}
const backends: BackendOption[] = [];
if (backendSupport.cuda) {
const isSupported = hardwareCapabilities.cuda.supported;
if (isSupported || includeDisabled) {
backends.push({
value: 'cuda',
label: 'CUDA',
devices: hardwareCapabilities.cuda.devices,
disabled: includeDisabled ? !isSupported : undefined,
});
}
}
if (backendSupport.rocm) {
const isSupported = hardwareCapabilities.rocm.supported;
if (isSupported || includeDisabled) {
backends.push({
value: 'rocm',
label: 'ROCm',
devices: hardwareCapabilities.rocm.devices,
disabled: includeDisabled ? !isSupported : undefined,
});
}
}
if (backendSupport.vulkan) {
const isSupported = hardwareCapabilities.vulkan.supported;
if (isSupported || includeDisabled) {
backends.push({
value: 'vulkan',
label: 'Vulkan',
devices: hardwareCapabilities.vulkan.devices,
disabled: includeDisabled ? !isSupported : undefined,
});
}
}
if (backendSupport.clblast) {
const isSupported = hardwareCapabilities.clblast.supported;
if (isSupported || includeDisabled) {
backends.push({
value: 'clblast',
label: 'CLBlast',
devices: hardwareCapabilities.clblast.devices,
disabled: includeDisabled ? !isSupported : undefined,
});
}
}
backends.push({
value: 'cpu',
label: 'CPU',
devices: cpuCapabilities?.devices,
disabled: false,
});
if (includeDisabled) {
backends.sort((a, b) => {
if (a.disabled === b.disabled) return 0;
return a.disabled ? 1 : -1;
});
}
this.availableBackendsCache.set(cacheKey, backends);
return backends;
} catch (error) {
getLogManager().logError(
'Failed to get available backends:',
error as Error
);
return [{ value: 'cpu', label: 'CPU' }];
}
}
clearCache(): void {
this.backendSupportCache.clear();
this.availableBackendsCache.clear();
}
}
let binaryManagerInstance: BinaryManager;
export function getBinaryManager(): BinaryManager {
if (!binaryManagerInstance) {
binaryManagerInstance = new BinaryManager();
}
return binaryManagerInstance;
}

View file

@ -1,112 +0,0 @@
import { getLogManager } from '@/main/managers/LogManager';
import { readJsonFile, writeJsonFile } from '@/utils/fs';
import type { FrontendPreference } from '@/types';
import { homedir } from 'os';
import { join } from 'path';
import { PRODUCT_NAME } from '@/constants';
type ConfigValue = string | number | boolean | unknown[] | undefined;
interface AppConfig {
installDir?: string;
currentKoboldBinary?: string;
selectedConfig?: string;
frontendPreference?: FrontendPreference;
[key: string]: ConfigValue;
}
let configManagerInstance: ConfigManager | null = null;
export class ConfigManager {
private config: AppConfig = {};
private configPath: string;
constructor(configPath: string) {
this.configPath = configPath;
}
async initialize(): Promise<void> {
this.config = await this.loadConfig();
}
private async loadConfig(): Promise<AppConfig> {
try {
const config = await readJsonFile<AppConfig>(this.configPath);
return config || {};
} catch (error) {
getLogManager().logError('Error loading config:', error as Error);
return {};
}
}
private async saveConfig(): Promise<void> {
try {
await writeJsonFile(this.configPath, this.config);
} catch (error) {
getLogManager().logError('Error saving config:', error as Error);
}
}
get(key: string): ConfigValue {
return this.config[key];
}
async set(key: string, value: ConfigValue): Promise<void> {
this.config[key] = value;
await this.saveConfig();
}
private getDefaultInstallDir(): string {
const platform = process.platform;
const home = homedir();
switch (platform) {
case 'win32':
return join(home, PRODUCT_NAME);
case 'darwin':
return join(home, 'Applications', PRODUCT_NAME);
default:
return join(home, '.local', 'share', PRODUCT_NAME);
}
}
getInstallDir(): string {
return this.config.installDir || this.getDefaultInstallDir();
}
async setInstallDir(dir: string): Promise<void> {
this.config.installDir = dir;
await this.saveConfig();
}
getCurrentKoboldBinary(): string | undefined {
const path = this.config.currentKoboldBinary as string | undefined;
return path ? path.trim() : path;
}
async setCurrentKoboldBinary(binaryPath: string): Promise<void> {
this.config.currentKoboldBinary = binaryPath;
await this.saveConfig();
}
getSelectedConfig(): string | undefined {
return this.config.selectedConfig;
}
async setSelectedConfig(configName: string): Promise<void> {
this.config.selectedConfig = configName;
await this.saveConfig();
}
}
export const getConfigManager = (configPath?: string): ConfigManager => {
if (!configManagerInstance) {
if (!configPath) {
throw new Error(
'ConfigManager not initialized. Provide configPath on first call.'
);
}
configManagerInstance = new ConfigManager(configPath);
}
return configManagerInstance;
};

View file

@ -1,495 +0,0 @@
/* eslint-disable no-comments/disallowComments */
import si from 'systeminformation';
import { shortenDeviceName } from '@/utils/hardware';
import { getLogManager } from '@/main/managers/LogManager';
import { terminateProcess } from '@/utils/process';
import type {
CPUCapabilities,
GPUCapabilities,
BasicGPUInfo,
HardwareInfo,
GPUMemoryInfo,
} from '@/types/hardware';
import { spawn } from 'child_process';
let hardwareManagerInstance: HardwareManager | null = null;
export class HardwareManager {
private cpuCapabilitiesCache: CPUCapabilities | null = null;
private basicGPUInfoCache: BasicGPUInfo | null = null;
private gpuCapabilitiesCache: GPUCapabilities | null = null;
private gpuMemoryInfoCache: GPUMemoryInfo[] | null = null;
async detectCPU(): Promise<CPUCapabilities> {
if (this.cpuCapabilitiesCache) {
return this.cpuCapabilitiesCache;
}
try {
const [cpu, flags] = await Promise.all([si.cpu(), si.cpuFlags()]);
const devices: string[] = [];
if (cpu.brand) {
devices.push(shortenDeviceName(cpu.brand));
}
const avx = flags.includes('avx') || flags.includes('AVX');
const avx2 = flags.includes('avx2') || flags.includes('AVX2');
this.cpuCapabilitiesCache = {
avx,
avx2,
devices,
};
return this.cpuCapabilitiesCache;
} catch (error) {
getLogManager().logError('CPU detection failed:', error as Error);
const fallbackCapabilities = {
avx: false,
avx2: false,
devices: [],
};
this.cpuCapabilitiesCache = fallbackCapabilities;
return fallbackCapabilities;
}
}
async detectGPU(): Promise<BasicGPUInfo> {
if (this.basicGPUInfoCache) {
return this.basicGPUInfoCache;
}
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(shortenDeviceName(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;
}
}
this.basicGPUInfoCache = {
hasAMD,
hasNVIDIA,
gpuInfo:
gpuInfo.length > 0 ? gpuInfo : ['No GPU information available'],
};
return this.basicGPUInfoCache;
} catch (error) {
getLogManager().logError('GPU detection failed:', error as Error);
const fallbackGPUInfo = {
hasAMD: false,
hasNVIDIA: false,
gpuInfo: ['GPU detection failed'],
};
this.basicGPUInfoCache = fallbackGPUInfo;
return fallbackGPUInfo;
}
}
async detectGPUCapabilities(): Promise<GPUCapabilities> {
// 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(),
this.detectVulkan(),
this.detectCLBlast(),
]);
this.gpuCapabilitiesCache = { cuda, rocm, vulkan, clblast };
return this.gpuCapabilitiesCache;
}
async detectAll(): Promise<HardwareInfo> {
const [cpu, gpu] = await Promise.all([this.detectCPU(), this.detectGPU()]);
return { cpu, gpu };
}
private async detectCUDA(): Promise<{
supported: boolean;
devices: string[];
}> {
try {
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(',');
const rawName = parts[0]?.trim() || 'Unknown NVIDIA GPU';
return shortenDeviceName(rawName);
})
.filter(Boolean);
resolve({
supported: devices.length > 0,
devices,
});
} else {
resolve({ supported: false, devices: [] });
}
});
nvidia.on('error', () => {
resolve({ supported: false, devices: [] });
});
setTimeout(async () => {
await terminateProcess(nvidia);
resolve({ supported: false, devices: [] });
}, 5000);
});
} catch {
return { supported: false, devices: [] };
}
}
private async findRocminfoCommand(): Promise<string | null> {
const platform = await import('process').then((p) => p.platform);
if (platform === 'win32') {
return 'hipInfo';
} else {
return 'rocminfo';
}
}
async detectROCm(): Promise<{
supported: boolean;
devices: string[];
}> {
try {
const rocminfoCommand = await this.findRocminfoCommand();
if (!rocminfoCommand) {
return { supported: false, devices: [] };
}
const isWindows = rocminfoCommand.includes('hipInfo');
const rocminfo = spawn(rocminfoCommand, [], { timeout: 5000 });
let output = '';
rocminfo.stdout.on('data', (data) => {
output += data.toString();
});
return new Promise((resolve) => {
// eslint-disable-next-line sonarjs/cognitive-complexity
rocminfo.on('close', (code) => {
if (code === 0 && output.trim()) {
const devices: string[] = [];
if (isWindows) {
const lines = output.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('Name:')) {
const name = trimmedLine.split('Name:')[1]?.trim();
if (
name &&
!name.toLowerCase().includes('cpu') &&
!devices.includes(shortenDeviceName(name))
) {
devices.push(shortenDeviceName(name));
}
}
}
} else {
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) {
let deviceType = '';
const searchRangeLines = 20;
const searchStartIndex = Math.max(0, i - searchRangeLines);
const searchEndIndex = Math.min(
lines.length,
i + searchRangeLines
);
for (
let searchIndex = searchStartIndex;
searchIndex < searchEndIndex;
searchIndex++
) {
if (lines[searchIndex].includes('Device Type:')) {
deviceType =
lines[searchIndex].split('Device Type:')[1]?.trim() ||
'';
break;
}
}
if (deviceType !== 'CPU') {
devices.push(shortenDeviceName(name));
}
}
}
}
}
resolve({
supported: devices.length > 0,
devices,
});
} else {
resolve({ supported: false, devices: [] });
}
});
rocminfo.on('error', () => {
resolve({ supported: false, devices: [] });
});
setTimeout(async () => {
await terminateProcess(rocminfo);
resolve({ supported: false, devices: [] });
}, 5000);
});
} catch {
return { supported: false, devices: [] };
}
}
private async detectVulkan(): Promise<{
supported: boolean;
devices: string[];
}> {
try {
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) {
// Handle both formats: "deviceName = AMD Radeon RX 7900 GRE" and other potential formats
if (line.includes('deviceName') && line.includes('=')) {
const parts = line.split('=');
if (parts.length >= 2) {
const name = parts[1]?.trim();
if (name) {
devices.push(shortenDeviceName(name));
}
}
}
}
resolve({
supported: devices.length > 0,
devices,
});
} else {
resolve({ supported: false, devices: [] });
}
});
vulkaninfo.on('error', () => {
resolve({ supported: false, devices: [] });
});
setTimeout(async () => {
await terminateProcess(vulkaninfo);
resolve({ supported: false, devices: [] });
}, 5000);
});
} catch {
return { supported: false, devices: [] };
}
}
private parseClInfoOutput(output: string): string[] {
const devices: string[] = [];
const lines = output.split('\n');
let currentPlatform = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Extract platform name - this appears early in the output
if (line.includes('Platform Name:')) {
currentPlatform = line.split('Platform Name:')[1]?.trim() || '';
continue;
}
// When we find a GPU device type, look for device name
if (line.includes('Device Type:') && line.includes('GPU')) {
const deviceName = this.findDeviceNameInClInfo(lines, i);
if (deviceName && currentPlatform) {
const deviceLabel = `${shortenDeviceName(deviceName)} (${currentPlatform})`;
devices.push(deviceLabel);
}
}
}
return devices;
}
private findDeviceNameInClInfo(lines: string[], startIndex: number): string {
// Look for Board name first (appears closer to Device Type and is more descriptive)
for (
let j = startIndex + 1;
j < Math.min(startIndex + 50, lines.length);
j++
) {
const nextLine = lines[j].trim();
if (nextLine.includes('Board name:')) {
return nextLine.split('Board name:')[1]?.trim() || '';
}
}
// If no Board name found, look for Name: field (appears much later)
for (
let j = startIndex + 1;
j < Math.min(startIndex + 100, lines.length);
j++
) {
const nextLine = lines[j].trim();
if (nextLine.startsWith('Name:')) {
return nextLine.split('Name:')[1]?.trim() || '';
}
}
return '';
}
private async detectCLBlast(): Promise<{
supported: boolean;
devices: string[];
}> {
try {
const clinfo = spawn('clinfo', [], { timeout: 3000 });
let output = '';
clinfo.stdout.on('data', (data) => {
output += data.toString();
});
return new Promise((resolve) => {
clinfo.on('close', (code) => {
if (code === 0 && output.trim()) {
const devices = this.parseClInfoOutput(output);
resolve({
supported: devices.length > 0,
devices,
});
} else {
resolve({ supported: false, devices: [] });
}
});
clinfo.on('error', () => {
resolve({ supported: false, devices: [] });
});
setTimeout(async () => {
await terminateProcess(clinfo);
resolve({ supported: false, devices: [] });
}, 3000);
});
} catch {
return { supported: false, devices: [] };
}
}
async detectGPUMemory(): Promise<GPUMemoryInfo[]> {
if (this.gpuMemoryInfoCache) {
return this.gpuMemoryInfoCache;
}
const memoryInfo: GPUMemoryInfo[] = [];
try {
const graphics = await si.graphics();
for (const controller of graphics.controllers) {
if (controller.model) {
let vram = controller.vram;
// systeminformation returns 0 or 1 if unknown/invalid
if (!vram || vram === 1) {
vram = null;
}
memoryInfo.push({
deviceName: shortenDeviceName(controller.model),
totalMemoryMB: vram,
});
}
}
this.gpuMemoryInfoCache = memoryInfo;
} catch (error) {
getLogManager().logError('GPU memory detection failed:', error as Error);
this.gpuMemoryInfoCache = [];
}
return this.gpuMemoryInfoCache;
}
}
export const getHardwareManager = (): HardwareManager => {
if (!hardwareManagerInstance) {
hardwareManagerInstance = new HardwareManager();
}
return hardwareManagerInstance;
};

View file

@ -1,796 +0,0 @@
import { spawn, ChildProcess } from 'child_process';
import { createWriteStream } from 'fs';
import { join } from 'path';
import {
rm,
readdir,
stat,
unlink,
rename,
mkdir,
chmod,
readFile,
writeFile,
copyFile,
} from 'fs/promises';
import { dialog } from 'electron';
import axios from 'axios';
import { execa } from 'execa';
import { terminateProcess } from '@/utils/process';
import { getConfigManager } from '@/main/managers/ConfigManager';
import { getLogManager } from '@/main/managers/LogManager';
import { getWindowManager } from '@/main/managers/WindowManager';
import { PRODUCT_NAME, SERVER_READY_SIGNALS } from '@/constants';
import {
KLITE_CSS_OVERRIDE,
KLITE_AUTOSCROLL_PATCHES,
} from '@/constants/patches';
import { pathExists, readJsonFile, writeJsonFile } from '@/utils/fs';
import { stripAssetExtensions } from '@/utils/version';
import { parseKoboldConfig } from '@/utils/kobold';
import { getAssetPath } from '@/utils/path';
import type {
GitHubAsset,
InstalledVersion,
KoboldConfig,
} from '@/types/electron';
import type { FrontendPreference } from '@/types';
export class KoboldCppManager {
private koboldProcess: ChildProcess | null = null;
private async removeDirectoryWithRetry(
dirPath: string,
maxRetries = 3,
delayMs = 1000
): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await rm(dirPath, { recursive: true, force: true });
return;
} catch (error) {
const isLastAttempt = attempt === maxRetries;
const isPermissionError =
(error as Error & { code?: string }).code === 'EPERM';
if (isLastAttempt) {
throw error;
}
if (isPermissionError && process.platform === 'win32') {
getWindowManager().sendKoboldOutput(
`Attempt ${attempt}/${maxRetries} failed (file in use), retrying in ${delayMs}ms...`
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
delayMs *= 1.5;
} else {
throw error;
}
}
}
}
private async handleExistingDirectory(
unpackedDirPath: string,
isUpdate: boolean
): Promise<void> {
if (!isUpdate || !(await pathExists(unpackedDirPath))) {
return;
}
try {
if (this.koboldProcess && !this.koboldProcess.killed) {
getWindowManager().sendKoboldOutput(
'Stopping process before update...'
);
await this.cleanup();
await new Promise((resolve) => setTimeout(resolve, 2000));
}
await this.removeDirectoryWithRetry(unpackedDirPath);
} catch (error) {
getLogManager().logError(
'Failed to remove existing directory for update:',
error as Error
);
throw new Error(
`Cannot update: Failed to remove existing installation. ` +
`Please ensure the server is stopped and try again. ` +
`Error: ${(error as Error).message}`
);
}
}
private async downloadFile(
asset: GitHubAsset,
tempPackedFilePath: string
): Promise<void> {
const writer = createWriteStream(tempPackedFilePath);
let downloadedBytes = 0;
const response = await axios({
method: 'GET',
url: asset.browser_download_url,
responseType: 'stream',
timeout: 30000,
maxRedirects: 5,
});
const totalBytes = asset.size;
response.data.on('data', (chunk: Buffer) => {
downloadedBytes += chunk.length;
if (totalBytes > 0) {
const progress = (downloadedBytes / totalBytes) * 100;
const mainWindow = getWindowManager().getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('download-progress', progress);
}
}
});
response.data.pipe(writer);
await new Promise<void>((resolve, reject) => {
writer.on('finish', async () => {
if (process.platform !== 'win32') {
try {
await chmod(tempPackedFilePath, 0o755);
} catch (error) {
getLogManager().logError(
'Failed to make binary executable:',
error as Error
);
}
}
resolve();
});
writer.on('error', reject);
response.data.on('error', reject);
});
}
private async setupLauncher(
tempPackedFilePath: string,
unpackedDirPath: string
): Promise<string> {
let launcherPath = await this.getLauncherPath(unpackedDirPath);
if (!launcherPath || !(await pathExists(launcherPath))) {
const expectedLauncherName =
process.platform === 'win32'
? 'koboldcpp-launcher.exe'
: 'koboldcpp-launcher';
const newLauncherPath = join(unpackedDirPath, expectedLauncherName);
if (await pathExists(tempPackedFilePath)) {
try {
await rename(tempPackedFilePath, newLauncherPath);
launcherPath = newLauncherPath;
} catch (error) {
getLogManager().logError(
'Failed to rename binary as launcher:',
error as Error
);
}
}
} else {
try {
await unlink(tempPackedFilePath);
} catch (error) {
getLogManager().logError(
'Failed to cleanup packed file:',
error as Error
);
}
}
if (!launcherPath || !(await pathExists(launcherPath))) {
throw new Error('Failed to find or create launcher');
}
return launcherPath;
}
async downloadRelease(asset: GitHubAsset): Promise<string> {
const tempPackedFilePath = join(
getConfigManager().getInstallDir(),
`${asset.name}.packed`
);
const baseFilename = stripAssetExtensions(asset.name);
const folderName = asset.version
? `${baseFilename}-${asset.version}`
: baseFilename;
const unpackedDirPath = join(
getConfigManager().getInstallDir(),
folderName
);
try {
await this.handleExistingDirectory(
unpackedDirPath,
Boolean(asset.isUpdate)
);
await this.downloadFile(asset, tempPackedFilePath);
await mkdir(unpackedDirPath, { recursive: true });
await this.unpackKoboldCpp(tempPackedFilePath, unpackedDirPath);
const launcherPath = await this.setupLauncher(
tempPackedFilePath,
unpackedDirPath
);
const currentBinary = getConfigManager().getCurrentKoboldBinary();
if (!currentBinary || (asset.isUpdate && asset.wasCurrentBinary)) {
await getConfigManager().setCurrentKoboldBinary(launcherPath);
}
getWindowManager().sendToRenderer('versions-updated');
return launcherPath;
} catch (error) {
getLogManager().logError(
'Failed to download or unpack binary:',
error as Error
);
throw new Error(
`Failed to download or unpack binary: ${(error as Error).message}`
);
}
}
private async unpackKoboldCpp(
packedPath: string,
unpackDir: string
): Promise<void> {
try {
await execa(packedPath, ['--unpack', unpackDir], {
timeout: 60000,
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch (error) {
const execaError = error as {
stderr?: string;
stdout?: string;
message: string;
};
const errorMessage =
execaError.stderr || execaError.stdout || execaError.message;
throw new Error(`Unpack failed: ${errorMessage}`);
}
}
private async patchKliteEmbd(unpackedDir: string): Promise<void> {
try {
const possiblePaths = [
join(unpackedDir, '_internal', 'klite.embd'),
join(unpackedDir, 'klite.embd'),
];
let kliteEmbdPath: string | null = null;
for (const path of possiblePaths) {
if (await pathExists(path)) {
kliteEmbdPath = path;
break;
}
}
if (!kliteEmbdPath) {
return;
}
const content = await readFile(kliteEmbdPath, 'utf8');
if (content.includes('</head>')) {
let patchedContent = content;
if (content.includes('gerbil-css-override')) {
patchedContent = patchedContent.replace(
/<style id="gerbil-css-override">[\s\S]*?<\/style>\s*/g,
''
);
}
if (content.includes('gerbil-autoscroll-patches')) {
patchedContent = patchedContent.replace(
/<script id="gerbil-autoscroll-patches">[\s\S]*?<\/script>\s*/g,
''
);
}
patchedContent = patchedContent.replace(
'</head>',
`${KLITE_CSS_OVERRIDE}\n${KLITE_AUTOSCROLL_PATCHES}\n</head>`
);
await writeFile(kliteEmbdPath, patchedContent, 'utf8');
}
} catch (error) {
getLogManager().logError('Failed to patch klite.embd:', error as Error);
}
}
private async patchKcppSduiEmbd(unpackedDir: string): Promise<void> {
try {
const possiblePaths = [
join(unpackedDir, '_internal', 'kcpp_sdui.embd'),
join(unpackedDir, 'kcpp_sdui.embd'),
];
const sourceAssetPath = getAssetPath('kcpp_sdui.embd');
for (const targetPath of possiblePaths) {
if (await pathExists(targetPath)) {
await copyFile(sourceAssetPath, targetPath);
break;
}
}
} catch (error) {
getLogManager().logError(
'Failed to patch kcpp_sdui.embd:',
error as Error
);
}
}
private async getLauncherPath(unpackedDir: string): Promise<string | null> {
const extensions =
process.platform === 'win32' ? ['.exe', ''] : ['', '.exe'];
for (const ext of extensions) {
const launcherPath = join(unpackedDir, `koboldcpp-launcher${ext}`);
if (await pathExists(launcherPath)) {
return launcherPath;
}
}
return null;
}
async getInstalledVersions(): Promise<InstalledVersion[]> {
try {
const installDir = getConfigManager().getInstallDir();
if (!(await pathExists(installDir))) {
return [];
}
const items = await readdir(installDir);
const launchers: { path: string; filename: string; size: number }[] = [];
for (const item of items) {
const itemPath = join(installDir, item);
const stats = await stat(itemPath);
if (stats.isDirectory()) {
const launcherPath = await this.getLauncherPath(itemPath);
if (launcherPath && (await pathExists(launcherPath))) {
const launcherStats = await stat(launcherPath);
const launcherFilename = launcherPath.split(/[/\\]/).pop() || '';
launchers.push({
path: launcherPath,
filename: launcherFilename,
size: launcherStats.size,
});
}
}
}
const versionPromises = launchers.map(async (launcher) => {
try {
const detectedVersion = await this.getVersionFromBinary(
launcher.path
);
const version = detectedVersion || 'unknown';
return {
version,
path: launcher.path,
filename: launcher.filename,
size: launcher.size,
} as InstalledVersion;
} catch (error) {
getLogManager().logError(
`Could not detect version for ${launcher.filename}:`,
error as Error
);
return null;
}
});
const results = await Promise.all(versionPromises);
return results.filter(
(version): version is InstalledVersion => version !== null
);
} catch (error) {
getLogManager().logError(
'Error scanning install directory:',
error as Error
);
return [];
}
}
async getConfigFiles(): Promise<
{ name: string; path: string; size: number }[]
> {
const configFiles: { name: string; path: string; size: number }[] = [];
try {
const installDir = getConfigManager().getInstallDir();
if (await pathExists(installDir)) {
const files = await readdir(installDir);
for (const file of files) {
const filePath = join(installDir, file);
const stats = await stat(filePath);
if (
stats.isFile() &&
(file.endsWith('.kcpps') ||
file.endsWith('.kcppt') ||
file.endsWith('.json'))
) {
configFiles.push({
name: file,
path: filePath,
size: stats.size,
});
}
}
}
} catch (error) {
getLogManager().logError(
'Error scanning for config files:',
error as Error
);
}
return configFiles.sort((a, b) => a.name.localeCompare(b.name));
}
async parseConfigFile(filePath: string): Promise<KoboldConfig | null> {
try {
if (!(await pathExists(filePath))) {
return null;
}
const config = await readJsonFile(filePath);
return config as KoboldConfig;
} catch (error) {
getLogManager().logError('Error parsing config file:', error as Error);
return null;
}
}
async saveConfigFile(
configFileName: string,
configData: KoboldConfig
): Promise<boolean> {
try {
const installDir = getConfigManager().getInstallDir();
const configPath = join(installDir, configFileName);
await writeJsonFile(configPath, configData);
return true;
} catch (error) {
getLogManager().logError('Error saving config file:', error as Error);
return false;
}
}
async selectModelFile(title = 'Select Model File'): Promise<string | null> {
try {
const mainWindow = getWindowManager().getMainWindow();
if (!mainWindow) {
return null;
}
const result = await dialog.showOpenDialog(mainWindow, {
title,
filters: [
{
name: 'Model Files',
extensions: ['gguf', 'safetensors'],
},
{ name: 'All Files', extensions: ['*'] },
],
properties: ['openFile'],
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
return result.filePaths[0];
} catch (error) {
getLogManager().logError('Error selecting model file:', error as Error);
return null;
}
}
async getCurrentVersion(): Promise<InstalledVersion | null> {
const currentBinaryPath = getConfigManager().getCurrentKoboldBinary();
const versions = await this.getInstalledVersions();
if (currentBinaryPath && (await pathExists(currentBinaryPath))) {
const currentVersion = versions.find((v) => v.path === currentBinaryPath);
if (currentVersion) {
return currentVersion;
}
}
const firstVersion = versions[0];
if (firstVersion) {
await getConfigManager().setCurrentKoboldBinary(firstVersion.path);
return firstVersion;
}
if (currentBinaryPath) {
await getConfigManager().setCurrentKoboldBinary('');
}
return null;
}
async getCurrentBinaryInfo(): Promise<{
path: string;
filename: string;
} | null> {
const currentVersion = await this.getCurrentVersion();
if (currentVersion) {
const pathParts = currentVersion.path.split(/[/\\]/);
const filename =
pathParts[pathParts.length - 2] || currentVersion.filename;
return {
path: currentVersion.path,
filename,
};
}
return null;
}
async setCurrentVersion(binaryPath: string): Promise<boolean> {
if (await pathExists(binaryPath)) {
await getConfigManager().setCurrentKoboldBinary(binaryPath);
getWindowManager().sendToRenderer('versions-updated');
return true;
}
return false;
}
async getVersionFromBinary(launcherPath: string): Promise<string | null> {
try {
if (!(await pathExists(launcherPath))) {
return null;
}
const folderName = launcherPath.split(/[/\\]/).slice(-2, -1)[0];
if (folderName) {
const versionMatch = folderName.match(
/-(\d+\.\d+(?:\.\d+)?(?:\.[a-zA-Z0-9]+)*(?:-[a-zA-Z0-9]+)*)$/
);
if (versionMatch) {
return versionMatch[1];
}
}
const result = await execa(launcherPath, ['--version'], {
timeout: 30000,
stdio: ['ignore', 'pipe', 'pipe'],
});
const allOutput = (result.stdout + result.stderr).trim();
if (/^\d+\.\d+/.test(allOutput)) {
const versionParts = allOutput.split(/\s+/)[0];
if (versionParts && /^\d+\.\d+/.test(versionParts)) {
return versionParts;
}
}
const lines = allOutput.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
if (/^\d+\.\d+/.test(trimmedLine)) {
const versionPart = trimmedLine.split(/\s+/)[0];
if (versionPart) {
return versionPart;
}
}
}
return null;
} catch {
return null;
}
}
async selectInstallDirectory(): Promise<string | null> {
const result = await dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
title: `Select the ${PRODUCT_NAME} Installation Directory`,
defaultPath: getConfigManager().getInstallDir(),
buttonLabel: 'Select Directory',
});
if (!result.canceled && result.filePaths.length > 0) {
await getConfigManager().setInstallDir(result.filePaths[0]);
getWindowManager().sendToRenderer(
'install-dir-changed',
result.filePaths[0]
);
return result.filePaths[0];
}
return null;
}
async launchKoboldCpp(
args: string[] = [],
frontendPreference: FrontendPreference = 'koboldcpp'
): Promise<{ success: boolean; pid?: number; error?: string }> {
try {
if (this.koboldProcess) {
await this.stopKoboldCpp();
}
const currentVersion = await this.getCurrentVersion();
if (!currentVersion || !(await pathExists(currentVersion.path))) {
const rawPath = getConfigManager().getCurrentKoboldBinary();
const error = currentVersion
? `Binary file does not exist at path: ${currentVersion.path}`
: 'No version configured';
getLogManager().logError(
`Launch failed: ${error}. Raw config path: "${rawPath}", Current version: ${JSON.stringify(currentVersion)}`
);
return {
success: false,
error,
};
}
const binaryDir = currentVersion.path
.split(/[/\\]/)
.slice(0, -1)
.join('/');
const { isImageMode } = parseKoboldConfig(args);
if (frontendPreference === 'koboldcpp') {
if (isImageMode) {
await this.patchKcppSduiEmbd(binaryDir);
} else {
await this.patchKliteEmbd(binaryDir);
}
} else if (frontendPreference === 'openwebui' && isImageMode) {
await this.patchKcppSduiEmbd(binaryDir);
}
const finalArgs = [...args];
const child = spawn(currentVersion.path, finalArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
detached: false,
});
this.koboldProcess = child;
const commandLine = `$ ${currentVersion.path} ${finalArgs.join(' ')}`;
getWindowManager().sendKoboldOutput(commandLine);
let readyResolve:
| ((value: { success: boolean; pid?: number; error?: string }) => void)
| null = null;
let _readyReject: ((error: Error) => void) | null = null;
let isReady = false;
const readyPromise = new Promise<{
success: boolean;
pid?: number;
error?: string;
}>((resolve, reject) => {
readyResolve = resolve;
_readyReject = reject;
});
child.stdout?.on('data', (data) => {
const output = data.toString();
getWindowManager().sendKoboldOutput(output, true);
if (!isReady && output.includes(SERVER_READY_SIGNALS.KOBOLDCPP)) {
isReady = true;
readyResolve?.({ success: true, pid: child.pid });
}
});
child.stderr?.on('data', (data) => {
const output = data.toString();
getWindowManager().sendKoboldOutput(output, true);
if (!isReady && output.includes(SERVER_READY_SIGNALS.KOBOLDCPP)) {
isReady = true;
readyResolve?.({ success: true, pid: child.pid });
}
});
child.on('exit', (code, signal) => {
const displayMessage = signal
? `\n[INFO] Process terminated with signal ${signal}`
: code === 0
? `\n[INFO] Process exited successfully`
: code && (code > 1 || code < 0)
? `\n[ERROR] Process exited with code ${code}`
: `\n[INFO] Process exited with code ${code}`;
getWindowManager().sendKoboldOutput(displayMessage);
this.koboldProcess = null;
if (!isReady) {
_readyReject?.(
new Error(
`Process exited before ready signal (code: ${code}, signal: ${signal})`
)
);
}
});
child.on('error', (error) => {
getLogManager().logError(`Process error: ${error.message}`, error);
getWindowManager().sendKoboldOutput(
`\n[ERROR] Process error: ${error.message}\n`
);
this.koboldProcess = null;
if (!isReady) {
_readyReject?.(error);
}
});
return readyPromise;
} catch (error) {
const errorMessage = (error as Error).message;
getLogManager().logError(
`Failed to launch: ${errorMessage}`,
error as Error
);
return { success: false, error: errorMessage };
}
}
async stopKoboldCpp(): Promise<void> {
if (this.koboldProcess) {
await terminateProcess(this.koboldProcess, {
timeoutMs: 5000,
logError: (message, error) => getLogManager().logError(message, error),
});
this.koboldProcess = null;
}
}
async cleanup(): Promise<void> {
if (this.koboldProcess) {
await terminateProcess(this.koboldProcess, {
logError: (message, error) => getLogManager().logError(message, error),
});
this.koboldProcess = null;
}
}
}
let koboldCppManagerInstance: KoboldCppManager;
export function getKoboldCppManager(): KoboldCppManager {
if (!koboldCppManagerInstance) {
koboldCppManagerInstance = new KoboldCppManager();
}
return koboldCppManagerInstance;
}

View file

@ -1,97 +0,0 @@
import { app } from 'electron';
import { join } from 'path';
import { createLogger, format, type Logger } from 'winston';
import DailyRotateFile from 'winston-daily-rotate-file';
let logManagerInstance: LogManager | null = null;
export class LogManager {
private logger: Logger;
constructor() {
const logsDir = join(app.getPath('userData'), 'logs');
this.logger = createLogger({
level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
format: format.combine(
format.timestamp(),
format.printf(({ timestamp, level, message, error }) => {
const processInfo = `[${process.type || 'unknown'}:${process.pid}]`;
let logEntry = `${timestamp} ${processInfo} [${level.toUpperCase()}] ${message}`;
if (error && error instanceof Error) {
logEntry += `\n Error: ${error.message}`;
if (error.stack) {
logEntry += `\n Stack: ${error.stack}`;
}
}
return logEntry;
})
),
transports: [
new DailyRotateFile({
filename: join(logsDir, 'gerbil-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
maxSize: '10m',
maxFiles: '5d',
createSymlink: true,
symlinkName: 'gerbil.log',
}),
],
});
this.setupGlobalErrorHandlers();
}
public logError(message: string, error?: Error) {
this.logger.error(message, { error });
this.flush();
}
public flush() {
const fileTransport = this.logger.transports.find(
(t) => t.constructor.name === 'DailyRotateFile'
);
if (
fileTransport &&
'flush' in fileTransport &&
typeof fileTransport.flush === 'function'
) {
(fileTransport as { flush: () => void }).flush();
}
}
public logDebug(message: string) {
// eslint-disable-next-line no-console
console.log(message);
}
public setupGlobalErrorHandlers() {
process.on('uncaughtException', (error) => {
this.logError('Uncaught Exception:', error);
});
process.on('unhandledRejection', (reason, _promise) => {
const message = `Unhandled Promise Rejection`;
const error =
reason instanceof Error ? reason : new Error(String(reason));
this.logError(message, error);
});
}
public getLogFilePath(): string {
return join(app.getPath('userData'), 'logs', 'gerbil.log');
}
public getLogsDirectory(): string {
return join(app.getPath('userData'), 'logs');
}
}
export const getLogManager = (): LogManager => {
if (!logManagerInstance) {
logManagerInstance = new LogManager();
}
return logManagerInstance;
};

View file

@ -1,288 +0,0 @@
import { spawn } from 'child_process';
import type { ChildProcess } from 'child_process';
import { join } from 'path';
import { homedir } from 'os';
import { access } from 'fs/promises';
import { getLogManager } from './LogManager';
import { getWindowManager } from './WindowManager';
import { getConfigManager } from './ConfigManager';
import { OPENWEBUI, SERVER_READY_SIGNALS } from '@/constants';
import { terminateProcess } from '@/utils/process';
import { parseKoboldConfig } from '@/utils/kobold';
export class OpenWebUIManager {
private openWebUIProcess: ChildProcess | null = null;
private static readonly OPENWEBUI_BASE_ARGS = [
'--python',
'3.11',
'open-webui@latest',
'serve',
];
constructor() {
process.on('SIGINT', () => {
this.cleanup().catch(() => {
void 0;
});
});
process.on('SIGTERM', () => {
this.cleanup().catch(() => {
void 0;
});
});
}
private async getUvEnvironment(): Promise<
Record<string, string | undefined>
> {
const env = { ...process.env };
const uvPaths = [
join(homedir(), '.cargo', 'bin'),
join(homedir(), '.local', 'bin'),
];
const existingPaths: string[] = [];
for (const path of uvPaths) {
try {
await access(path);
existingPaths.push(path);
} catch {
void 0;
}
}
if (existingPaths.length > 0) {
const pathSeparator = process.platform === 'win32' ? ';' : ':';
env.PATH = `${existingPaths.join(pathSeparator)}${pathSeparator}${env.PATH}`;
}
if (process.platform === 'win32') {
env.PYTHONIOENCODING = 'utf-8';
env.PYTHONLEGACYWINDOWSSTDIO = '1';
env.PYTHONUTF8 = '1';
env.CHCP = '65001';
}
return env;
}
async isUvAvailable(): Promise<boolean> {
try {
const env = await this.getUvEnvironment();
const testProcess = spawn('uv', ['--version'], { stdio: 'pipe', env });
return new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => {
testProcess.kill();
resolve(false);
}, 10000);
testProcess.on('exit', (code) => {
clearTimeout(timeout);
resolve(code === 0);
});
testProcess.on('error', () => {
clearTimeout(timeout);
resolve(false);
});
});
} catch {
return false;
}
}
private async createUvProcess(
args: string[],
env?: Record<string, string>
): Promise<ChildProcess> {
const uvEnv = await this.getUvEnvironment();
const mergedEnv = { ...uvEnv, ...env };
if (process.platform === 'win32') {
mergedEnv.PYTHONIOENCODING = 'utf-8';
mergedEnv.PYTHONUTF8 = '1';
}
return spawn('uvx', args, {
stdio: ['pipe', 'pipe', 'pipe'],
detached: false,
env: mergedEnv,
});
}
private async waitForOpenWebUIToStart(): Promise<void> {
getWindowManager().sendKoboldOutput('Waiting for Open WebUI to start...');
return new Promise((resolve, reject) => {
const checkForOutput = (data: Buffer) => {
const output = data.toString();
if (output.includes(SERVER_READY_SIGNALS.OPENWEBUI)) {
getWindowManager().sendKoboldOutput('Open WebUI is now running!');
resolve();
if (this.openWebUIProcess?.stdout) {
this.openWebUIProcess.stdout.removeListener('data', checkForOutput);
}
}
};
if (this.openWebUIProcess?.stdout) {
this.openWebUIProcess.stdout.on('data', checkForOutput);
} else {
reject(new Error('Open WebUI process stdout not available'));
}
});
}
async startFrontend(args: string[]): Promise<void> {
try {
const config = {
name: 'openwebui',
port: OPENWEBUI.PORT,
};
const {
host: koboldHost,
port: koboldPort,
isImageMode,
} = parseKoboldConfig(args);
if (isImageMode) {
return;
}
await this.stopFrontend();
getWindowManager().sendKoboldOutput(
`Preparing Open WebUI to connect at ${koboldHost}:${koboldPort}...`
);
getWindowManager().sendKoboldOutput(
`Starting ${config.name} frontend on port ${config.port}...`
);
const koboldUrl = `http://${koboldHost}:${koboldPort}`;
const openWebUIArgs = [
...OpenWebUIManager.OPENWEBUI_BASE_ARGS,
'--port',
config.port.toString(),
];
getWindowManager().sendKoboldOutput('Starting Open WebUI with uv...');
const installDir = getConfigManager().getInstallDir();
const openWebUIDataDir = join(installDir, 'openwebui-data');
this.openWebUIProcess = await this.createUvProcess(openWebUIArgs, {
OPENAI_API_BASE_URL: `${koboldUrl}/v1`,
OPENAI_API_KEY: 'kobold',
DATA_DIR: openWebUIDataDir,
DISABLE_SIGNUP: 'true',
});
if (this.openWebUIProcess.stdout) {
this.openWebUIProcess.stdout.on('data', (data: Buffer) => {
try {
const output = data.toString('utf8');
getWindowManager().sendKoboldOutput(output, true);
} catch (error) {
getLogManager().logError(
'Error processing stdout data:',
error as Error
);
}
});
}
if (this.openWebUIProcess.stderr) {
this.openWebUIProcess.stderr.on('data', (data: Buffer) => {
try {
const output = data.toString('utf8');
getWindowManager().sendKoboldOutput(output, true);
} catch (error) {
getLogManager().logError(
'Error processing stderr data:',
error as Error
);
}
});
}
this.openWebUIProcess.on(
'exit',
(code: number | null, signal: string | null) => {
const message = signal
? `Open WebUI terminated with signal ${signal}`
: `Open WebUI exited with code ${code}`;
getWindowManager().sendKoboldOutput(message);
this.openWebUIProcess = null;
}
);
this.openWebUIProcess.on('error', (error) => {
getLogManager().logError('Open WebUI process error:', error);
getWindowManager().sendKoboldOutput(
`Open WebUI error: ${error.message}`
);
this.openWebUIProcess = null;
});
await this.waitForOpenWebUIToStart();
getWindowManager().sendKoboldOutput(
`Open WebUI is ready and auto-configured!`
);
getWindowManager().sendKoboldOutput(
`Access Open WebUI at: http://localhost:${config.port}`
);
getWindowManager().sendKoboldOutput(
`Connection: ${koboldUrl}/v1 (auto-configured)`
);
} catch (error) {
getLogManager().logError(
'Failed to start Open WebUI frontend:',
error as Error
);
getWindowManager().sendKoboldOutput(
`Failed to start Open WebUI: ${(error as Error).message}`
);
this.openWebUIProcess = null;
throw error;
}
}
async stopFrontend(): Promise<void> {
if (this.openWebUIProcess) {
getWindowManager().sendKoboldOutput('Stopping Open WebUI...');
try {
await terminateProcess(this.openWebUIProcess, {
logError: (message, error) =>
getLogManager().logError(message, error),
});
getWindowManager().sendKoboldOutput('Open WebUI stopped');
} catch (error) {
getLogManager().logError('Error stopping Open WebUI:', error as Error);
}
this.openWebUIProcess = null;
}
}
async cleanup(): Promise<void> {
await this.stopFrontend();
}
}
let openWebUIManagerInstance: OpenWebUIManager;
export function getOpenWebUIManager(): OpenWebUIManager {
if (!openWebUIManagerInstance) {
openWebUIManagerInstance = new OpenWebUIManager();
}
return openWebUIManagerInstance;
}

View file

@ -1,569 +0,0 @@
import { spawn } from 'child_process';
import { createServer, request, type Server } from 'http';
import { homedir } from 'os';
import { join } from 'path';
import { access, readdir } from 'fs/promises';
import type { ChildProcess } from 'child_process';
import { getLogManager } from './LogManager';
import { getWindowManager } from './WindowManager';
import { SILLYTAVERN, SERVER_READY_SIGNALS } from '@/constants';
import { terminateProcess } from '@/utils/process';
import { pathExists, readJsonFile, writeJsonFile } from '@/utils/fs';
import { parseKoboldConfig } from '@/utils/kobold';
export class SillyTavernManager {
private sillyTavernProcess: ChildProcess | null = null;
private proxyServer: Server | null = null;
private static readonly SILLYTAVERN_BASE_ARGS = [
'sillytavern',
'--global',
'--listen',
'--browserLaunchEnabled',
'false',
'--disableCsrf',
];
constructor() {
process.on('SIGINT', () => {
this.cleanup().catch(() => {
void 0;
});
});
process.on('SIGTERM', () => {
this.cleanup().catch(() => {
void 0;
});
});
}
private detectedDataRoot: string | null = null;
private getFallbackDataRoot(): string {
const platform = process.platform;
const home = homedir();
switch (platform) {
case 'win32':
return join(home, 'AppData', 'Local', 'SillyTavern', 'Data', 'data');
case 'darwin':
return join(
home,
'Library',
'Application Support',
'SillyTavern',
'data'
);
case 'linux':
default:
return join(home, '.local', 'share', 'SillyTavern', 'data');
}
}
private async getSillyTavernDataRoot(): Promise<string> {
if (this.detectedDataRoot) {
return this.detectedDataRoot;
}
const fallback = this.getFallbackDataRoot();
this.detectedDataRoot = fallback;
return fallback;
}
private async getSillyTavernSettingsPath(): Promise<string> {
const dataRoot = await this.getSillyTavernDataRoot();
return join(dataRoot, 'default-user', 'settings.json');
}
private async tryAddPathToEnv(
env: Record<string, string | undefined>,
path: string
): Promise<boolean> {
const pathSeparator = process.platform === 'win32' ? ';' : ':';
if (!env.PATH?.includes(path)) {
env.PATH = `${path}${pathSeparator}${env.PATH}`;
return true;
}
return false;
}
private async tryVersionManagerPath(
basePath: string,
env: Record<string, string | undefined>
): Promise<boolean> {
try {
await access(basePath);
const versions = await readdir(basePath);
if (versions.length > 0) {
const latestVersion = versions.sort().pop();
if (latestVersion) {
const binSubPath = basePath.includes('fnm')
? join('installation', 'bin')
: 'bin';
const nodeBinPath = join(basePath, latestVersion, binSubPath);
try {
await access(nodeBinPath);
return this.tryAddPathToEnv(env, nodeBinPath);
} catch {
return false;
}
}
}
} catch {
return false;
}
return false;
}
private async getNodeEnvironment(): Promise<
Record<string, string | undefined>
> {
const env = { ...process.env };
if (process.platform === 'win32') {
return env;
}
const versionManagerPaths = [
join(homedir(), '.local', 'share', 'fnm', 'node-versions'),
join(homedir(), '.nvm', 'versions', 'node'),
join(homedir(), '.volta', 'tools', 'image', 'node'),
join(homedir(), '.asdf', 'installs', 'nodejs'),
];
const systemPaths: string[] = [];
if (process.platform === 'darwin') {
systemPaths.push('/opt/homebrew/bin', '/usr/local/bin');
}
for (const systemPath of systemPaths) {
try {
await access(systemPath);
await this.tryAddPathToEnv(env, systemPath);
return env;
} catch {
continue;
}
}
for (const versionPath of versionManagerPaths) {
if (await this.tryVersionManagerPath(versionPath, env)) {
return env;
}
}
return env;
}
async isNpxAvailable(): Promise<boolean> {
try {
const env = await this.getNodeEnvironment();
const testProcess = spawn('npx', ['--version'], {
stdio: 'pipe',
env,
shell: process.platform === 'win32',
});
return new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => {
testProcess.kill();
resolve(false);
}, 5000);
testProcess.on('exit', (code) => {
clearTimeout(timeout);
resolve(code === 0);
});
testProcess.on('error', () => {
clearTimeout(timeout);
resolve(false);
});
});
} catch {
return false;
}
}
private async createNpxProcess(args: string[]): Promise<ChildProcess> {
const env = await this.getNodeEnvironment();
return spawn('npx', args, {
stdio: ['pipe', 'pipe', 'pipe'],
detached: false,
env,
shell: process.platform === 'win32',
});
}
private async ensureSillyTavernSettings(): Promise<void> {
const settingsPath = await this.getSillyTavernSettingsPath();
if (await pathExists(settingsPath)) {
getWindowManager().sendKoboldOutput(
`SillyTavern settings found at ${settingsPath}`
);
return;
}
getWindowManager().sendKoboldOutput(
'SillyTavern settings not found, starting SillyTavern briefly to generate config...'
);
const initProcess = await this.createNpxProcess(
SillyTavernManager.SILLYTAVERN_BASE_ARGS
);
return new Promise((resolve, reject) => {
let hasResolved = false;
initProcess.on('exit', (code: number | null, signal: string | null) => {
getWindowManager().sendKoboldOutput(
signal
? `SillyTavern init process terminated with signal ${signal}`
: `SillyTavern init process exited with code ${code}`
);
if (!hasResolved) {
hasResolved = true;
if (code !== 0) {
const errorMsg =
code === 4294963214
? 'SillyTavern failed to install due to EBUSY error (resource busy or locked). This is a critical error.'
: `SillyTavern initialization failed with exit code ${code}`;
getLogManager().logError(
'SillyTavern initialization failed:',
new Error(errorMsg)
);
getWindowManager().sendKoboldOutput(`CRITICAL ERROR: ${errorMsg}`);
reject(new Error(errorMsg));
} else {
getWindowManager().sendKoboldOutput(
'SillyTavern settings should now be generated'
);
resolve();
}
}
});
initProcess.on('error', (error) => {
if (!hasResolved) {
hasResolved = true;
getLogManager().logError(
'Failed to initialize SillyTavern settings:',
error
);
getWindowManager().sendKoboldOutput(
`SillyTavern initialization error: ${error.message}`
);
reject(error);
}
});
if (initProcess.stdout) {
initProcess.stdout.on('data', (data: Buffer) => {
const output = data.toString();
if (output.includes(SERVER_READY_SIGNALS.SILLYTAVERN)) {
setTimeout(async () => {
if (!initProcess.killed && !hasResolved) {
hasResolved = true;
await terminateProcess(initProcess, {
logError: (message, error) =>
getLogManager().logError(message, error),
});
getWindowManager().sendKoboldOutput(
'SillyTavern settings should now be generated'
);
resolve();
}
}, 2000);
}
});
}
if (initProcess.stderr) {
initProcess.stderr.on('data', (data: Buffer) => {
getWindowManager().sendKoboldOutput(data.toString().trim());
});
}
});
}
private async setupSillyTavernConfig(
koboldHost: string,
koboldPort: number,
isImageMode: boolean
): Promise<void> {
try {
const configPath = await this.getSillyTavernSettingsPath();
let settings: Record<string, unknown> = {};
if (await pathExists(configPath)) {
try {
const existingSettings =
await readJsonFile<Record<string, unknown>>(configPath);
if (existingSettings) {
settings = existingSettings;
getWindowManager().sendKoboldOutput(
`Loaded existing SillyTavern settings`
);
}
} catch {
getWindowManager().sendKoboldOutput(
`Could not read existing settings, creating new ones`
);
}
}
const koboldUrl = `http://${koboldHost}:${koboldPort}`;
if (!settings.power_user) settings.power_user = {};
const powerUser = settings.power_user as Record<string, unknown>;
powerUser.auto_connect = true;
if (isImageMode) {
getWindowManager().sendKoboldOutput(
`Image generation mode detected. Please configure SillyTavern manually:\n` +
`1. Open SillyTavern and navigate to Settings (top-right gear icon)\n` +
`2. Go to 'Extensions' tab and enable 'Image Generation'\n` +
`3. In Image Generation settings, set Source to 'Stable Diffusion WebUI (AUTOMATIC1111)'\n` +
`4. Set API URL to: ${koboldUrl}\n` +
`5. Click 'Connect' to test the connection`
);
}
if (!settings.textgenerationwebui_settings)
settings.textgenerationwebui_settings = {};
const textgenSettings = settings.textgenerationwebui_settings as Record<
string,
unknown
>;
if (!textgenSettings.server_urls) textgenSettings.server_urls = {};
const serverUrls = textgenSettings.server_urls as Record<string, unknown>;
serverUrls.koboldcpp = koboldUrl;
settings.main_api = 'textgenerationwebui';
textgenSettings.type = 'koboldcpp';
getWindowManager().sendKoboldOutput(
`Configured SillyTavern for text generation at ${koboldUrl}`
);
await writeJsonFile(configPath, settings);
getWindowManager().sendKoboldOutput(
`SillyTavern configuration updated successfully!`
);
} catch (error) {
getLogManager().logError(
'Failed to setup SillyTavern config:',
error as Error
);
getWindowManager().sendKoboldOutput(
`Failed to configure SillyTavern: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private async waitForSillyTavernToStart(_port: number): Promise<void> {
getWindowManager().sendKoboldOutput('Waiting for SillyTavern to start...');
return new Promise((resolve, reject) => {
const checkForOutput = (data: Buffer) => {
if (data.toString().includes(SERVER_READY_SIGNALS.SILLYTAVERN)) {
getWindowManager().sendKoboldOutput('SillyTavern is now running!');
resolve();
if (this.sillyTavernProcess?.stdout) {
this.sillyTavernProcess.stdout.removeListener(
'data',
checkForOutput
);
}
}
};
if (this.sillyTavernProcess?.stdout) {
this.sillyTavernProcess.stdout.on('data', checkForOutput);
} else {
reject(new Error('SillyTavern process stdout not available'));
}
});
}
private createProxyServer(targetPort: number, proxyPort: number): void {
this.proxyServer = createServer((req, res) => {
const options = {
hostname: 'localhost',
port: targetPort,
path: req.url,
method: req.method,
headers: req.headers,
};
const proxyReq = request(options, (proxyRes) => {
const headers = { ...proxyRes.headers };
delete headers['x-frame-options'];
res.writeHead(proxyRes.statusCode || 200, headers);
proxyRes.pipe(res);
});
proxyReq.on('error', (err) => {
getLogManager().logError('Proxy request error:', err);
res.writeHead(500);
res.end('Proxy error');
});
req.pipe(proxyReq);
});
this.proxyServer.listen(proxyPort, () => {
getWindowManager().sendKoboldOutput(
`Proxy server started on port ${proxyPort}, forwarding to SillyTavern on port ${targetPort}`
);
});
this.proxyServer.on('error', (err) => {
getLogManager().logError('Proxy server error:', err);
});
}
async startFrontend(args: string[]): Promise<void> {
try {
const config = {
name: 'sillytavern',
port: SILLYTAVERN.PORT,
proxyPort: SILLYTAVERN.PROXY_PORT,
};
const {
host: koboldHost,
port: koboldPort,
isImageMode,
} = parseKoboldConfig(args);
await this.stopFrontend();
getWindowManager().sendKoboldOutput(
`Preparing SillyTavern to connect at ${koboldHost}:${koboldPort}...`
);
await this.ensureSillyTavernSettings();
await this.setupSillyTavernConfig(koboldHost, koboldPort, isImageMode);
getWindowManager().sendKoboldOutput(
`Starting ${config.name} frontend on port ${config.port}...`
);
const sillyTavernArgs = [
...SillyTavernManager.SILLYTAVERN_BASE_ARGS,
'--port',
config.port.toString(),
];
getWindowManager().sendKoboldOutput(
'Final port check before starting SillyTavern...'
);
this.sillyTavernProcess = await this.createNpxProcess(sillyTavernArgs);
if (this.sillyTavernProcess.stdout) {
this.sillyTavernProcess.stdout.on('data', (data: Buffer) => {
getWindowManager().sendKoboldOutput(data.toString(), true);
});
}
if (this.sillyTavernProcess.stderr) {
this.sillyTavernProcess.stderr.on('data', (data: Buffer) => {
getWindowManager().sendKoboldOutput(data.toString(), true);
});
}
this.sillyTavernProcess.on(
'exit',
(code: number | null, signal: string | null) => {
const message = signal
? `SillyTavern terminated with signal ${signal}`
: `SillyTavern exited with code ${code}`;
getWindowManager().sendKoboldOutput(message);
this.sillyTavernProcess = null;
}
);
this.sillyTavernProcess.on('error', (error) => {
getLogManager().logError('SillyTavern process error:', error);
getWindowManager().sendKoboldOutput(
`SillyTavern error: ${error.message}`
);
this.sillyTavernProcess = null;
});
await this.waitForSillyTavernToStart(config.port);
this.createProxyServer(config.port, config.proxyPort);
} catch (error) {
getLogManager().logError(
`Failed to start SillyTavern: ${error instanceof Error ? error.message : String(error)}`,
error as Error
);
throw error;
}
}
async stopFrontend(): Promise<void> {
const promises: Promise<void>[] = [];
if (this.sillyTavernProcess) {
promises.push(
terminateProcess(this.sillyTavernProcess, {
logError: (message, error) =>
getLogManager().logError(message, error),
}).then(() => {
this.sillyTavernProcess = null;
})
);
}
if (this.proxyServer) {
promises.push(
new Promise((resolve) => {
this.proxyServer?.close(() => {
this.proxyServer = null;
resolve();
});
})
);
}
await Promise.all(promises);
}
async cleanup(): Promise<void> {
if (this.sillyTavernProcess) {
try {
await this.stopFrontend();
} catch (error) {
getLogManager().logError(
'Error during SillyTavernManager cleanup:',
error as Error
);
}
}
}
}
let sillyTavernManagerInstance: SillyTavernManager;
export function getSillyTavernManager(): SillyTavernManager {
if (!sillyTavernManagerInstance) {
sillyTavernManagerInstance = new SillyTavernManager();
}
return sillyTavernManagerInstance;
}

View file

@ -1,213 +0,0 @@
import { BrowserWindow, app, shell, screen, Menu, clipboard } from 'electron';
import { join } from 'path';
import { stripVTControlCharacters } from 'util';
import { PRODUCT_NAME } from '../../constants';
import type { IPCChannel, IPCChannelPayloads } from '@/types/ipc';
export class WindowManager {
private mainWindow: BrowserWindow | null = null;
private isDevelopment(): boolean {
return process.env.NODE_ENV === 'development' || !app.isPackaged;
}
createMainWindow(): BrowserWindow {
const { workAreaSize } = screen.getPrimaryDisplay();
const windowHeight = Math.floor(workAreaSize.height * 0.86);
this.mainWindow = new BrowserWindow({
width: 1000,
height: windowHeight,
frame: false,
title: PRODUCT_NAME,
show: false,
backgroundColor: '#ffffff',
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: join(__dirname, '../preload/index.js'),
backgroundThrottling: false,
offscreen: false,
spellcheck: false,
},
});
this.mainWindow.once('ready-to-show', () => {
this.mainWindow?.show();
});
if (process.env.VITE_DEV_SERVER_URL) {
this.mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
} else if (process.env.NODE_ENV === 'development') {
this.mainWindow.loadURL('http://localhost:5173');
} else {
this.mainWindow.loadFile(join(__dirname, '../../dist/index.html'));
}
this.mainWindow.on('closed', () => {
this.mainWindow = null;
});
if (!this.isDevelopment()) {
Menu.setApplicationMenu(null);
}
this.mainWindow.webContents.on('will-navigate', (event, navigationUrl) => {
const url = new URL(navigationUrl);
if (
url.hostname !== 'localhost' &&
url.hostname !== '127.0.0.1' &&
!navigationUrl.startsWith('file://')
) {
event.preventDefault();
}
});
this.mainWindow.webContents.setWindowOpenHandler(() => ({
action: 'allow',
}));
this.mainWindow.on('close', () => {
app.quit();
});
this.setupContextMenu();
return this.mainWindow;
}
private setupContextMenu() {
if (!this.mainWindow) return;
// eslint-disable-next-line sonarjs/cognitive-complexity
this.mainWindow.webContents.on('context-menu', (_, params) => {
const hasLinkURL = !!params.linkURL;
const hasSelection = !!params.selectionText;
const isEditable = params.isEditable;
const isDev = this.isDevelopment();
const canCut = hasSelection && isEditable;
const canCopy = hasSelection;
const canPaste = isEditable;
const canSelectAll = isEditable || params.mediaType === 'none';
const canUndo = isEditable && params.editFlags?.canUndo;
const canRedo = isEditable && params.editFlags?.canRedo;
const hasEditOperations =
canCut || canCopy || canPaste || canSelectAll || canUndo || canRedo;
const menuItems = [];
if (isDev) {
menuItems.push({
label: 'Inspect Element',
click: () => {
this.mainWindow?.webContents.inspectElement(params.x, params.y);
},
});
}
if (hasEditOperations) {
if (isDev) {
menuItems.push({ type: 'separator' as const });
}
if (canUndo) {
menuItems.push({ label: 'Undo', role: 'undo' as const });
}
if (canRedo) {
menuItems.push({ label: 'Redo', role: 'redo' as const });
}
if (
(canUndo || canRedo) &&
(canCut || canCopy || canPaste || canSelectAll)
) {
menuItems.push({ type: 'separator' as const });
}
if (canCut) {
menuItems.push({ label: 'Cut', role: 'cut' as const });
}
if (canCopy) {
menuItems.push({ label: 'Copy', role: 'copy' as const });
}
if (canPaste) {
menuItems.push({ label: 'Paste', role: 'paste' as const });
}
if ((canCut || canCopy || canPaste) && canSelectAll) {
menuItems.push({ type: 'separator' as const });
}
if (canSelectAll) {
menuItems.push({ label: 'Select All', role: 'selectAll' as const });
}
}
if (hasLinkURL) {
if (isDev || hasEditOperations) {
menuItems.push({ type: 'separator' as const });
}
menuItems.push({
label: 'Open Link in Browser',
click: () => {
if (params.linkURL) {
shell.openExternal(params.linkURL);
}
},
});
menuItems.push({
label: 'Copy Link Address',
click: () => {
if (params.linkURL) {
clipboard.writeText(params.linkURL);
}
},
});
}
if (menuItems.length > 0) {
const menu = Menu.buildFromTemplate(menuItems);
menu.popup({ window: this.mainWindow! });
}
});
}
getMainWindow(): BrowserWindow | null {
return this.mainWindow;
}
sendToRenderer<T extends IPCChannel>(
channel: T,
...args: IPCChannelPayloads[T]
): void {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send(channel, ...args);
}
}
sendKoboldOutput(message: string, raw?: boolean): void {
const cleanMessage = stripVTControlCharacters(message);
this.sendToRenderer(
'kobold-output',
raw ? cleanMessage : `${cleanMessage}\n`
);
}
public cleanup() {
if (this.mainWindow) {
this.mainWindow.removeAllListeners();
}
}
}
let windowManagerInstance: WindowManager;
export function getWindowManager(): WindowManager {
if (!windowManagerInstance) {
windowManagerInstance = new WindowManager();
}
return windowManagerInstance;
}

175
src/main/modules/binary.ts Normal file
View file

@ -0,0 +1,175 @@
import { join, dirname } from 'path';
import { pathExists } from '@/utils/fs';
import { logError } from './logging';
import { getCurrentBinaryInfo } from './koboldcpp';
import { detectGPUCapabilities, detectCPU } from './hardware';
import type { BackendOption, BackendSupport } from '@/types';
const backendSupportCache = new Map<string, BackendSupport>();
const availableBackendsCache = new Map<string, BackendOption[]>();
async function detectBackendSupportFromPath(koboldBinaryPath: string) {
if (backendSupportCache.has(koboldBinaryPath)) {
return backendSupportCache.get(koboldBinaryPath)!;
}
const support: BackendSupport = {
rocm: false,
vulkan: false,
clblast: false,
noavx2: false,
failsafe: false,
cuda: false,
};
try {
const binaryDir = dirname(koboldBinaryPath);
const internalDir = join(binaryDir, '_internal');
const platform = process.platform;
const libExtension = platform === 'win32' ? '.dll' : '.so';
const hasKoboldCppLib = async (name: string): Promise<boolean> => {
const filename = `${name}${libExtension}`;
if (platform === 'win32') {
return (
(await pathExists(join(binaryDir, filename))) ||
(await pathExists(join(internalDir, filename)))
);
} else {
return (
(await pathExists(join(internalDir, filename))) ||
(await pathExists(join(binaryDir, filename)))
);
}
};
support.rocm = await hasKoboldCppLib('koboldcpp_hipblas');
support.vulkan = await hasKoboldCppLib('koboldcpp_vulkan');
support.clblast = await hasKoboldCppLib('koboldcpp_clblast');
support.noavx2 = await hasKoboldCppLib('koboldcpp_noavx2');
support.failsafe = await hasKoboldCppLib('koboldcpp_failsafe');
support.cuda = await hasKoboldCppLib('koboldcpp_cublas');
} catch (error) {
logError('Error detecting backend support:', error as Error);
}
backendSupportCache.set(koboldBinaryPath, support);
return support;
}
export async function detectBackendSupport() {
try {
const currentBinaryInfo = await getCurrentBinaryInfo();
if (!currentBinaryInfo?.path) {
return null;
}
return detectBackendSupportFromPath(currentBinaryInfo.path);
} catch (error) {
logError('Error detecting current binary backend support:', error as Error);
return null;
}
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export async function getAvailableBackends(
includeDisabled = false
): Promise<BackendOption[]> {
try {
const [currentBinaryInfo, hardwareCapabilities, cpuCapabilities] =
await Promise.all([
getCurrentBinaryInfo(),
detectGPUCapabilities(),
includeDisabled ? detectCPU() : Promise.resolve(null),
]);
if (!currentBinaryInfo?.path) {
return [{ value: 'cpu', label: 'CPU' }];
}
const cacheKey = `${currentBinaryInfo.path}:${includeDisabled}`;
if (availableBackendsCache.has(cacheKey)) {
return availableBackendsCache.get(cacheKey)!;
}
const backendSupport = await detectBackendSupport();
if (!backendSupport) {
return [];
}
const backends: BackendOption[] = [];
if (backendSupport.cuda) {
const isSupported = hardwareCapabilities.cuda.supported;
if (isSupported || includeDisabled) {
backends.push({
value: 'cuda',
label: 'CUDA',
devices: hardwareCapabilities.cuda.devices,
disabled: includeDisabled ? !isSupported : undefined,
});
}
}
if (backendSupport.rocm) {
const isSupported = hardwareCapabilities.rocm.supported;
if (isSupported || includeDisabled) {
backends.push({
value: 'rocm',
label: 'ROCm',
devices: hardwareCapabilities.rocm.devices,
disabled: includeDisabled ? !isSupported : undefined,
});
}
}
if (backendSupport.vulkan) {
const isSupported = hardwareCapabilities.vulkan.supported;
if (isSupported || includeDisabled) {
backends.push({
value: 'vulkan',
label: 'Vulkan',
devices: hardwareCapabilities.vulkan.devices,
disabled: includeDisabled ? !isSupported : undefined,
});
}
}
if (backendSupport.clblast) {
const isSupported = hardwareCapabilities.clblast.supported;
if (isSupported || includeDisabled) {
backends.push({
value: 'clblast',
label: 'CLBlast',
devices: hardwareCapabilities.clblast.devices,
disabled: includeDisabled ? !isSupported : undefined,
});
}
}
backends.push({
value: 'cpu',
label: 'CPU',
devices: cpuCapabilities?.devices,
disabled: false,
});
if (includeDisabled) {
backends.sort((a, b) => {
if (a.disabled === b.disabled) return 0;
return a.disabled ? 1 : -1;
});
}
availableBackendsCache.set(cacheKey, backends);
return backends;
} catch (error) {
logError('Failed to get available backends:', error as Error);
return [{ value: 'cpu', label: 'CPU' }];
}
}

View file

@ -0,0 +1,94 @@
import { logError } from '@/main/modules/logging';
import { readJsonFile, writeJsonFile } from '@/utils/fs';
import { getConfigDir } from '@/utils/path';
import type { FrontendPreference } from '@/types';
import { homedir } from 'os';
import { join } from 'path';
import { PRODUCT_NAME } from '@/constants';
type ConfigValue = string | number | boolean | unknown[] | undefined;
interface AppConfig {
installDir?: string;
currentKoboldBinary?: string;
selectedConfig?: string;
frontendPreference?: FrontendPreference;
[key: string]: ConfigValue;
}
let config: AppConfig = {};
let configPath: string;
async function loadConfig() {
try {
const loadedConfig = await readJsonFile<AppConfig>(configPath);
return loadedConfig || {};
} catch (error) {
logError('Error loading config:', error as Error);
return {};
}
}
async function saveConfig() {
try {
await writeJsonFile(configPath, config);
} catch (error) {
logError('Error saving config:', error as Error);
}
}
export async function initialize() {
configPath = join(getConfigDir());
config = await loadConfig();
}
export function get(key: string) {
return config[key];
}
export async function set(key: string, value: ConfigValue) {
config[key] = value;
await saveConfig();
}
function getDefaultInstallDir() {
const platform = process.platform;
const home = homedir();
switch (platform) {
case 'win32':
return join(home, PRODUCT_NAME);
case 'darwin':
return join(home, 'Applications', PRODUCT_NAME);
default:
return join(home, '.local', 'share', PRODUCT_NAME);
}
}
export function getInstallDir() {
return config.installDir || getDefaultInstallDir();
}
export async function setInstallDir(dir: string) {
config.installDir = dir;
await saveConfig();
}
export function getCurrentKoboldBinary() {
const path = config.currentKoboldBinary;
return path ? path.trim() : path;
}
export async function setCurrentKoboldBinary(binaryPath: string) {
config.currentKoboldBinary = binaryPath;
await saveConfig();
}
export function getSelectedConfig() {
return config.selectedConfig;
}
export async function setSelectedConfig(configName: string) {
config.selectedConfig = configName;
await saveConfig();
}

View file

@ -0,0 +1,472 @@
/* eslint-disable no-comments/disallowComments */
import si from 'systeminformation';
import { shortenDeviceName } from '@/utils/hardware';
import { logError } from '@/main/modules/logging';
import { terminateProcess } from '@/utils/process';
import type {
CPUCapabilities,
GPUCapabilities,
BasicGPUInfo,
GPUMemoryInfo,
} from '@/types/hardware';
import { spawn } from 'child_process';
let cpuCapabilitiesCache: CPUCapabilities | null = null;
let basicGPUInfoCache: BasicGPUInfo | null = null;
let gpuCapabilitiesCache: GPUCapabilities | null = null;
let gpuMemoryInfoCache: GPUMemoryInfo[] | null = null;
export async function detectCPU() {
if (cpuCapabilitiesCache) {
return cpuCapabilitiesCache;
}
try {
const [cpu, flags] = await Promise.all([si.cpu(), si.cpuFlags()]);
const devices: string[] = [];
if (cpu.brand) {
devices.push(shortenDeviceName(cpu.brand));
}
const avx = flags.includes('avx') || flags.includes('AVX');
const avx2 = flags.includes('avx2') || flags.includes('AVX2');
cpuCapabilitiesCache = {
avx,
avx2,
devices,
};
return cpuCapabilitiesCache;
} catch (error) {
logError('CPU detection failed:', error as Error);
const fallbackCapabilities = {
avx: false,
avx2: false,
devices: [],
};
cpuCapabilitiesCache = fallbackCapabilities;
return fallbackCapabilities;
}
}
export async function detectGPU() {
if (basicGPUInfoCache) {
return basicGPUInfoCache;
}
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(shortenDeviceName(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;
}
}
basicGPUInfoCache = {
hasAMD,
hasNVIDIA,
gpuInfo: gpuInfo.length > 0 ? gpuInfo : ['No GPU information available'],
};
return basicGPUInfoCache;
} catch (error) {
logError('GPU detection failed:', error as Error);
const fallbackGPUInfo = {
hasAMD: false,
hasNVIDIA: false,
gpuInfo: ['GPU detection failed'],
};
basicGPUInfoCache = fallbackGPUInfo;
return fallbackGPUInfo;
}
}
export async function detectGPUCapabilities() {
// WARNING: we're not worrying about the users that update their system
// during runtime and not restart. Should we be though?
if (gpuCapabilitiesCache) {
return gpuCapabilitiesCache;
}
const [cuda, rocm, vulkan, clblast] = await Promise.all([
detectCUDA(),
detectROCm(),
detectVulkan(),
detectCLBlast(),
]);
gpuCapabilitiesCache = { cuda, rocm, vulkan, clblast };
return gpuCapabilitiesCache;
}
async function detectCUDA() {
try {
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<{
supported: boolean;
devices: string[];
}>((resolve) => {
nvidia.on('close', (code) => {
if (code === 0 && output.trim()) {
const devices = output
.trim()
.split('\n')
.map((line) => {
const parts = line.split(',');
const rawName = parts[0]?.trim() || 'Unknown NVIDIA GPU';
return shortenDeviceName(rawName);
})
.filter(Boolean);
resolve({
supported: devices.length > 0,
devices,
});
} else {
resolve({ supported: false, devices: [] });
}
});
nvidia.on('error', () => {
resolve({ supported: false, devices: [] });
});
setTimeout(async () => {
await terminateProcess(nvidia);
resolve({ supported: false, devices: [] });
}, 5000);
});
} catch {
return { supported: false, devices: [] };
}
}
async function findRocminfoCommand() {
const platform = await import('process').then((p) => p.platform);
if (platform === 'win32') {
return 'hipInfo';
} else {
return 'rocminfo';
}
}
export async function detectROCm() {
try {
const rocminfoCommand = await findRocminfoCommand();
if (!rocminfoCommand) {
return { supported: false, devices: [] };
}
const isWindows = rocminfoCommand.includes('hipInfo');
const rocminfo = spawn(rocminfoCommand, [], { timeout: 5000 });
let output = '';
rocminfo.stdout.on('data', (data) => {
output += data.toString();
});
return new Promise<{
supported: boolean;
devices: string[];
}>((resolve) => {
// eslint-disable-next-line sonarjs/cognitive-complexity
rocminfo.on('close', (code) => {
if (code === 0 && output.trim()) {
const devices: string[] = [];
if (isWindows) {
const lines = output.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('Name:')) {
const name = trimmedLine.split('Name:')[1]?.trim();
if (
name &&
!name.toLowerCase().includes('cpu') &&
!devices.includes(shortenDeviceName(name))
) {
devices.push(shortenDeviceName(name));
}
}
}
} else {
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) {
let deviceType = '';
const searchRangeLines = 20;
const searchStartIndex = Math.max(0, i - searchRangeLines);
const searchEndIndex = Math.min(
lines.length,
i + searchRangeLines
);
for (
let searchIndex = searchStartIndex;
searchIndex < searchEndIndex;
searchIndex++
) {
if (lines[searchIndex].includes('Device Type:')) {
deviceType =
lines[searchIndex].split('Device Type:')[1]?.trim() ||
'';
break;
}
}
if (deviceType !== 'CPU') {
devices.push(shortenDeviceName(name));
}
}
}
}
}
resolve({
supported: devices.length > 0,
devices,
});
} else {
resolve({ supported: false, devices: [] });
}
});
rocminfo.on('error', () => {
resolve({ supported: false, devices: [] });
});
setTimeout(async () => {
await terminateProcess(rocminfo);
resolve({ supported: false, devices: [] });
}, 5000);
});
} catch {
return { supported: false, devices: [] };
}
}
async function detectVulkan() {
try {
const vulkaninfo = spawn('vulkaninfo', ['--summary'], { timeout: 5000 });
let output = '';
vulkaninfo.stdout.on('data', (data) => {
output += data.toString();
});
return new Promise<{
supported: boolean;
devices: string[];
}>((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') && line.includes('=')) {
const parts = line.split('=');
if (parts.length >= 2) {
const name = parts[1]?.trim();
if (name) {
devices.push(shortenDeviceName(name));
}
}
}
}
resolve({
supported: devices.length > 0,
devices,
});
} else {
resolve({ supported: false, devices: [] });
}
});
vulkaninfo.on('error', () => {
resolve({ supported: false, devices: [] });
});
setTimeout(async () => {
await terminateProcess(vulkaninfo);
resolve({ supported: false, devices: [] });
}, 5000);
});
} catch {
return { supported: false, devices: [] };
}
}
function parseClInfoOutput(output: string) {
const devices: string[] = [];
const lines = output.split('\n');
let currentPlatform = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.includes('Platform Name:')) {
currentPlatform = line.split('Platform Name:')[1]?.trim() || '';
continue;
}
if (line.includes('Device Type:') && line.includes('GPU')) {
const deviceName = findDeviceNameInClInfo(lines, i);
if (deviceName && currentPlatform) {
const deviceLabel = `${shortenDeviceName(deviceName)} (${currentPlatform})`;
devices.push(deviceLabel);
}
}
}
return devices;
}
function findDeviceNameInClInfo(lines: string[], startIndex: number) {
for (
let j = startIndex + 1;
j < Math.min(startIndex + 50, lines.length);
j++
) {
const nextLine = lines[j].trim();
if (nextLine.includes('Board name:')) {
return nextLine.split('Board name:')[1]?.trim() || '';
}
}
for (
let j = startIndex + 1;
j < Math.min(startIndex + 100, lines.length);
j++
) {
const nextLine = lines[j].trim();
if (nextLine.startsWith('Name:')) {
return nextLine.split('Name:')[1]?.trim() || '';
}
}
return '';
}
async function detectCLBlast() {
try {
const clinfo = spawn('clinfo', [], { timeout: 3000 });
let output = '';
clinfo.stdout.on('data', (data) => {
output += data.toString();
});
return new Promise<{
supported: boolean;
devices: string[];
}>((resolve) => {
clinfo.on('close', (code) => {
if (code === 0 && output.trim()) {
const devices = parseClInfoOutput(output);
resolve({
supported: devices.length > 0,
devices,
});
} else {
resolve({ supported: false, devices: [] });
}
});
clinfo.on('error', () => {
resolve({ supported: false, devices: [] });
});
setTimeout(async () => {
await terminateProcess(clinfo);
resolve({ supported: false, devices: [] });
}, 3000);
});
} catch {
return { supported: false, devices: [] };
}
}
export async function detectGPUMemory() {
if (gpuMemoryInfoCache) {
return gpuMemoryInfoCache;
}
const memoryInfo: GPUMemoryInfo[] = [];
try {
const graphics = await si.graphics();
for (const controller of graphics.controllers) {
if (controller.model) {
let vram = controller.vram;
if (!vram || vram === 1) {
vram = null;
}
memoryInfo.push({
deviceName: shortenDeviceName(controller.model),
totalMemoryMB: vram,
});
}
}
gpuMemoryInfoCache = memoryInfo;
} catch (error) {
logError('GPU memory detection failed:', error as Error);
gpuMemoryInfoCache = [];
}
return gpuMemoryInfoCache;
}

View file

@ -0,0 +1,740 @@
import { spawn, ChildProcess } from 'child_process';
import { createWriteStream } from 'fs';
import { join } from 'path';
import {
rm,
readdir,
stat,
unlink,
rename,
mkdir,
chmod,
readFile,
writeFile,
copyFile,
} from 'fs/promises';
import { dialog } from 'electron';
import axios from 'axios';
import { execa } from 'execa';
import { terminateProcess } from '@/utils/process';
import {
getInstallDir,
setInstallDir,
getCurrentKoboldBinary,
setCurrentKoboldBinary,
} from './config';
import { logError } from './logging';
import { sendKoboldOutput, getMainWindow, sendToRenderer } from './window';
import { PRODUCT_NAME, SERVER_READY_SIGNALS } from '@/constants';
import {
KLITE_CSS_OVERRIDE,
KLITE_AUTOSCROLL_PATCHES,
} from '@/constants/patches';
import { pathExists, readJsonFile, writeJsonFile } from '@/utils/fs';
import { stripAssetExtensions } from '@/utils/version';
import { parseKoboldConfig } from '@/utils/kobold';
import type {
GitHubAsset,
InstalledVersion,
KoboldConfig,
} from '@/types/electron';
import { isDevelopment } from '@/utils/environment';
import type { FrontendPreference } from '@/types';
let koboldProcess: ChildProcess | null = null;
async function removeDirectoryWithRetry(
dirPath: string,
maxRetries = 3,
delayMs = 1000
): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await rm(dirPath, { recursive: true, force: true });
return;
} catch (error) {
const isLastAttempt = attempt === maxRetries;
const isPermissionError =
(error as Error & { code?: string }).code === 'EPERM';
if (isLastAttempt) {
throw error;
}
if (isPermissionError && process.platform === 'win32') {
sendKoboldOutput(
`Attempt ${attempt}/${maxRetries} failed (file in use), retrying in ${delayMs}ms...`
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
delayMs *= 1.5;
} else {
throw error;
}
}
}
}
async function handleExistingDirectory(
unpackedDirPath: string,
isUpdate: boolean
): Promise<void> {
if (!isUpdate || !(await pathExists(unpackedDirPath))) {
return;
}
try {
if (koboldProcess && !koboldProcess.killed) {
sendKoboldOutput('Stopping process before update...');
await cleanup();
await new Promise((resolve) => setTimeout(resolve, 2000));
}
await removeDirectoryWithRetry(unpackedDirPath);
} catch (error) {
logError('Failed to remove existing directory for update:', error as Error);
throw new Error(
`Cannot update: Failed to remove existing installation. ` +
`Please ensure the server is stopped and try again. ` +
`Error: ${(error as Error).message}`
);
}
}
async function downloadFile(
asset: GitHubAsset,
tempPackedFilePath: string
): Promise<void> {
const writer = createWriteStream(tempPackedFilePath);
let downloadedBytes = 0;
const response = await axios({
method: 'GET',
url: asset.browser_download_url,
responseType: 'stream',
timeout: 30000,
maxRedirects: 5,
});
const totalBytes = asset.size;
response.data.on('data', (chunk: Buffer) => {
downloadedBytes += chunk.length;
if (totalBytes > 0) {
const progress = (downloadedBytes / totalBytes) * 100;
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('download-progress', progress);
}
}
});
response.data.pipe(writer);
await new Promise<void>((resolve, reject) => {
writer.on('finish', async () => {
if (process.platform !== 'win32') {
try {
await chmod(tempPackedFilePath, 0o755);
} catch (error) {
logError('Failed to make binary executable:', error as Error);
}
}
resolve();
});
writer.on('error', reject);
response.data.on('error', reject);
});
}
async function setupLauncher(
tempPackedFilePath: string,
unpackedDirPath: string
): Promise<string> {
let launcherPath = await getLauncherPath(unpackedDirPath);
if (!launcherPath || !(await pathExists(launcherPath))) {
const expectedLauncherName =
process.platform === 'win32'
? 'koboldcpp-launcher.exe'
: 'koboldcpp-launcher';
const newLauncherPath = join(unpackedDirPath, expectedLauncherName);
if (await pathExists(tempPackedFilePath)) {
try {
await rename(tempPackedFilePath, newLauncherPath);
launcherPath = newLauncherPath;
} catch (error) {
logError('Failed to rename binary as launcher:', error as Error);
}
}
} else {
try {
await unlink(tempPackedFilePath);
} catch (error) {
logError('Failed to cleanup packed file:', error as Error);
}
}
if (!launcherPath || !(await pathExists(launcherPath))) {
throw new Error('Failed to find or create launcher');
}
return launcherPath;
}
export async function downloadRelease(asset: GitHubAsset) {
const tempPackedFilePath = join(getInstallDir(), `${asset.name}.packed`);
const baseFilename = stripAssetExtensions(asset.name);
const folderName = asset.version
? `${baseFilename}-${asset.version}`
: baseFilename;
const unpackedDirPath = join(getInstallDir(), folderName);
try {
await handleExistingDirectory(unpackedDirPath, Boolean(asset.isUpdate));
await downloadFile(asset, tempPackedFilePath);
await mkdir(unpackedDirPath, { recursive: true });
await unpackKoboldCpp(tempPackedFilePath, unpackedDirPath);
const launcherPath = await setupLauncher(
tempPackedFilePath,
unpackedDirPath
);
const currentBinary = getCurrentKoboldBinary();
if (!currentBinary || (asset.isUpdate && asset.wasCurrentBinary)) {
await setCurrentKoboldBinary(launcherPath);
}
sendToRenderer('versions-updated');
return launcherPath;
} catch (error) {
logError('Failed to download or unpack binary:', error as Error);
throw new Error(
`Failed to download or unpack binary: ${(error as Error).message}`
);
}
}
async function unpackKoboldCpp(
packedPath: string,
unpackDir: string
): Promise<void> {
try {
await execa(packedPath, ['--unpack', unpackDir], {
timeout: 60000,
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch (error) {
const execaError = error as {
stderr?: string;
stdout?: string;
message: string;
};
const errorMessage =
execaError.stderr || execaError.stdout || execaError.message;
throw new Error(`Unpack failed: ${errorMessage}`);
}
}
async function patchKliteEmbd(unpackedDir: string) {
try {
const possiblePaths = [
join(unpackedDir, '_internal', 'klite.embd'),
join(unpackedDir, 'klite.embd'),
];
let kliteEmbdPath: string | null = null;
for (const path of possiblePaths) {
if (await pathExists(path)) {
kliteEmbdPath = path;
break;
}
}
if (!kliteEmbdPath) {
return;
}
const content = await readFile(kliteEmbdPath, 'utf8');
if (content.includes('</head>')) {
let patchedContent = content;
if (content.includes('gerbil-css-override')) {
patchedContent = patchedContent.replace(
/<style id="gerbil-css-override">[\s\S]*?<\/style>\s*/g,
''
);
}
if (content.includes('gerbil-autoscroll-patches')) {
patchedContent = patchedContent.replace(
/<script id="gerbil-autoscroll-patches">[\s\S]*?<\/script>\s*/g,
''
);
}
patchedContent = patchedContent.replace(
'</head>',
`${KLITE_CSS_OVERRIDE}\n${KLITE_AUTOSCROLL_PATCHES}\n</head>`
);
await writeFile(kliteEmbdPath, patchedContent, 'utf8');
}
} catch (error) {
logError('Failed to patch klite.embd:', error as Error);
}
}
async function patchKcppSduiEmbd(unpackedDir: string) {
try {
const possiblePaths = [
join(unpackedDir, '_internal', 'kcpp_sdui.embd'),
join(unpackedDir, 'kcpp_sdui.embd'),
];
let sourceAssetPath;
if (isDevelopment) {
sourceAssetPath = join(__dirname, '../../assets', 'kcpp_sdui.embd');
} else {
sourceAssetPath = join(process.resourcesPath, 'assets', 'kcpp_sdui.embd');
}
for (const targetPath of possiblePaths) {
if (await pathExists(targetPath)) {
await copyFile(sourceAssetPath, targetPath);
break;
}
}
} catch (error) {
logError('Failed to patch kcpp_sdui.embd:', error as Error);
}
}
async function getLauncherPath(unpackedDir: string) {
const extensions = process.platform === 'win32' ? ['.exe', ''] : ['', '.exe'];
for (const ext of extensions) {
const launcherPath = join(unpackedDir, `koboldcpp-launcher${ext}`);
if (await pathExists(launcherPath)) {
return launcherPath;
}
}
return null;
}
export async function getInstalledVersions() {
try {
const installDir = getInstallDir();
if (!(await pathExists(installDir))) {
return [];
}
const items = await readdir(installDir);
const launchers: { path: string; filename: string; size: number }[] = [];
for (const item of items) {
const itemPath = join(installDir, item);
const stats = await stat(itemPath);
if (stats.isDirectory()) {
const launcherPath = await getLauncherPath(itemPath);
if (launcherPath && (await pathExists(launcherPath))) {
const launcherStats = await stat(launcherPath);
const launcherFilename = launcherPath.split(/[/\\]/).pop() || '';
launchers.push({
path: launcherPath,
filename: launcherFilename,
size: launcherStats.size,
});
}
}
}
const versionPromises = launchers.map(async (launcher) => {
try {
const detectedVersion = await getVersionFromBinary(launcher.path);
const version = detectedVersion || 'unknown';
return {
version,
path: launcher.path,
filename: launcher.filename,
size: launcher.size,
} as InstalledVersion;
} catch (error) {
logError(
`Could not detect version for ${launcher.filename}:`,
error as Error
);
return null;
}
});
const results = await Promise.all(versionPromises);
return results.filter(
(version): version is InstalledVersion => version !== null
);
} catch (error) {
logError('Error scanning install directory:', error as Error);
return [];
}
}
export async function getConfigFiles() {
const configFiles: { name: string; path: string; size: number }[] = [];
try {
const installDir = getInstallDir();
if (await pathExists(installDir)) {
const files = await readdir(installDir);
for (const file of files) {
const filePath = join(installDir, file);
const stats = await stat(filePath);
if (
stats.isFile() &&
(file.endsWith('.kcpps') ||
file.endsWith('.kcppt') ||
file.endsWith('.json'))
) {
configFiles.push({
name: file,
path: filePath,
size: stats.size,
});
}
}
}
} catch (error) {
logError('Error scanning for config files:', error as Error);
}
return configFiles.sort((a, b) => a.name.localeCompare(b.name));
}
export async function parseConfigFile(filePath: string) {
try {
if (!(await pathExists(filePath))) {
return null;
}
const config = await readJsonFile(filePath);
return config as KoboldConfig;
} catch (error) {
logError('Error parsing config file:', error as Error);
return null;
}
}
export async function saveConfigFile(
configFileName: string,
configData: KoboldConfig
): Promise<boolean> {
try {
const installDir = getInstallDir();
const configPath = join(installDir, configFileName);
await writeJsonFile(configPath, configData);
return true;
} catch (error) {
logError('Error saving config file:', error as Error);
return false;
}
}
export async function selectModelFile(title = 'Select Model File') {
try {
const mainWindow = getMainWindow();
if (!mainWindow) {
return null;
}
const result = await dialog.showOpenDialog(mainWindow, {
title,
filters: [
{
name: 'Model Files',
extensions: ['gguf', 'safetensors'],
},
{ name: 'All Files', extensions: ['*'] },
],
properties: ['openFile'],
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
return result.filePaths[0];
} catch (error) {
logError('Error selecting model file:', error as Error);
return null;
}
}
export async function getCurrentVersion() {
const currentBinaryPath = getCurrentKoboldBinary();
const versions = await getInstalledVersions();
if (currentBinaryPath && (await pathExists(currentBinaryPath))) {
const currentVersion = versions.find((v) => v.path === currentBinaryPath);
if (currentVersion) {
return currentVersion;
}
}
const firstVersion = versions[0];
if (firstVersion) {
await setCurrentKoboldBinary(firstVersion.path);
return firstVersion;
}
if (currentBinaryPath) {
await setCurrentKoboldBinary('');
}
return null;
}
export async function getCurrentBinaryInfo() {
const currentVersion = await getCurrentVersion();
if (currentVersion) {
const pathParts = currentVersion.path.split(/[/\\]/);
const filename = pathParts[pathParts.length - 2] || currentVersion.filename;
return {
path: currentVersion.path,
filename,
};
}
return null;
}
export async function setCurrentVersion(binaryPath: string) {
if (await pathExists(binaryPath)) {
await setCurrentKoboldBinary(binaryPath);
sendToRenderer('versions-updated');
return true;
}
return false;
}
async function getVersionFromBinary(launcherPath: string) {
try {
if (!(await pathExists(launcherPath))) {
return null;
}
const folderName = launcherPath.split(/[/\\]/).slice(-2, -1)[0];
if (folderName) {
const versionMatch = folderName.match(
/-(\d+\.\d+(?:\.\d+)?(?:\.[a-zA-Z0-9]+)*(?:-[a-zA-Z0-9]+)*)$/
);
if (versionMatch) {
return versionMatch[1];
}
}
const result = await execa(launcherPath, ['--version'], {
timeout: 30000,
stdio: ['ignore', 'pipe', 'pipe'],
});
const allOutput = (result.stdout + result.stderr).trim();
if (/^\d+\.\d+/.test(allOutput)) {
const versionParts = allOutput.split(/\s+/)[0];
if (versionParts && /^\d+\.\d+/.test(versionParts)) {
return versionParts;
}
}
const lines = allOutput.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
if (/^\d+\.\d+/.test(trimmedLine)) {
const versionPart = trimmedLine.split(/\s+/)[0];
if (versionPart) {
return versionPart;
}
}
}
return null;
} catch {
return null;
}
}
export async function selectInstallDirectory() {
const result = await dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
title: `Select the ${PRODUCT_NAME} Installation Directory`,
defaultPath: getInstallDir(),
buttonLabel: 'Select Directory',
});
if (!result.canceled && result.filePaths.length > 0) {
await setInstallDir(result.filePaths[0]);
sendToRenderer('install-dir-changed', result.filePaths[0]);
return result.filePaths[0];
}
return null;
}
export async function launchKoboldCpp(
args: string[] = [],
frontendPreference: FrontendPreference = 'koboldcpp'
) {
try {
if (koboldProcess) {
await stopKoboldCpp();
}
const currentVersion = await getCurrentVersion();
if (!currentVersion || !(await pathExists(currentVersion.path))) {
const rawPath = getCurrentKoboldBinary();
const error = currentVersion
? `Binary file does not exist at path: ${currentVersion.path}`
: 'No version configured';
logError(
`Launch failed: ${error}. Raw config path: "${rawPath}", Current version: ${JSON.stringify(currentVersion)}`
);
return {
success: false,
error,
};
}
const binaryDir = currentVersion.path.split(/[/\\]/).slice(0, -1).join('/');
const { isImageMode } = parseKoboldConfig(args);
if (frontendPreference === 'koboldcpp') {
if (isImageMode) {
await patchKcppSduiEmbd(binaryDir);
} else {
await patchKliteEmbd(binaryDir);
}
} else if (frontendPreference === 'openwebui' && isImageMode) {
await patchKcppSduiEmbd(binaryDir);
}
const finalArgs = [...args];
const child = spawn(currentVersion.path, finalArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
detached: false,
});
koboldProcess = child;
const commandLine = `$ ${currentVersion.path} ${finalArgs.join(' ')}`;
sendKoboldOutput(commandLine);
let readyResolve:
| ((value: { success: boolean; pid?: number; error?: string }) => void)
| null = null;
let _readyReject: ((error: Error) => void) | null = null;
let isReady = false;
const readyPromise = new Promise<{
success: boolean;
pid?: number;
error?: string;
}>((resolve, reject) => {
readyResolve = resolve;
_readyReject = reject;
});
child.stdout?.on('data', (data) => {
const output = data.toString();
sendKoboldOutput(output, true);
if (!isReady && output.includes(SERVER_READY_SIGNALS.KOBOLDCPP)) {
isReady = true;
readyResolve?.({ success: true, pid: child.pid });
}
});
child.stderr?.on('data', (data) => {
const output = data.toString();
sendKoboldOutput(output, true);
if (!isReady && output.includes(SERVER_READY_SIGNALS.KOBOLDCPP)) {
isReady = true;
readyResolve?.({ success: true, pid: child.pid });
}
});
child.on('exit', (code, signal) => {
const displayMessage = signal
? `\n[INFO] Process terminated with signal ${signal}`
: code === 0
? `\n[INFO] Process exited successfully`
: code && (code > 1 || code < 0)
? `\n[ERROR] Process exited with code ${code}`
: `\n[INFO] Process exited with code ${code}`;
sendKoboldOutput(displayMessage);
koboldProcess = null;
if (!isReady) {
_readyReject?.(
new Error(
`Process exited before ready signal (code: ${code}, signal: ${signal})`
)
);
}
});
child.on('error', (error) => {
logError(`Process error: ${error.message}`, error);
sendKoboldOutput(`\n[ERROR] Process error: ${error.message}\n`);
koboldProcess = null;
if (!isReady) {
_readyReject?.(error);
}
});
return readyPromise;
} catch (error) {
const errorMessage = (error as Error).message;
logError(`Failed to launch: ${errorMessage}`, error as Error);
return { success: false, error: errorMessage };
}
}
export async function stopKoboldCpp() {
if (koboldProcess) {
await terminateProcess(koboldProcess, {
timeoutMs: 5000,
logError: (message, error) => logError(message, error),
});
koboldProcess = null;
}
}
export async function cleanup() {
if (koboldProcess) {
await terminateProcess(koboldProcess, {
logError: (message, error) => logError(message, error),
});
koboldProcess = null;
}
}

View file

@ -0,0 +1,95 @@
import { app } from 'electron';
import { join } from 'path';
import { createLogger, format, type Logger } from 'winston';
import DailyRotateFile from 'winston-daily-rotate-file';
import { isDevelopment } from '@/utils/environment';
let logger: Logger | null = null;
let isInitialized = false;
export const initializeLogger = () => {
if (isInitialized) return;
const logsDir = join(app.getPath('userData'), 'logs');
logger = createLogger({
level: isDevelopment ? 'debug' : 'info',
format: format.combine(
format.timestamp(),
format.printf(({ timestamp, level, message, error }) => {
const processInfo = `[${process.type || 'unknown'}:${process.pid}]`;
let logEntry = `${timestamp} ${processInfo} [${level.toUpperCase()}] ${message}`;
if (error && error instanceof Error) {
logEntry += `\n Error: ${error.message}`;
if (error.stack) {
logEntry += `\n Stack: ${error.stack}`;
}
}
return logEntry;
})
),
transports: [
new DailyRotateFile({
filename: join(logsDir, 'gerbil-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
maxSize: '10m',
maxFiles: '5d',
createSymlink: true,
symlinkName: 'gerbil.log',
}),
],
});
setupGlobalErrorHandlers();
isInitialized = true;
};
const ensureInitialized = () => {
if (!isInitialized) {
initializeLogger();
}
};
export const logError = (message: string, error?: Error) => {
ensureInitialized();
logger!.error(message, { error });
flushLogs();
};
export const flushLogs = () => {
ensureInitialized();
const fileTransport = logger!.transports.find(
(t) => t.constructor.name === 'DailyRotateFile'
);
if (
fileTransport &&
'flush' in fileTransport &&
typeof fileTransport.flush === 'function'
) {
(fileTransport as { flush: () => void }).flush();
}
};
export const logDebug = (message: string) => {
// eslint-disable-next-line no-console
console.log(message);
};
const setupGlobalErrorHandlers = () => {
process.on('uncaughtException', (error) => {
logError('Uncaught Exception:', error);
});
process.on('unhandledRejection', (reason, _promise) => {
const message = `Unhandled Promise Rejection`;
const error = reason instanceof Error ? reason : new Error(String(reason));
logError(message, error);
});
};
export const getLogFilePath = () =>
join(app.getPath('userData'), 'logs', 'gerbil.log');
export const getLogsDirectory = () => join(app.getPath('userData'), 'logs');

View file

@ -0,0 +1,242 @@
import { spawn } from 'child_process';
import type { ChildProcess } from 'child_process';
import { join } from 'path';
import { homedir } from 'os';
import { access } from 'fs/promises';
import { logError } from './logging';
import { sendKoboldOutput } from './window';
import { getInstallDir } from './config';
import { OPENWEBUI, SERVER_READY_SIGNALS } from '@/constants';
import { terminateProcess } from '@/utils/process';
import { parseKoboldConfig } from '@/utils/kobold';
let openWebUIProcess: ChildProcess | null = null;
const OPENWEBUI_BASE_ARGS = ['--python', '3.11', 'open-webui@latest', 'serve'];
process.on('SIGINT', () => {
void cleanup();
});
process.on('SIGTERM', () => {
void cleanup();
});
async function getUvEnvironment() {
const env = { ...process.env };
const uvPaths = [
join(homedir(), '.cargo', 'bin'),
join(homedir(), '.local', 'bin'),
];
const existingPaths: string[] = [];
for (const path of uvPaths) {
try {
await access(path);
existingPaths.push(path);
} catch {
void 0;
}
}
if (existingPaths.length > 0) {
const pathSeparator = process.platform === 'win32' ? ';' : ':';
env.PATH = `${existingPaths.join(pathSeparator)}${pathSeparator}${env.PATH}`;
}
if (process.platform === 'win32') {
env.PYTHONIOENCODING = 'utf-8';
env.PYTHONLEGACYWINDOWSSTDIO = '1';
env.PYTHONUTF8 = '1';
env.CHCP = '65001';
}
return env;
}
export async function isUvAvailable() {
try {
const env = await getUvEnvironment();
const testProcess = spawn('uv', ['--version'], { stdio: 'pipe', env });
return new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => {
testProcess.kill();
resolve(false);
}, 10000);
testProcess.on('exit', (code) => {
clearTimeout(timeout);
resolve(code === 0);
});
testProcess.on('error', () => {
clearTimeout(timeout);
resolve(false);
});
});
} catch {
return false;
}
}
async function createUvProcess(args: string[], env?: Record<string, string>) {
const uvEnv = await getUvEnvironment();
const mergedEnv = { ...uvEnv, ...env };
if (process.platform === 'win32') {
mergedEnv.PYTHONIOENCODING = 'utf-8';
mergedEnv.PYTHONUTF8 = '1';
}
return spawn('uvx', args, {
stdio: ['pipe', 'pipe', 'pipe'],
detached: false,
env: mergedEnv,
});
}
async function waitForOpenWebUIToStart() {
sendKoboldOutput('Waiting for Open WebUI to start...');
return new Promise<void>((resolve, reject) => {
const checkForOutput = (data: Buffer) => {
const output = data.toString();
if (output.includes(SERVER_READY_SIGNALS.OPENWEBUI)) {
sendKoboldOutput('Open WebUI is now running!');
resolve();
if (openWebUIProcess?.stdout) {
openWebUIProcess.stdout.removeListener('data', checkForOutput);
}
}
};
if (openWebUIProcess?.stdout) {
openWebUIProcess.stdout.on('data', checkForOutput);
} else {
reject(new Error('Open WebUI process stdout not available'));
}
});
}
export async function startFrontend(args: string[]) {
try {
const config = {
name: 'openwebui',
port: OPENWEBUI.PORT,
};
const {
host: koboldHost,
port: koboldPort,
isImageMode,
} = parseKoboldConfig(args);
if (isImageMode) {
return;
}
await stopFrontend();
sendKoboldOutput(
`Preparing Open WebUI to connect at ${koboldHost}:${koboldPort}...`
);
sendKoboldOutput(
`Starting ${config.name} frontend on port ${config.port}...`
);
const koboldUrl = `http://${koboldHost}:${koboldPort}`;
const openWebUIArgs = [
...OPENWEBUI_BASE_ARGS,
'--port',
config.port.toString(),
];
sendKoboldOutput('Starting Open WebUI with uv...');
const installDir = getInstallDir();
const openWebUIDataDir = join(installDir, 'openwebui-data');
openWebUIProcess = await createUvProcess(openWebUIArgs, {
OPENAI_API_BASE_URL: `${koboldUrl}/v1`,
OPENAI_API_KEY: 'kobold',
DATA_DIR: openWebUIDataDir,
DISABLE_SIGNUP: 'true',
});
if (openWebUIProcess.stdout) {
openWebUIProcess.stdout.on('data', (data: Buffer) => {
try {
const output = data.toString('utf8');
sendKoboldOutput(output, true);
} catch (error) {
logError('Error processing stdout data:', error as Error);
}
});
}
if (openWebUIProcess.stderr) {
openWebUIProcess.stderr.on('data', (data: Buffer) => {
try {
const output = data.toString('utf8');
sendKoboldOutput(output, true);
} catch (error) {
logError('Error processing stderr data:', error as Error);
}
});
}
openWebUIProcess.on(
'exit',
(code: number | null, signal: string | null) => {
const message = signal
? `Open WebUI terminated with signal ${signal}`
: `Open WebUI exited with code ${code}`;
sendKoboldOutput(message);
openWebUIProcess = null;
}
);
openWebUIProcess.on('error', (error) => {
logError('Open WebUI process error:', error);
sendKoboldOutput(`Open WebUI error: ${error.message}`);
openWebUIProcess = null;
});
await waitForOpenWebUIToStart();
sendKoboldOutput(`Open WebUI is ready and auto-configured!`);
sendKoboldOutput(`Access Open WebUI at: http://localhost:${config.port}`);
sendKoboldOutput(`Connection: ${koboldUrl}/v1 (auto-configured)`);
} catch (error) {
logError('Failed to start Open WebUI frontend:', error as Error);
sendKoboldOutput(`Failed to start Open WebUI: ${(error as Error).message}`);
openWebUIProcess = null;
throw error;
}
}
export async function stopFrontend() {
if (openWebUIProcess) {
sendKoboldOutput('Stopping Open WebUI...');
try {
await terminateProcess(openWebUIProcess, {
logError: (message, error) => logError(message, error),
});
sendKoboldOutput('Open WebUI stopped');
} catch (error) {
logError('Error stopping Open WebUI:', error as Error);
}
openWebUIProcess = null;
}
}
export async function cleanup() {
await stopFrontend();
}

View file

@ -0,0 +1,522 @@
import { spawn } from 'child_process';
import { createServer, request, type Server } from 'http';
import { homedir } from 'os';
import { join } from 'path';
import { access, readdir } from 'fs/promises';
import type { ChildProcess } from 'child_process';
import { logError } from './logging';
import { sendKoboldOutput } from './window';
import { SILLYTAVERN, SERVER_READY_SIGNALS } from '@/constants';
import { terminateProcess } from '@/utils/process';
import { pathExists, readJsonFile, writeJsonFile } from '@/utils/fs';
import { parseKoboldConfig } from '@/utils/kobold';
let sillyTavernProcess: ChildProcess | null = null;
let proxyServer: Server | null = null;
let detectedDataRoot: string | null = null;
const SILLYTAVERN_BASE_ARGS = [
'sillytavern',
'--global',
'--listen',
'--browserLaunchEnabled',
'false',
'--disableCsrf',
];
process.on('SIGINT', () => {
void cleanup();
});
process.on('SIGTERM', () => {
void cleanup();
});
function getFallbackDataRoot() {
const platform = process.platform;
const home = homedir();
switch (platform) {
case 'win32':
return join(home, 'AppData', 'Local', 'SillyTavern', 'Data', 'data');
case 'darwin':
return join(
home,
'Library',
'Application Support',
'SillyTavern',
'data'
);
case 'linux':
default:
return join(home, '.local', 'share', 'SillyTavern', 'data');
}
}
async function getSillyTavernDataRoot() {
if (detectedDataRoot) {
return detectedDataRoot;
}
const fallback = getFallbackDataRoot();
detectedDataRoot = fallback;
return fallback;
}
async function getSillyTavernSettingsPath() {
const dataRoot = await getSillyTavernDataRoot();
return join(dataRoot, 'default-user', 'settings.json');
}
async function tryAddPathToEnv(
env: Record<string, string | undefined>,
path: string
) {
const pathSeparator = process.platform === 'win32' ? ';' : ':';
if (!env.PATH?.includes(path)) {
env.PATH = `${path}${pathSeparator}${env.PATH}`;
return true;
}
return false;
}
async function tryVersionManagerPath(
basePath: string,
env: Record<string, string | undefined>
) {
try {
await access(basePath);
const versions = await readdir(basePath);
if (versions.length > 0) {
const latestVersion = versions.sort().pop();
if (latestVersion) {
const binSubPath = basePath.includes('fnm')
? join('installation', 'bin')
: 'bin';
const nodeBinPath = join(basePath, latestVersion, binSubPath);
try {
await access(nodeBinPath);
return tryAddPathToEnv(env, nodeBinPath);
} catch {
return false;
}
}
}
} catch {
return false;
}
return false;
}
async function getNodeEnvironment() {
const env = { ...process.env };
if (process.platform === 'win32') {
return env;
}
const versionManagerPaths = [
join(homedir(), '.local', 'share', 'fnm', 'node-versions'),
join(homedir(), '.nvm', 'versions', 'node'),
join(homedir(), '.volta', 'tools', 'image', 'node'),
join(homedir(), '.asdf', 'installs', 'nodejs'),
];
const systemPaths: string[] = [];
if (process.platform === 'darwin') {
systemPaths.push('/opt/homebrew/bin', '/usr/local/bin');
}
for (const systemPath of systemPaths) {
try {
await access(systemPath);
await tryAddPathToEnv(env, systemPath);
return env;
} catch {
continue;
}
}
for (const versionPath of versionManagerPaths) {
if (await tryVersionManagerPath(versionPath, env)) {
return env;
}
}
return env;
}
export async function isNpxAvailable() {
try {
const env = await getNodeEnvironment();
const testProcess = spawn('npx', ['--version'], {
stdio: 'pipe',
env,
shell: process.platform === 'win32',
});
return new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => {
testProcess.kill();
resolve(false);
}, 5000);
testProcess.on('exit', (code) => {
clearTimeout(timeout);
resolve(code === 0);
});
testProcess.on('error', () => {
clearTimeout(timeout);
resolve(false);
});
});
} catch {
return false;
}
}
async function createNpxProcess(args: string[]) {
const env = await getNodeEnvironment();
return spawn('npx', args, {
stdio: ['pipe', 'pipe', 'pipe'],
detached: false,
env,
shell: process.platform === 'win32',
});
}
async function ensureSillyTavernSettings() {
const settingsPath = await getSillyTavernSettingsPath();
if (await pathExists(settingsPath)) {
sendKoboldOutput(`SillyTavern settings found at ${settingsPath}`);
return;
}
sendKoboldOutput(
'SillyTavern settings not found, starting SillyTavern briefly to generate config...'
);
const initProcess = await createNpxProcess(SILLYTAVERN_BASE_ARGS);
return new Promise<void>((resolve, reject) => {
let hasResolved = false;
initProcess.on('exit', (code: number | null, signal: string | null) => {
sendKoboldOutput(
signal
? `SillyTavern init process terminated with signal ${signal}`
: `SillyTavern init process exited with code ${code}`
);
if (!hasResolved) {
hasResolved = true;
if (code !== 0) {
const errorMsg =
code === 4294963214
? 'SillyTavern failed to install due to EBUSY error (resource busy or locked). This is a critical error.'
: `SillyTavern initialization failed with exit code ${code}`;
logError('SillyTavern initialization failed:', new Error(errorMsg));
sendKoboldOutput(`CRITICAL ERROR: ${errorMsg}`);
reject(new Error(errorMsg));
} else {
sendKoboldOutput('SillyTavern settings should now be generated');
resolve();
}
}
});
initProcess.on('error', (error) => {
if (!hasResolved) {
hasResolved = true;
logError('Failed to initialize SillyTavern settings:', error);
sendKoboldOutput(`SillyTavern initialization error: ${error.message}`);
reject(error);
}
});
if (initProcess.stdout) {
initProcess.stdout.on('data', (data: Buffer) => {
const output = data.toString();
if (output.includes(SERVER_READY_SIGNALS.SILLYTAVERN)) {
setTimeout(async () => {
if (!initProcess.killed && !hasResolved) {
hasResolved = true;
await terminateProcess(initProcess, {
logError: (message, error) => logError(message, error),
});
sendKoboldOutput('SillyTavern settings should now be generated');
resolve();
}
}, 2000);
}
});
}
if (initProcess.stderr) {
initProcess.stderr.on('data', (data: Buffer) => {
sendKoboldOutput(data.toString().trim());
});
}
});
}
async function setupSillyTavernConfig(
koboldHost: string,
koboldPort: number,
isImageMode: boolean
) {
try {
const configPath = await getSillyTavernSettingsPath();
let settings: Record<string, unknown> = {};
if (await pathExists(configPath)) {
try {
const existingSettings =
await readJsonFile<Record<string, unknown>>(configPath);
if (existingSettings) {
settings = existingSettings;
sendKoboldOutput(`Loaded existing SillyTavern settings`);
}
} catch {
sendKoboldOutput(`Could not read existing settings, creating new ones`);
}
}
const koboldUrl = `http://${koboldHost}:${koboldPort}`;
if (!settings.power_user) settings.power_user = {};
const powerUser = settings.power_user as Record<string, unknown>;
powerUser.auto_connect = true;
if (isImageMode) {
sendKoboldOutput(
`Image generation mode detected. Please configure SillyTavern manually:\n` +
`1. Open SillyTavern and navigate to Settings (top-right gear icon)\n` +
`2. Go to 'Extensions' tab and enable 'Image Generation'\n` +
`3. In Image Generation settings, set Source to 'Stable Diffusion WebUI (AUTOMATIC1111)'\n` +
`4. Set API URL to: ${koboldUrl}\n` +
`5. Click 'Connect' to test the connection`
);
}
if (!settings.textgenerationwebui_settings)
settings.textgenerationwebui_settings = {};
const textgenSettings = settings.textgenerationwebui_settings as Record<
string,
unknown
>;
if (!textgenSettings.server_urls) textgenSettings.server_urls = {};
const serverUrls = textgenSettings.server_urls as Record<string, unknown>;
serverUrls.koboldcpp = koboldUrl;
settings.main_api = 'textgenerationwebui';
textgenSettings.type = 'koboldcpp';
sendKoboldOutput(
`Configured SillyTavern for text generation at ${koboldUrl}`
);
await writeJsonFile(configPath, settings);
sendKoboldOutput(`SillyTavern configuration updated successfully!`);
} catch (error) {
logError('Failed to setup SillyTavern config:', error as Error);
sendKoboldOutput(
`Failed to configure SillyTavern: ${error instanceof Error ? error.message : String(error)}`
);
}
}
async function waitForSillyTavernToStart(_port: number) {
sendKoboldOutput('Waiting for SillyTavern to start...');
return new Promise<void>((resolve, reject) => {
const checkForOutput = (data: Buffer) => {
if (data.toString().includes(SERVER_READY_SIGNALS.SILLYTAVERN)) {
sendKoboldOutput('SillyTavern is now running!');
resolve();
if (sillyTavernProcess?.stdout) {
sillyTavernProcess.stdout.removeListener('data', checkForOutput);
}
}
};
if (sillyTavernProcess?.stdout) {
sillyTavernProcess.stdout.on('data', checkForOutput);
} else {
reject(new Error('SillyTavern process stdout not available'));
}
});
}
function createProxyServer(targetPort: number, proxyPort: number) {
proxyServer = createServer((req, res) => {
const options = {
hostname: 'localhost',
port: targetPort,
path: req.url,
method: req.method,
headers: req.headers,
};
const proxyReq = request(options, (proxyRes) => {
const headers = { ...proxyRes.headers };
delete headers['x-frame-options'];
res.writeHead(proxyRes.statusCode || 200, headers);
proxyRes.pipe(res);
});
proxyReq.on('error', (err) => {
logError('Proxy request error:', err);
res.writeHead(500);
res.end('Proxy error');
});
req.pipe(proxyReq);
});
proxyServer.listen(proxyPort, () => {
sendKoboldOutput(
`Proxy server started on port ${proxyPort}, forwarding to SillyTavern on port ${targetPort}`
);
});
proxyServer.on('error', (err) => {
logError('Proxy server error:', err);
});
}
export async function startFrontend(args: string[]) {
try {
const config = {
name: 'sillytavern',
port: SILLYTAVERN.PORT,
proxyPort: SILLYTAVERN.PROXY_PORT,
};
const {
host: koboldHost,
port: koboldPort,
isImageMode,
} = parseKoboldConfig(args);
await stopFrontend();
sendKoboldOutput(
`Preparing SillyTavern to connect at ${koboldHost}:${koboldPort}...`
);
await ensureSillyTavernSettings();
await setupSillyTavernConfig(koboldHost, koboldPort, isImageMode);
sendKoboldOutput(
`Starting ${config.name} frontend on port ${config.port}...`
);
const sillyTavernArgs = [
...SILLYTAVERN_BASE_ARGS,
'--port',
config.port.toString(),
];
sendKoboldOutput('Final port check before starting SillyTavern...');
sillyTavernProcess = await createNpxProcess(sillyTavernArgs);
if (sillyTavernProcess.stdout) {
sillyTavernProcess.stdout.on('data', (data: Buffer) => {
sendKoboldOutput(data.toString(), true);
});
}
if (sillyTavernProcess.stderr) {
sillyTavernProcess.stderr.on('data', (data: Buffer) => {
sendKoboldOutput(data.toString(), true);
});
}
sillyTavernProcess.on(
'exit',
(code: number | null, signal: string | null) => {
const message = signal
? `SillyTavern terminated with signal ${signal}`
: `SillyTavern exited with code ${code}`;
sendKoboldOutput(message);
sillyTavernProcess = null;
}
);
sillyTavernProcess.on('error', (error) => {
logError('SillyTavern process error:', error);
sendKoboldOutput(`SillyTavern error: ${error.message}`);
sillyTavernProcess = null;
});
await waitForSillyTavernToStart(config.port);
createProxyServer(config.port, config.proxyPort);
} catch (error) {
logError(
`Failed to start SillyTavern: ${error instanceof Error ? error.message : String(error)}`,
error as Error
);
throw error;
}
}
export async function stopFrontend() {
const promises: Promise<void>[] = [];
if (sillyTavernProcess) {
promises.push(
terminateProcess(sillyTavernProcess, {
logError: (message, error) => logError(message, error),
}).then(() => {
sillyTavernProcess = null;
})
);
}
if (proxyServer) {
promises.push(
new Promise((resolve) => {
proxyServer?.close(() => {
proxyServer = null;
resolve();
});
})
);
}
await Promise.all(promises);
}
export async function cleanup() {
if (sillyTavernProcess) {
try {
await stopFrontend();
} catch (error) {
logError('Error during SillyTavernManager cleanup:', error as Error);
}
}
}
export function getSillyTavernManager() {
return {
isNpxAvailable,
startFrontend,
stopFrontend,
cleanup,
};
}

195
src/main/modules/window.ts Normal file
View file

@ -0,0 +1,195 @@
import { BrowserWindow, app, shell, screen, Menu, clipboard } from 'electron';
import { join } from 'path';
import { stripVTControlCharacters } from 'util';
import { PRODUCT_NAME } from '../../constants';
import type { IPCChannel, IPCChannelPayloads } from '@/types/ipc';
import { isDevelopment } from '@/utils/environment';
let mainWindow: BrowserWindow | null = null;
export function createMainWindow() {
const { size } = screen.getPrimaryDisplay();
const windowHeight = Math.floor(size.height * 0.86);
mainWindow = new BrowserWindow({
width: 1000,
height: windowHeight,
frame: false,
title: PRODUCT_NAME,
show: false,
backgroundColor: '#ffffff',
icon: join(__dirname, '../../src/assets/icon.png'),
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: join(__dirname, '../preload/index.js'),
backgroundThrottling: false,
offscreen: false,
spellcheck: false,
},
});
mainWindow.once('ready-to-show', () => {
mainWindow?.show();
});
if (isDevelopment) {
mainWindow.loadURL('http://localhost:5173');
} else {
mainWindow.loadFile(join(__dirname, '../../dist/index.html'));
}
mainWindow.on('closed', () => {
mainWindow = null;
});
if (!isDevelopment) {
Menu.setApplicationMenu(null);
}
mainWindow.webContents.on('will-navigate', (event, navigationUrl) => {
const url = new URL(navigationUrl);
if (
url.hostname !== 'localhost' &&
url.hostname !== '127.0.0.1' &&
!navigationUrl.startsWith('file://')
) {
event.preventDefault();
}
});
mainWindow.webContents.setWindowOpenHandler(() => ({
action: 'allow',
}));
mainWindow.on('close', () => {
app.quit();
});
setupContextMenu();
return mainWindow;
}
function setupContextMenu() {
if (!mainWindow) return;
// eslint-disable-next-line sonarjs/cognitive-complexity
mainWindow.webContents.on('context-menu', (_, params) => {
const hasLinkURL = !!params.linkURL;
const hasSelection = !!params.selectionText;
const isEditable = params.isEditable;
const isDev = isDevelopment;
const canCut = hasSelection && isEditable;
const canCopy = hasSelection;
const canPaste = isEditable;
const canSelectAll = isEditable || params.mediaType === 'none';
const canUndo = isEditable && params.editFlags?.canUndo;
const canRedo = isEditable && params.editFlags?.canRedo;
const hasEditOperations =
canCut || canCopy || canPaste || canSelectAll || canUndo || canRedo;
const menuItems = [];
if (isDev) {
menuItems.push({
label: 'Inspect Element',
click: () => {
mainWindow?.webContents.inspectElement(params.x, params.y);
},
});
}
if (hasEditOperations) {
if (isDev) {
menuItems.push({ type: 'separator' as const });
}
if (canUndo) {
menuItems.push({ label: 'Undo', role: 'undo' as const });
}
if (canRedo) {
menuItems.push({ label: 'Redo', role: 'redo' as const });
}
if (
(canUndo || canRedo) &&
(canCut || canCopy || canPaste || canSelectAll)
) {
menuItems.push({ type: 'separator' as const });
}
if (canCut) {
menuItems.push({ label: 'Cut', role: 'cut' as const });
}
if (canCopy) {
menuItems.push({ label: 'Copy', role: 'copy' as const });
}
if (canPaste) {
menuItems.push({ label: 'Paste', role: 'paste' as const });
}
if ((canCut || canCopy || canPaste) && canSelectAll) {
menuItems.push({ type: 'separator' as const });
}
if (canSelectAll) {
menuItems.push({ label: 'Select All', role: 'selectAll' as const });
}
}
if (hasLinkURL) {
if (isDev || hasEditOperations) {
menuItems.push({ type: 'separator' as const });
}
menuItems.push({
label: 'Open Link in Browser',
click: () => {
if (params.linkURL) {
shell.openExternal(params.linkURL);
}
},
});
menuItems.push({
label: 'Copy Link Address',
click: () => {
if (params.linkURL) {
clipboard.writeText(params.linkURL);
}
},
});
}
if (menuItems.length > 0) {
const menu = Menu.buildFromTemplate(menuItems);
menu.popup({ window: mainWindow! });
}
});
}
export function getMainWindow() {
return mainWindow;
}
export function sendToRenderer<T extends IPCChannel>(
channel: T,
...args: IPCChannelPayloads[T]
): void {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, ...args);
}
}
export function sendKoboldOutput(message: string, raw?: boolean) {
const cleanMessage = stripVTControlCharacters(message);
sendToRenderer('kobold-output', raw ? cleanMessage : `${cleanMessage}\n`);
}
export function cleanup() {
if (mainWindow) {
mainWindow.removeAllListeners();
}
}

View file

@ -1,9 +0,0 @@
import { join } from 'path';
import { app } from 'electron';
export const getAssetPath = (filename: string): string => {
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
return join(__dirname, '../..', 'src/assets', filename);
}
return join(process.resourcesPath, 'assets', filename);
};

View file

@ -1,7 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { ConfigFile, SdConvDirectMode } from '@/types'; import type { ConfigFile, SdConvDirectMode } from '@/types';
import type { ImageModelPreset } from '@/constants/imageModelPresets'; import type { ImageModelPreset } from '@/constants/imageModelPresets';
import { DEFAULT_CONTEXT_SIZE } from '@/constants'; import { DEFAULT_AUTO_GPU_LAYERS, DEFAULT_CONTEXT_SIZE } from '@/constants';
interface LaunchConfigState { interface LaunchConfigState {
gpuLayers: number; gpuLayers: number;
@ -92,7 +92,7 @@ interface LaunchConfigState {
export const useLaunchConfigStore = create<LaunchConfigState>((set, get) => ({ export const useLaunchConfigStore = create<LaunchConfigState>((set, get) => ({
gpuLayers: 0, gpuLayers: 0,
autoGpuLayers: true, autoGpuLayers: DEFAULT_AUTO_GPU_LAYERS,
contextSize: DEFAULT_CONTEXT_SIZE, contextSize: DEFAULT_CONTEXT_SIZE,
model: '', model: '',
additionalArguments: '', additionalArguments: '',
@ -181,7 +181,7 @@ export const useLaunchConfigStore = create<LaunchConfigState>((set, get) => ({
if (typeof configData.autoGpuLayers === 'boolean') { if (typeof configData.autoGpuLayers === 'boolean') {
updates.autoGpuLayers = configData.autoGpuLayers; updates.autoGpuLayers = configData.autoGpuLayers;
} else { } else {
updates.autoGpuLayers = true; updates.autoGpuLayers = DEFAULT_AUTO_GPU_LAYERS;
} }
if (typeof configData.gpulayers === 'number') { if (typeof configData.gpulayers === 'number') {

1
src/utils/environment.ts Normal file
View file

@ -0,0 +1 @@
export const isDevelopment = process.env.NODE_ENV === 'development';

View file

@ -1,15 +1,7 @@
import { join } from 'path'; import { join } from 'path';
import { homedir } from 'os'; import { homedir } from 'os';
import { app } from 'electron';
import { PRODUCT_NAME, CONFIG_FILE_NAME } from '@/constants'; import { PRODUCT_NAME, CONFIG_FILE_NAME } from '@/constants';
export function getAssetPath(filename: string): string {
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
return join(__dirname, '../..', 'src/assets', filename);
}
return join(process.resourcesPath, 'assets', filename);
}
export function getConfigDir(): string { export function getConfigDir(): string {
return join(getConfigDirPath(), CONFIG_FILE_NAME); return join(getConfigDirPath(), CONFIG_FILE_NAME);
} }

View file

@ -191,7 +191,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@babel/runtime@npm:^7.20.13": "@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.20.13":
version: 7.28.4 version: 7.28.4
resolution: "@babel/runtime@npm:7.28.4" resolution: "@babel/runtime@npm:7.28.4"
checksum: 10c0/792ce7af9750fb9b93879cc9d1db175701c4689da890e6ced242ea0207c9da411ccf16dc04e689cc01158b28d7898c40d75598f4559109f761c12ce01e959bf7 checksum: 10c0/792ce7af9750fb9b93879cc9d1db175701c4689da890e6ced242ea0207c9da411ccf16dc04e689cc01158b28d7898c40d75598f4559109f761c12ce01e959bf7
@ -3649,10 +3649,11 @@ __metadata:
execa: "npm:^9.6.0" execa: "npm:^9.6.0"
globals: "npm:^16.3.0" globals: "npm:^16.3.0"
jiti: "npm:^2.5.1" jiti: "npm:^2.5.1"
lucide-react: "npm:^0.542.0" lucide-react: "npm:^0.543.0"
prettier: "npm:^3.6.2" prettier: "npm:^3.6.2"
react: "npm:^19.1.1" react: "npm:^19.1.1"
react-dom: "npm:^19.1.1" react-dom: "npm:^19.1.1"
react-error-boundary: "npm:^6.0.0"
rollup-plugin-visualizer: "npm:^6.0.3" rollup-plugin-visualizer: "npm:^6.0.3"
systeminformation: "npm:^5.27.8" systeminformation: "npm:^5.27.8"
typescript: "npm:^5.9.2" typescript: "npm:^5.9.2"
@ -4766,12 +4767,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"lucide-react@npm:^0.542.0": "lucide-react@npm:^0.543.0":
version: 0.542.0 version: 0.543.0
resolution: "lucide-react@npm:0.542.0" resolution: "lucide-react@npm:0.543.0"
peerDependencies: peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/3ccdb898a480f0194f93abef0b6f0347bc2647db24051e65a17c1dbd22ca0d10a7636d9af68c92665b42b6c9e761567a0d65944fe8568fb0e30a7ea57281ccec checksum: 10c0/76bb54f9c602ff5d44ec66f2786c035d92aaabca1e1abb380b0c8101dfb3290e52c82fa4d630e286ef07c05b63696e187f0ca3ae186535d9c5b4de7aa8825bd3
languageName: node languageName: node
linkType: hard linkType: hard
@ -5686,6 +5687,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-error-boundary@npm:^6.0.0":
version: 6.0.0
resolution: "react-error-boundary@npm:6.0.0"
dependencies:
"@babel/runtime": "npm:^7.12.5"
peerDependencies:
react: ">=16.13.1"
checksum: 10c0/1914d600dee95a14f14af4afe9867b0d35c26c4f7826d23208800ba2a99728659029aad60a6ef95e13430b4d79c2c4c9b3585f50bf508450478760d2e4e732d8
languageName: node
linkType: hard
"react-is@npm:^16.13.1": "react-is@npm:^16.13.1":
version: 16.13.1 version: 16.13.1
resolution: "react-is@npm:16.13.1" resolution: "react-is@npm:16.13.1"