less verbone frontend error handling, better process killing for windows, better loading behaviour for available backends

This commit is contained in:
lone-cloud 2025-08-29 16:32:22 -07:00
parent ae8ec47f51
commit 66a710dc95
18 changed files with 376 additions and 320 deletions

View file

@ -12,6 +12,7 @@ import { AppHeader } from '@/components/AppHeader';
import { useUpdateChecker } from '@/hooks/useUpdateChecker';
import { useKoboldVersions } from '@/hooks/useKoboldVersions';
import { UI } from '@/constants';
import { Logger } from '@/utils/logger';
import type { DownloadItem } from '@/types/electron';
import type { InterfaceTab, FrontendPreference, Screen } from '@/types';
@ -45,7 +46,7 @@ export const App = () => {
useEffect(() => {
const checkInstallation = async () => {
try {
await Logger.safeExecute(async () => {
const [versions, currentBinaryPath, hasSeenWelcome, preference] =
await Promise.all([
window.electronAPI.kobold.getInstalledVersions(),
@ -81,20 +82,14 @@ export const App = () => {
setCurrentScreenWithTransition('download');
}
setHasInitialized(true);
if (versions.length > 0) {
setTimeout(() => {
checkForUpdates();
}, 2000);
}
} catch (error) {
window.electronAPI.logs.logError(
'Error checking installation:',
error as Error
);
}, 'Error checking installation:');
setHasInitialized(true);
}
};
checkInstallation();
@ -102,7 +97,7 @@ export const App = () => {
}, []);
const handleBinaryUpdate = async (download: DownloadItem) => {
try {
await Logger.safeExecute(async () => {
const success = await sharedHandleDownload({
type: 'asset',
item: download,
@ -113,16 +108,11 @@ export const App = () => {
if (success) {
dismissUpdate();
}
} catch (error) {
window.electronAPI.logs.logError(
'Failed to update binary:',
error as Error
);
}
}, 'Failed to update binary:');
};
const handleDownloadComplete = async () => {
try {
await Logger.safeExecute(async () => {
const [versions, currentBinaryPath] = await Promise.all([
window.electronAPI.kobold.getInstalledVersions(),
window.electronAPI.config.get('currentKoboldBinary') as Promise<string>,
@ -143,12 +133,7 @@ export const App = () => {
}
}
}
} catch (error) {
window.electronAPI.logs.logError(
'Error refreshing versions after download:',
error as Error
);
}
}, 'Error refreshing versions after download:');
setTimeout(() => {
setCurrentScreenWithTransition('launch');
@ -176,16 +161,8 @@ export const App = () => {
}
};
const performEject = async () => {
try {
await window.electronAPI.kobold.stopKoboldCpp();
} catch (error) {
window.electronAPI.logs.logError(
'Error stopping KoboldCpp:',
error as Error
);
}
const performEject = () => {
window.electronAPI.kobold.stopKoboldCpp();
handleBackToLaunch();
};
@ -302,17 +279,14 @@ export const App = () => {
opened={settingsOpened}
onClose={async () => {
setSettingsOpened(false);
try {
const preference = (await window.electronAPI.config.get(
const preference = await Logger.safeExecute(
() =>
window.electronAPI.config.get(
'frontendPreference'
)) as FrontendPreference;
setFrontendPreference(preference || 'koboldcpp');
} catch (error) {
window.electronAPI.logs.logError(
'Failed to load frontend preference:',
error as Error
) as Promise<FrontendPreference>,
'Failed to load frontend preference:'
);
}
setFrontendPreference(preference || 'koboldcpp');
}}
currentScreen={currentScreen || undefined}
/>

View file

@ -14,6 +14,7 @@ import { useState } from 'react';
import type { InstalledVersion, DownloadItem } from '@/types/electron';
import { getDisplayNameFromPath } from '@/utils/version';
import { GITHUB_API } from '@/constants';
import { Logger } from '@/utils/logger';
interface UpdateAvailableModalProps {
opened: boolean;
@ -37,15 +38,12 @@ export const UpdateAvailableModal = ({
const [isUpdating, setIsUpdating] = useState(false);
const handleUpdate = async () => {
try {
setIsUpdating(true);
await Logger.safeExecute(async () => {
await onUpdate(availableUpdate);
onClose();
} catch (error) {
window.electronAPI.logs.logError('Failed to update:', error as Error);
} finally {
}, 'Failed to update:');
setIsUpdating(false);
}
};
return (

View file

@ -5,6 +5,7 @@ import { getPlatformDisplayName } from '@/utils/platform';
import { formatDownloadSize } from '@/utils/download';
import { getAssetDescription, sortDownloadsByType } from '@/utils/assets';
import { useKoboldVersions } from '@/hooks/useKoboldVersions';
import { Logger } from '@/utils/logger';
import type { DownloadItem } from '@/types/electron';
interface DownloadScreenProps {
@ -33,7 +34,7 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
async (download: DownloadItem) => {
setDownloadingAsset(download.name);
try {
await Logger.safeExecute(async () => {
const success = await sharedHandleDownload({
type: 'asset',
item: download,
@ -48,14 +49,9 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
setDownloadingAsset(null);
}, 200);
}
} catch (error) {
window.electronAPI.logs.logError(
`Failed to download ${download.name}:`,
error as Error
);
} finally {
}, `Failed to download ${download.name}:`);
setDownloadingAsset(null);
}
},
[sharedHandleDownload, onDownloadComplete]
);

View file

@ -8,7 +8,7 @@ import {
} from '@mantine/core';
import { ChevronDown } from 'lucide-react';
import styles from '@/styles/layout.module.css';
import { UI } from '@/constants';
import { UI, SERVER_READY_SIGNALS } from '@/constants';
import { handleTerminalOutput, processTerminalContent } from '@/utils/terminal';
import { useLaunchConfigStore } from '@/stores/launchConfigStore';
import type { FrontendPreference } from '@/types';
@ -69,14 +69,14 @@ export const TerminalTab = ({
const serverPort = port || 5001;
if (frontendPreference === 'sillytavern') {
if (newData.includes('SillyTavern is listening on')) {
if (newData.includes(SERVER_READY_SIGNALS.SILLYTAVERN)) {
setTimeout(
() => onServerReady(`http://${serverHost}:${serverPort}`),
1500
);
}
} else {
if (newData.includes('Please connect to custom endpoint at')) {
if (newData.includes(SERVER_READY_SIGNALS.KOBOLDCPP)) {
setTimeout(
() => onServerReady(`http://${serverHost}:${serverPort}`),
1500

View file

@ -3,6 +3,7 @@ import { useState, useEffect } from 'react';
import { InfoTooltip } from '@/components/InfoTooltip';
import { CheckboxWithTooltip } from '@/components/CheckboxWithTooltip';
import { useLaunchConfig } from '@/hooks/useLaunchConfig';
import { Logger } from '@/utils/logger';
export const AdvancedTab = () => {
const {
@ -34,8 +35,11 @@ export const AdvancedTab = () => {
useEffect(() => {
const detectBackendSupport = async () => {
try {
const support = await window.electronAPI.kobold.detectBackendSupport();
const support = await Logger.safeExecute(
() => window.electronAPI.kobold.detectBackendSupport(),
'Failed to detect backend support:'
);
if (support) {
setBackendSupport({
noavx2: support.noavx2,
@ -44,15 +48,8 @@ export const AdvancedTab = () => {
} else {
setBackendSupport({ noavx2: false, failsafe: false });
}
} catch (error) {
window.electronAPI.logs.logError(
'Failed to detect backend support:',
error as Error
);
setBackendSupport({ noavx2: false, failsafe: false });
} finally {
setIsLoading(false);
}
};
void detectBackendSupport();

View file

@ -4,6 +4,7 @@ import { InfoTooltip } from '@/components/InfoTooltip';
import { BackendSelectItem } from '@/components/screens/Launch/GeneralTab/BackendSelectItem';
import { GpuDeviceSelector } from '@/components/screens/Launch/GeneralTab/GpuDeviceSelector';
import { useLaunchConfig } from '@/hooks/useLaunchConfig';
import { Logger } from '@/utils/logger';
import type { BackendOption } from '@/types';
export const BackendSelector = () => {
@ -19,22 +20,19 @@ export const BackendSelector = () => {
const [availableBackends, setAvailableBackends] = useState<BackendOption[]>(
[]
);
const [isLoadingBackends, setIsLoadingBackends] = useState(false);
const hasInitialized = useRef(false);
useEffect(() => {
const loadBackends = async () => {
try {
const backends =
await window.electronAPI.kobold.getAvailableBackends(true);
setAvailableBackends(backends);
hasInitialized.current = true;
} catch (error) {
window.electronAPI.logs.logError(
'Failed to detect available backends:',
error as Error
setIsLoadingBackends(true);
const backends = await Logger.safeExecute(
() => window.electronAPI.kobold.getAvailableBackends(true),
'Failed to detect available backends:'
);
setAvailableBackends([]);
}
setAvailableBackends(backends || []);
setIsLoadingBackends(false);
hasInitialized.current = true;
};
if (!hasInitialized.current) {
@ -60,7 +58,9 @@ export const BackendSelector = () => {
<InfoTooltip label="Select a backend to use. CUDA runs on NVIDIA GPUs, and is much faster. ROCm is the AMD equivalent. Vulkan and CLBlast work on all GPUs." />
</Group>
<Select
placeholder="Select backend"
placeholder={
isLoadingBackends ? 'Loading backends...' : 'Select backend'
}
value={backend}
onChange={(value) => {
if (value) {
@ -72,7 +72,7 @@ export const BackendSelector = () => {
label: b.label,
disabled: b.disabled,
}))}
disabled={availableBackends.length === 0}
disabled={isLoadingBackends || availableBackends.length === 0}
renderOption={({ option }) => {
const backendData = availableBackends.find(
(b) => b.value === option.value

View file

@ -10,6 +10,7 @@ import { ImageGenerationTab } from '@/components/screens/Launch/ImageGenerationT
import { WarningDisplay } from '@/components/WarningDisplay';
import { ConfigFileManager } from '@/components/screens/Launch/ConfigFileManager';
import { DEFAULT_MODEL_URL } from '@/constants';
import { Logger } from '@/utils/logger';
import type { ConfigFile } from '@/types';
interface LaunchScreenProps {
@ -83,23 +84,19 @@ export const LaunchScreen = ({
});
const setHappyDefaults = useCallback(async () => {
try {
const backends = await window.electronAPI.kobold.getAvailableBackends();
if (!backend && backends.length > 0) {
handleBackendChange(backends[0].value);
}
} catch (error) {
window.electronAPI.logs.logError(
'Failed to set defaults:',
error as Error
const backends = await Logger.safeExecute(
() => window.electronAPI.kobold.getAvailableBackends(),
'Failed to set defaults:'
);
if (!backend && backends && backends.length > 0) {
handleBackendChange(backends[0].value);
}
}, [backend, handleBackendChange]);
const setInitialDefaults = useCallback(
async (currentModelPath: string, currentSdModel: string) => {
try {
await Logger.tryExecute(async () => {
if (
!defaultsSetRef.current &&
!currentModelPath.trim() &&
@ -108,12 +105,7 @@ export const LaunchScreen = ({
handleModelPathChange(DEFAULT_MODEL_URL);
defaultsSetRef.current = true;
}
} catch (error) {
window.electronAPI.logs.logError(
'Failed to set initial defaults:',
error as Error
);
}
}, 'Failed to set initial defaults:');
},
[handleModelPathChange]
);
@ -194,49 +186,43 @@ export const LaunchScreen = ({
});
const handleCreateNewConfig = async (configName: string) => {
try {
await Logger.safeExecute(async () => {
const fullConfigName = `${configName}.json`;
const success = await window.electronAPI.kobold.saveConfigFile(
const saveSuccess = await window.electronAPI.kobold.saveConfigFile(
fullConfigName,
buildConfigData()
);
if (success) {
if (saveSuccess) {
await loadConfigFiles();
setSelectedFile(fullConfigName);
await window.electronAPI.kobold.setSelectedConfig(fullConfigName);
} else {
window.electronAPI.logs.logError(
Logger.error(
'Failed to create new configuration',
new Error('Save operation failed')
);
}
} catch (error) {
window.electronAPI.logs.logError(
'Failed to create new configuration:',
error as Error
);
}
}, 'Failed to create new configuration:');
};
const handleSaveConfig = async () => {
if (!selectedFile) {
window.electronAPI.logs.logError(
Logger.error(
'No configuration file selected for saving',
new Error('Selected file is null')
);
return false;
}
try {
const success = await window.electronAPI.kobold.saveConfigFile(
const success = await Logger.safeExecute(async () => {
const saveSuccess = await window.electronAPI.kobold.saveConfigFile(
selectedFile,
buildConfigData()
);
if (!success) {
window.electronAPI.logs.logError(
if (!saveSuccess) {
Logger.error(
'Failed to save configuration',
new Error('Save operation failed')
);
@ -244,13 +230,9 @@ export const LaunchScreen = ({
}
return true;
} catch (error) {
window.electronAPI.logs.logError(
'Failed to save configuration:',
error as Error
);
return false;
}
}, 'Failed to save configuration:');
return success ?? false;
};
useEffect(() => {

View file

@ -13,6 +13,7 @@ import { Folder, FolderOpen, Monitor } from 'lucide-react';
import styles from '@/styles/layout.module.css';
import type { FrontendPreference } from '@/types';
import { FRONTENDS } from '@/constants';
import { Logger } from '@/utils/logger';
export const GeneralTab = () => {
const [installDir, setInstallDir] = useState<string>('');
@ -29,32 +30,22 @@ export const GeneralTab = () => {
useEffect(() => {
const checkNpxAvailability = async () => {
try {
const available = await window.electronAPI.sillytavern.isNpxAvailable();
setIsNpxAvailable(available);
const available = await Logger.safeExecute(
() => window.electronAPI.sillytavern.isNpxAvailable(),
'Failed to check npx availability:'
);
if (!available && FrontendPreference === 'sillytavern') {
await window.electronAPI.config.set(
'frontendPreference',
'koboldcpp'
const isAvailable = available ?? false;
setIsNpxAvailable(isAvailable);
if (!isAvailable && FrontendPreference === 'sillytavern') {
await Logger.tryExecute(
() =>
window.electronAPI.config.set('frontendPreference', 'koboldcpp'),
'Failed to reset frontend preference:'
);
setFrontendPreference('koboldcpp');
}
} catch (error) {
window.electronAPI.logs.logError(
'Failed to check npx availability:',
error as Error
);
setIsNpxAvailable(false);
if (FrontendPreference === 'sillytavern') {
await window.electronAPI.config.set(
'frontendPreference',
'koboldcpp'
);
setFrontendPreference('koboldcpp');
}
}
};
if (FrontendPreference) {
@ -63,58 +54,46 @@ export const GeneralTab = () => {
}, [FrontendPreference]);
const loadCurrentInstallDir = async () => {
try {
const currentDir = await window.electronAPI.kobold.getCurrentInstallDir();
setInstallDir(currentDir);
} catch (error) {
window.electronAPI.logs.logError(
'Failed to load install directory:',
error as Error
const currentDir = await Logger.safeExecute(
() => window.electronAPI.kobold.getCurrentInstallDir(),
'Failed to load install directory:'
);
if (currentDir) {
setInstallDir(currentDir);
}
};
const loadFrontendPreference = async () => {
try {
const frontendPreference = (await window.electronAPI.config.get(
'frontendPreference'
)) as FrontendPreference;
setFrontendPreference(frontendPreference || 'koboldcpp');
} catch (error) {
window.electronAPI.logs.logError(
'Failed to load frontend preference:',
error as Error
const frontendPreference = await Logger.safeExecute(
() => window.electronAPI.config.get('frontendPreference'),
'Failed to load frontend preference:'
);
if (frontendPreference) {
setFrontendPreference(
(frontendPreference as FrontendPreference) || 'koboldcpp'
);
}
};
const handleSelectInstallDir = async () => {
try {
const selectedDir =
await window.electronAPI.kobold.selectInstallDirectory();
const selectedDir = await Logger.safeExecute(
() => window.electronAPI.kobold.selectInstallDirectory(),
'Failed to select install directory:'
);
if (selectedDir) {
setInstallDir(selectedDir);
}
} catch (error) {
window.electronAPI.logs.logError(
'Failed to select install directory:',
error as Error
);
}
};
const handleFrontendPreferenceChange = async (value: string | null) => {
if (!value || (value !== 'koboldcpp' && value !== 'sillytavern')) return;
try {
await window.electronAPI.config.set('frontendPreference', value);
setFrontendPreference(value);
} catch (error) {
window.electronAPI.logs.logError(
'Failed to save frontend preference:',
error as Error
const success = await Logger.tryExecute(
() => window.electronAPI.config.set('frontendPreference', value),
'Failed to save frontend preference:'
);
if (success) {
setFrontendPreference(value);
}
};

View file

@ -18,6 +18,7 @@ import {
stripAssetExtensions,
compareVersions,
} from '@/utils/version';
import { Logger } from '@/utils/logger';
import { formatDownloadSize } from '@/utils/download';
import { useKoboldVersions } from '@/hooks/useKoboldVersions';
@ -62,7 +63,7 @@ export const VersionsTab = () => {
const loadInstalledVersions = useCallback(async () => {
setLoadingInstalled(true);
try {
await Logger.safeExecute(async () => {
const [versions, currentBinaryPath] = await Promise.all([
window.electronAPI.kobold.getInstalledVersions(),
window.electronAPI.config.get('currentKoboldBinary') as Promise<string>,
@ -95,25 +96,18 @@ export const VersionsTab = () => {
await window.electronAPI.config.set('currentKoboldBinary', '');
}
}
} catch (error) {
window.electronAPI.logs.logError(
'Failed to load installed versions:',
error as Error
);
} finally {
}, 'Failed to load installed versions:');
setLoadingInstalled(false);
}
}, []);
const loadLatestRelease = useCallback(async () => {
try {
const release = await getLatestReleaseWithDownloadStatus();
setLatestRelease(release);
} catch (error) {
window.electronAPI.logs.logError(
'Failed to load latest release:',
error as Error
const release = await Logger.safeExecute(
() => getLatestReleaseWithDownloadStatus(),
'Failed to load latest release:'
);
if (release) {
setLatestRelease(release);
}
}, [getLatestReleaseWithDownloadStatus]);
@ -205,7 +199,7 @@ export const VersionsTab = () => {
}, [downloading]);
const handleDownload = async (version: VersionInfo) => {
try {
await Logger.safeExecute(async () => {
const download = availableDownloads.find((d) => d.name === version.name);
if (!download) {
throw new Error('Download not found');
@ -221,13 +215,11 @@ export const VersionsTab = () => {
if (success) {
await loadInstalledVersions();
}
} catch (error) {
window.electronAPI.logs.logError('Failed to download:', error as Error);
}
}, 'Failed to download:');
};
const handleUpdate = async (version: VersionInfo) => {
try {
await Logger.safeExecute(async () => {
const download = availableDownloads.find((d) => d.name === version.name);
if (!download) {
throw new Error('Download not found');
@ -243,23 +235,21 @@ export const VersionsTab = () => {
if (success) {
await loadInstalledVersions();
}
} catch (error) {
window.electronAPI.logs.logError('Failed to update:', error as Error);
}
}, 'Failed to update:');
};
const makeCurrent = async (version: VersionInfo) => {
if (!version.installedPath) return;
try {
await Logger.safeExecute(async () => {
const success = await window.electronAPI.kobold.setCurrentVersion(
version.installedPath
version.installedPath!
);
if (success) {
await window.electronAPI.config.set(
'currentKoboldBinary',
version.installedPath
version.installedPath!
);
const newCurrentVersion = installedVersions.find(
@ -269,12 +259,7 @@ export const VersionsTab = () => {
setCurrentVersion(newCurrentVersion);
}
}
} catch (error) {
window.electronAPI.logs.logError(
'Failed to set current version:',
error as Error
);
}
}, 'Failed to set current version:');
};
const isLoading = loadingInstalled || loadingPlatform || loadingRemote;

View file

@ -9,6 +9,11 @@ export const UI = {
export const { HEADER_HEIGHT } = UI;
export const SERVER_READY_SIGNALS = {
KOBOLDCPP: 'Please connect to custom endpoint at',
SILLYTAVERN: 'SillyTavern is listening on',
} as const;
export * from './defaults';
export const GITHUB_API = {

View file

@ -32,14 +32,16 @@ export class IPCHandlers {
private async launchKoboldCppWithCustomFrontends(args: string[] = []) {
try {
const result = await this.koboldManager.launchKoboldCpp(args);
const frontendPreference =
await this.configManager.get('frontendPreference');
this.koboldManager.launchKoboldCpp(args);
if (frontendPreference === 'sillytavern') {
this.sillyTavernManager.startFrontend(args);
}
return result;
} catch (error) {
this.logManager.logError('Error in enhanced launch:', error as Error);
return {
@ -50,7 +52,7 @@ export class IPCHandlers {
}
setupHandlers() {
ipcMain.handle('kobold:downloadRelease', async (_event, asset) => {
ipcMain.handle('kobold:downloadRelease', async (_, asset) => {
try {
const mainWindow = this.koboldManager
.getWindowManager()
@ -79,9 +81,7 @@ export class IPCHandlers {
this.koboldManager.getConfigFiles()
);
ipcMain.handle(
'kobold:saveConfigFile',
async (_event, configName, configData) =>
ipcMain.handle('kobold:saveConfigFile', async (_, configName, configData) =>
this.koboldManager.saveConfigFile(configName, configData)
);
@ -89,11 +89,11 @@ export class IPCHandlers {
this.configManager.getSelectedConfig()
);
ipcMain.handle('kobold:setSelectedConfig', (_event, configName) =>
ipcMain.handle('kobold:setSelectedConfig', (_, configName) =>
this.configManager.setSelectedConfig(configName)
);
ipcMain.handle('kobold:setCurrentVersion', (_event, version) =>
ipcMain.handle('kobold:setCurrentVersion', (_, version) =>
this.koboldManager.setCurrentVersion(version)
);
@ -152,20 +152,16 @@ export class IPCHandlers {
}
});
ipcMain.handle('kobold:launchKoboldCpp', (_event, args) =>
ipcMain.handle('kobold:launchKoboldCpp', (_, args) =>
this.launchKoboldCppWithCustomFrontends(args)
);
ipcMain.handle('kobold:stopKoboldCpp', async () => {
try {
await this.koboldManager.stopKoboldCpp();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
ipcMain.handle('kobold:stopKoboldCpp', () => {
this.koboldManager.stopKoboldCpp();
this.sillyTavernManager.stopFrontend();
});
ipcMain.handle('kobold:parseConfigFile', (_event, filePath) =>
ipcMain.handle('kobold:parseConfigFile', (_, filePath) =>
this.koboldManager.parseConfigFile(filePath)
);
@ -173,15 +169,15 @@ export class IPCHandlers {
this.koboldManager.selectModelFile()
);
ipcMain.handle('config:get', (_event, key) => this.configManager.get(key));
ipcMain.handle('config:get', (_, key) => this.configManager.get(key));
ipcMain.handle('config:set', (_event, key, value) =>
ipcMain.handle('config:set', (_, key, value) =>
this.configManager.set(key, value)
);
ipcMain.handle('app:getVersion', () => app.getVersion());
ipcMain.handle('app:openExternal', async (_event, url) => {
ipcMain.handle('app:openExternal', async (_, url) => {
try {
await shell.openExternal(url);
return { success: true };
@ -190,12 +186,9 @@ export class IPCHandlers {
}
});
ipcMain.handle(
'logs:logError',
(_event, message: string, error?: Error) => {
ipcMain.handle('logs:logError', (_, message: string, error?: Error) => {
this.logManager.logError(message, error);
}
);
});
ipcMain.handle('sillytavern:isNpxAvailable', () =>
this.sillyTavernManager.isNpxAvailable()

View file

@ -21,7 +21,7 @@ import { pipeline } from 'stream/promises';
import { ConfigManager } from '@/main/managers/ConfigManager';
import { LogManager } from '@/main/managers/LogManager';
import { WindowManager } from '@/main/managers/WindowManager';
import { PRODUCT_NAME } from '@/constants';
import { PRODUCT_NAME, SERVER_READY_SIGNALS } from '@/constants';
import { stripAssetExtensions } from '@/utils/version';
import type {
GitHubAsset,
@ -700,14 +700,47 @@ export class KoboldCppManager {
this.windowManager.sendKoboldOutput(commandLine);
}, 200);
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;
setTimeout(() => {
if (!isReady) {
reject(
new Error('Timeout waiting for KoboldCpp ready signal (30s)')
);
}
}, 30000);
});
child.stdout?.on('data', (data) => {
const output = data.toString();
this.windowManager.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();
this.windowManager.sendKoboldOutput(output, true);
if (!isReady && output.includes(SERVER_READY_SIGNALS.KOBOLDCPP)) {
isReady = true;
readyResolve?.({ success: true, pid: child.pid });
}
});
child.on('exit', (code, signal) => {
@ -720,6 +753,14 @@ export class KoboldCppManager {
: `\n[INFO] Process exited with code ${code}`;
this.windowManager.sendKoboldOutput(displayMessage);
this.koboldProcess = null;
if (!isReady) {
_readyReject?.(
new Error(
`Process exited before ready signal (code: ${code}, signal: ${signal})`
)
);
}
});
child.on('error', (error) => {
@ -732,9 +773,13 @@ export class KoboldCppManager {
`\n[ERROR] Process error: ${error.message}\n`
);
this.koboldProcess = null;
if (!isReady) {
_readyReject?.(error);
}
});
return { success: true, pid: child.pid };
return await readyPromise;
} catch (error) {
const errorMessage = (error as Error).message;
this.logManager.logError(

View file

@ -70,8 +70,8 @@ export class LogManager {
this.logError('Uncaught Exception:', error);
});
process.on('unhandledRejection', (reason, promise) => {
const message = `Unhandled Promise Rejection at: ${promise}`;
process.on('unhandledRejection', (reason, _promise) => {
const message = `Unhandled Promise Rejection`;
const error =
reason instanceof Error ? reason : new Error(String(reason));
this.logError(message, error);

View file

@ -21,6 +21,14 @@ export class SillyTavernManager {
private proxyServer: Server | null = null;
private logManager: LogManager;
private windowManager: WindowManager;
private static readonly SILLYTAVERN_BASE_ARGS = [
'sillytavern',
'--global',
'--listen',
'--browserLaunchEnabled',
'false',
'--disableCsrf',
];
constructor(logManager: LogManager, windowManager: WindowManager) {
this.logManager = logManager;
@ -109,14 +117,6 @@ export class SillyTavernManager {
return join(this.getSillyTavernDataRoot(), 'default-user', 'settings.json');
}
private static readonly SILLYTAVERN_BASE_ARGS = [
'sillytavern',
'--listen',
'--browserLaunchEnabled',
'false',
'--disableCsrf',
];
async isNpxAvailable(): Promise<boolean> {
try {
const testProcess = spawn('npx', ['--version'], { stdio: 'pipe' });
@ -146,10 +146,6 @@ export class SillyTavernManager {
return spawn('npx', args, {
stdio: ['pipe', 'pipe', 'pipe'],
detached: false,
env: {
...process.env,
DATA_ROOT: this.getSillyTavernDataRoot(),
},
});
}
@ -157,7 +153,9 @@ export class SillyTavernManager {
const settingsPath = this.getSillyTavernSettingsPath();
if (existsSync(settingsPath)) {
this.windowManager.sendKoboldOutput('SillyTavern settings found');
this.windowManager.sendKoboldOutput(
`SillyTavern settings found at ${settingsPath}`
);
return;
}
@ -250,9 +248,14 @@ export class SillyTavernManager {
});
}
private parseKoboldConfig(args: string[]): { host: string; port: number } {
private parseKoboldConfig(args: string[]): {
host: string;
port: number;
isImageMode: boolean;
} {
let host = 'localhost';
let port = 5001;
let hasSdModel = false;
for (let i = 0; i < args.length - 1; i++) {
if (args[i] === '--hostname' || args[i] === '--host') {
@ -262,23 +265,23 @@ export class SillyTavernManager {
if (!isNaN(parsedPort)) {
port = parsedPort;
}
} else if (args[i] === '--sdmodel') {
hasSdModel = true;
}
}
return { host, port };
const isImageMode = hasSdModel;
return { host, port, isImageMode };
}
private async setupSillyTavernConfig(
koboldHost: string,
koboldPort: number
koboldPort: number,
isImageMode: boolean
): Promise<void> {
try {
const configPath = this.getSillyTavernSettingsPath();
this.windowManager.sendKoboldOutput(
`Configuring SillyTavern settings at: ${configPath}`
);
let settings: Record<string, unknown> = {};
if (existsSync(configPath)) {
@ -299,6 +302,18 @@ export class SillyTavernManager {
if (!settings.power_user) settings.power_user = {};
const powerUser = settings.power_user as Record<string, unknown>;
powerUser.auto_connect = true;
if (isImageMode) {
this.windowManager.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 = {};
@ -313,7 +328,10 @@ export class SillyTavernManager {
settings.main_api = 'textgenerationwebui';
textgenSettings.type = 'koboldcpp';
powerUser.auto_connect = true;
this.windowManager.sendKoboldOutput(
`Configured SillyTavern for text generation with KoboldCpp at ${koboldUrl}`
);
writeFileSync(configPath, JSON.stringify(settings, null, 2), 'utf-8');
@ -407,8 +425,11 @@ export class SillyTavernManager {
port: SILLYTAVERN.PORT,
proxyPort: SILLYTAVERN.PROXY_PORT,
};
const { host: koboldHost, port: koboldPort } =
this.parseKoboldConfig(args);
const {
host: koboldHost,
port: koboldPort,
isImageMode,
} = this.parseKoboldConfig(args);
await this.stopFrontend();
@ -417,7 +438,7 @@ export class SillyTavernManager {
);
await this.ensureSillyTavernSettings();
await this.setupSillyTavernConfig(koboldHost, koboldPort);
await this.setupSillyTavernConfig(koboldHost, koboldPort, isImageMode);
this.windowManager.sendKoboldOutput(
`Starting ${config.name} frontend on port ${config.port}...`
@ -479,19 +500,6 @@ export class SillyTavernManager {
}
async stopFrontend(): Promise<void> {
this.windowManager.sendKoboldOutput('Stopping SillyTavern frontend...');
try {
this.windowManager.sendKoboldOutput(
`Killing processes on port ${SILLYTAVERN.PORT}...`
);
} catch (error) {
this.logManager.logError(
'Error killing processes on port:',
error as Error
);
}
const promises: Promise<void>[] = [];
if (this.sillyTavernProcess) {
@ -501,7 +509,6 @@ export class SillyTavernManager {
this.logManager.logError(message, error),
}).then(() => {
this.sillyTavernProcess = null;
this.windowManager.sendKoboldOutput('SillyTavern process terminated');
})
);
}
@ -511,7 +518,6 @@ export class SillyTavernManager {
new Promise((resolve) => {
this.proxyServer?.close(() => {
this.proxyServer = null;
this.windowManager.sendKoboldOutput('Proxy server closed');
resolve();
});
})
@ -519,7 +525,6 @@ export class SillyTavernManager {
}
await Promise.all(promises);
this.windowManager.sendKoboldOutput('SillyTavern frontend stopped');
}
async cleanup(): Promise<void> {

View file

@ -149,7 +149,7 @@ export class WindowManager {
private setupContextMenu() {
if (!this.mainWindow) return;
this.mainWindow.webContents.on('context-menu', (_event, params) => {
this.mainWindow.webContents.on('context-menu', (_, params) => {
const hasLinkURL = !!params.linkURL;
const isDev = this.isDevelopment();

View file

@ -10,6 +10,7 @@ import type {
HardwareInfo,
GPUMemoryInfo,
} from '@/types/hardware';
import { spawn } from 'child_process';
export class HardwareService {
private cpuCapabilitiesCache: CPUCapabilities | null = null;
@ -147,7 +148,6 @@ export class HardwareService {
devices: string[];
}> {
try {
const { spawn } = await import('child_process');
const nvidia = spawn(
'nvidia-smi',
['--query-gpu=name,memory.total,memory.free', '--format=csv,noheader'],
@ -200,7 +200,6 @@ export class HardwareService {
devices: string[];
}> {
try {
const { spawn } = await import('child_process');
const rocminfo = spawn('rocminfo', [], { timeout: 5000 });
let output = '';
@ -278,7 +277,6 @@ export class HardwareService {
devices: string[];
}> {
try {
const { spawn } = await import('child_process');
const vulkaninfo = spawn('vulkaninfo', ['--summary'], { timeout: 5000 });
let output = '';
@ -390,7 +388,6 @@ export class HardwareService {
devices: string[];
}> {
try {
const { spawn } = await import('child_process');
const clinfo = spawn('clinfo', [], { timeout: 3000 });
let output = '';

56
src/utils/logger.ts Normal file
View file

@ -0,0 +1,56 @@
export class Logger {
private static logError(message: string, error: Error): void {
if (window.electronAPI?.logs?.logError) {
window.electronAPI.logs.logError(message, error);
}
}
static async safeExecute<T>(
operation: () => Promise<T>,
errorMessage: string
): Promise<T | null> {
try {
return await operation();
} catch (error) {
this.logError(errorMessage, error as Error);
return null;
}
}
static safeExecuteSync<T>(
operation: () => T,
errorMessage: string
): T | null {
try {
return operation();
} catch (error) {
this.logError(errorMessage, error as Error);
return null;
}
}
static async tryExecute(
operation: () => Promise<void>,
errorMessage: string
): Promise<boolean> {
try {
await operation();
return true;
} catch (error) {
this.logError(errorMessage, error as Error);
return false;
}
}
static withErrorHandling<TArgs extends unknown[], TReturn>(
fn: (...args: TArgs) => Promise<TReturn>,
errorMessage: string
): (...args: TArgs) => Promise<TReturn | null> {
return async (...args: TArgs) =>
this.safeExecute(() => fn(...args), errorMessage);
}
static error(message: string, error: Error): void {
this.logError(message, error);
}
}

View file

@ -1,3 +1,4 @@
import { spawn } from 'child_process';
import type { ChildProcess } from 'child_process';
export interface ProcessTerminationOptions {
@ -5,6 +6,32 @@ export interface ProcessTerminationOptions {
logError?: (message: string, error: Error) => void;
}
async function killWindowsProcessTree(
pid: number,
logError?: (message: string, error: Error) => void
): Promise<void> {
return new Promise((resolve) => {
const taskkill = spawn('taskkill', ['/pid', pid.toString(), '/t', '/f'], {
stdio: 'pipe',
});
taskkill.on('exit', (code) => {
if (code !== 0 && code !== 128) {
logError?.(
`taskkill exited with code ${code} for PID ${pid}`,
new Error(`taskkill failed with exit code ${code}`)
);
}
resolve();
});
taskkill.on('error', (error) => {
logError?.(`Failed to execute taskkill for PID ${pid}:`, error);
resolve();
});
});
}
export async function terminateProcess(
childProcess: ChildProcess,
options: ProcessTerminationOptions = {}
@ -16,8 +43,24 @@ export async function terminateProcess(
}
try {
const signal = process.platform === 'win32' ? undefined : 'SIGTERM';
childProcess.kill(signal);
if (process.platform === 'win32') {
await killWindowsProcessTree(childProcess.pid, logError);
await new Promise<void>((resolve) => {
const timeout = setTimeout(
() => {
resolve();
},
Math.min(timeoutMs, 2000)
);
childProcess.once('exit', () => {
clearTimeout(timeout);
resolve();
});
});
} else {
childProcess.kill('SIGTERM');
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
@ -36,6 +79,7 @@ export async function terminateProcess(
resolve();
});
});
}
} catch (error) {
logError?.('Error terminating process:', error as Error);
}