@@ -459,33 +483,38 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
loading={downloading === version.name}
disabled={downloading !== null}
leftSection={
-
+ downloading === version.name ? (
+
+ ) : (
+
+ )
}
>
- Download
+ {downloading === version.name
+ ? 'Downloading...'
+ : 'Download'}
)}
- {version.isDownloaded &&
- !version.isCurrent &&
- version.installedData && (
-
- )}
+
+ {downloading === version.name &&
+ downloadProgress[version.name] !== undefined && (
+
+
+
+ {downloadProgress[version.name].toFixed(1)}%
+ complete
+
+
+ )}
- ));
- })()}
+ )))()}
{userPlatform === 'linux' && rocmDownload && (
@@ -497,7 +526,7 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
- AMD GPU support with ROCm
+ Version {rocmDownload.version || 'latest'}
{rocmDownload.size && (
<>
{' '}
@@ -519,7 +548,7 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
loading={downloadingROCm}
disabled={downloading !== null || downloadingROCm}
leftSection={
-
}
@@ -556,14 +585,12 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
- setThemeMode(value as 'light' | 'dark' | 'system')
- }
+ onChange={(value) => setThemeMode(value as ThemeMode)}
data={[
{
label: (
-
+
Light
),
@@ -572,7 +599,7 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
{
label: (
-
+
Dark
),
@@ -581,9 +608,7 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
{
label: (
-
+
System
),
diff --git a/src/components/UpdateDialog.tsx b/src/components/UpdateDialog.tsx
index d729e8a..6867ddc 100644
--- a/src/components/UpdateDialog.tsx
+++ b/src/components/UpdateDialog.tsx
@@ -1,19 +1,5 @@
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
-
-interface GitHubRelease {
- tag_name: string;
- name: string;
- published_at: string;
- body: string;
- assets: GitHubAsset[];
-}
-
-interface GitHubAsset {
- name: string;
- browser_download_url: string;
- size: number;
- created_at: string;
-}
+import type { GitHubRelease } from '@/types';
interface UpdateDialogProps {
updateInfo: {
diff --git a/src/constants/app.ts b/src/constants/app.ts
new file mode 100644
index 0000000..8889b1c
--- /dev/null
+++ b/src/constants/app.ts
@@ -0,0 +1,36 @@
+export const APP_NAME = 'friendly-kobold';
+
+export const CONFIG_FILE_NAME = 'config.json';
+
+export const DIALOG_TITLES = {
+ SELECT_INSTALL_DIR: 'Select the Friendly Kobold Installation Directory',
+} as const;
+
+export const GITHUB_API = {
+ BASE_URL: 'https://api.github.com',
+ KOBOLDCPP_REPO: 'LostRuins/koboldcpp',
+ get LATEST_RELEASE_URL() {
+ return `${this.BASE_URL}/repos/${this.KOBOLDCPP_REPO}/releases/latest`;
+ },
+ get ALL_RELEASES_URL() {
+ return `${this.BASE_URL}/repos/${this.KOBOLDCPP_REPO}/releases`;
+ },
+} as const;
+
+export const ASSET_SUFFIXES = {
+ ROCM: 'rocm',
+ NOCUDA: 'nocuda',
+ OLDPC: 'oldpc',
+} as const;
+
+export const KOBOLDAI_URLS = {
+ STANDARD_DOWNLOAD: 'https://koboldai.org/cpp',
+ ROCM_DOWNLOAD: 'https://koboldai.org/cpplinuxrocm',
+} as const;
+
+export const ROCM = {
+ BINARY_NAME: 'koboldcpp-linux-x64-rocm',
+ DOWNLOAD_URL: KOBOLDAI_URLS.ROCM_DOWNLOAD,
+ ERROR_MESSAGE: 'ROCm version is only available for Linux',
+ SIZE_BYTES: 1024 * 1024 * 1024, // 1GB
+} as const;
diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx
index d471c4a..ef6fd37 100644
--- a/src/contexts/ThemeContext.tsx
+++ b/src/contexts/ThemeContext.tsx
@@ -7,7 +7,7 @@ import {
} from 'react';
import { MantineColorScheme } from '@mantine/core';
-type ThemeMode = 'light' | 'dark' | 'system';
+export type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeContextType {
themeMode: ThemeMode;
diff --git a/src/hooks/useLaunchConfig.ts b/src/hooks/useLaunchConfig.ts
new file mode 100644
index 0000000..41447ab
--- /dev/null
+++ b/src/hooks/useLaunchConfig.ts
@@ -0,0 +1,137 @@
+import { useState, useCallback } from 'react';
+import type { ConfigFile } from '@/types';
+
+export const useLaunchConfig = () => {
+ const [serverOnly, setServerOnly] = useState(false);
+ const [gpuLayers, setGpuLayers] = useState(0);
+ const [autoGpuLayers, setAutoGpuLayers] = useState(false);
+ const [contextSize, setContextSize] = useState(2048);
+ const [modelPath, setModelPath] = useState('');
+ const [additionalArguments, setAdditionalArguments] = useState('');
+
+ const parseAndApplyConfigFile = useCallback(async (configPath: string) => {
+ const configData =
+ await window.electronAPI.kobold.parseConfigFile(configPath);
+ if (configData) {
+ if (typeof configData.gpulayers === 'number') {
+ setGpuLayers(configData.gpulayers);
+ } else {
+ setGpuLayers(0);
+ }
+
+ if (typeof configData.contextsize === 'number') {
+ setContextSize(configData.contextsize);
+ } else {
+ setContextSize(2048);
+ }
+
+ if (typeof configData.model_param === 'string') {
+ setModelPath(configData.model_param);
+ await window.electronAPI.config.setModelPath(configData.model_param);
+ }
+ } else {
+ setGpuLayers(0);
+ setContextSize(2048);
+ }
+ }, []);
+
+ const loadSavedSettings = useCallback(async () => {
+ const [savedServerOnly, savedModelPath] = await Promise.all([
+ window.electronAPI.config.getServerOnly(),
+ window.electronAPI.config.getModelPath(),
+ ]);
+
+ setServerOnly(savedServerOnly);
+ setModelPath(savedModelPath || '');
+ setGpuLayers(0);
+ setContextSize(2048);
+ }, []);
+
+ const loadConfigFromFile = useCallback(
+ async (configFiles: ConfigFile[], savedConfig: string | null) => {
+ let currentSelectedFile = null;
+
+ if (savedConfig && configFiles.some((f) => f.name === savedConfig)) {
+ currentSelectedFile = savedConfig;
+ } else if (configFiles.length > 0) {
+ currentSelectedFile = configFiles[0].name;
+ }
+
+ if (currentSelectedFile) {
+ const selectedConfig = configFiles.find(
+ (f) => f.name === currentSelectedFile
+ );
+ if (selectedConfig) {
+ await parseAndApplyConfigFile(selectedConfig.path);
+ }
+ }
+
+ return currentSelectedFile;
+ },
+ [parseAndApplyConfigFile]
+ );
+
+ const handleServerOnlyChange = useCallback(async (checked: boolean) => {
+ setServerOnly(checked);
+ await window.electronAPI.config.setServerOnly(checked);
+ }, []);
+
+ const handleGpuLayersChange = useCallback(async (value: number) => {
+ setGpuLayers(value);
+ }, []);
+
+ const roundToValidContextSize = useCallback((value: number): number => {
+ if (value < 1024) {
+ return Math.round(value / 256) * 256;
+ }
+ return Math.round(value / 1024) * 1024;
+ }, []);
+
+ const handleContextSizeChangeWithStep = useCallback(
+ async (value: number) => {
+ const roundedValue = roundToValidContextSize(value);
+ setContextSize(roundedValue);
+ },
+ [roundToValidContextSize]
+ );
+
+ const handleModelPathChange = useCallback(async (value: string) => {
+ setModelPath(value);
+ await window.electronAPI.config.setModelPath(value);
+ }, []);
+
+ const handleSelectModelFile = useCallback(async () => {
+ const filePath = await window.electronAPI.kobold.selectModelFile();
+ if (filePath) {
+ await handleModelPathChange(filePath);
+ }
+ }, [handleModelPathChange]);
+
+ const handleAdditionalArgumentsChange = useCallback((value: string) => {
+ setAdditionalArguments(value);
+ }, []);
+
+ const handleAutoGpuLayersChange = useCallback((checked: boolean) => {
+ setAutoGpuLayers(checked);
+ }, []);
+
+ return {
+ serverOnly,
+ gpuLayers,
+ autoGpuLayers,
+ contextSize,
+ modelPath,
+ additionalArguments,
+
+ parseAndApplyConfigFile,
+ loadSavedSettings,
+ loadConfigFromFile,
+ handleServerOnlyChange,
+ handleGpuLayersChange,
+ handleAutoGpuLayersChange,
+ handleContextSizeChangeWithStep,
+ handleModelPathChange,
+ handleSelectModelFile,
+ handleAdditionalArgumentsChange,
+ };
+};
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts
new file mode 100644
index 0000000..33e83ec
--- /dev/null
+++ b/src/hooks/useNotifications.ts
@@ -0,0 +1,91 @@
+import { createElement, type ReactNode } from 'react';
+import { notifications } from '@mantine/notifications';
+import { Check, X, Info, AlertTriangle } from 'lucide-react';
+
+export const useNotifications = () => {
+ const success = (title: string, message?: string) => {
+ notifications.show({
+ title,
+ message,
+ color: 'green',
+ icon: createElement(Check, { size: 18 }),
+ position: 'top-right',
+ autoClose: 5000,
+ });
+ };
+
+ const error = (title: string, message?: string) => {
+ notifications.show({
+ title,
+ message,
+ color: 'red',
+ icon: createElement(X, { size: 18 }),
+ position: 'top-right',
+ autoClose: 7000,
+ });
+ };
+
+ const info = (title: string, message?: string) => {
+ notifications.show({
+ title,
+ message,
+ color: 'blue',
+ icon: createElement(Info, { size: 18 }),
+ position: 'top-right',
+ autoClose: 5000,
+ });
+ };
+
+ const warning = (title: string, message?: string) => {
+ notifications.show({
+ title,
+ message,
+ color: 'yellow',
+ icon: createElement(AlertTriangle, { size: 18 }),
+ position: 'top-right',
+ autoClose: 6000,
+ });
+ };
+
+ const custom = (options: {
+ title: string;
+ message?: string;
+ color?: string;
+ icon?: ReactNode;
+ autoClose?: number | false;
+ position?:
+ | 'top-left'
+ | 'top-right'
+ | 'top-center'
+ | 'bottom-left'
+ | 'bottom-right'
+ | 'bottom-center';
+ }) => {
+ const { title, message = '', ...rest } = options;
+ notifications.show({
+ title,
+ message,
+ position: 'top-right',
+ autoClose: 5000,
+ ...rest,
+ });
+ };
+
+ const hide = (id: string) => {
+ notifications.hide(id);
+ };
+
+ const clean = () => {
+ notifications.clean();
+ };
+
+ return {
+ success,
+ error,
+ info,
+ warning,
+ custom,
+ hide,
+ clean,
+ };
+};
diff --git a/src/index.css b/src/index.css
index 57852c4..75958d7 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,5 +1 @@
@import '@mantine/core/styles.css';
-
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
diff --git a/src/main/index.ts b/src/main/index.ts
index 18d901c..717500f 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -3,12 +3,13 @@ import { join } from 'path';
import { existsSync, mkdirSync } from 'fs';
import { homedir } from 'os';
-import { WindowManager } from './managers/WindowManager';
-import { ConfigManager } from './managers/ConfigManager';
-import { KoboldCppManager } from './managers/KoboldCppManager';
-import { GitHubService } from './services/GitHubService';
-import { GPUService } from './services/GPUService';
-import { IPCHandlers } from './utils/IPCHandlers';
+import { WindowManager } from '@/main/managers/WindowManager';
+import { ConfigManager } from '@/main/managers/ConfigManager';
+import { KoboldCppManager } from '@/main/managers/KoboldCppManager';
+import { GitHubService } from '@/main/services/GitHubService';
+import { GPUService } from '@/main/services/GPUService';
+import { IPCHandlers } from '@/main/utils/IPCHandlers';
+import { APP_NAME, CONFIG_FILE_NAME } from '@/constants/app';
class FriendlyKoboldApp {
private windowManager: WindowManager;
@@ -25,7 +26,8 @@ class FriendlyKoboldApp {
this.gpuService = new GPUService();
this.koboldManager = new KoboldCppManager(
this.configManager,
- this.githubService
+ this.githubService,
+ this.windowManager
);
this.ipcHandlers = new IPCHandlers(
this.koboldManager,
@@ -38,7 +40,7 @@ class FriendlyKoboldApp {
}
private getConfigPath() {
- return join(app.getPath('userData'), 'config.json');
+ return join(app.getPath('userData'), CONFIG_FILE_NAME);
}
private getDefaultInstallPath() {
@@ -47,11 +49,11 @@ class FriendlyKoboldApp {
switch (platform) {
case 'win32':
- return join(home, 'FriendlyKobold');
+ return join(home, APP_NAME);
case 'darwin':
- return join(home, 'Applications', 'FriendlyKobold');
+ return join(home, 'Applications', APP_NAME);
default:
- return join(home, '.local', 'share', 'friendly-kobold');
+ return join(home, '.local', 'share', APP_NAME);
}
}
@@ -76,11 +78,25 @@ class FriendlyKoboldApp {
this.ipcHandlers.setupHandlers();
app.on('window-all-closed', () => {
- if (process.platform !== 'darwin') {
- app.quit();
+ if (process.platform === 'darwin') {
+ return;
}
});
+ app.on('before-quit', async (event) => {
+ // Prevent immediate quit to allow cleanup
+ event.preventDefault();
+
+ // Clean up KoboldCpp process
+ await this.koboldManager.cleanup();
+
+ // Clean up window manager
+ this.windowManager.cleanup();
+
+ // Now actually quit
+ app.exit(0);
+ });
+
app.on('activate', () => {
if (!this.windowManager.getMainWindow()) {
this.windowManager.createMainWindow();
diff --git a/src/main/managers/ConfigManager.ts b/src/main/managers/ConfigManager.ts
index 2f0e326..f7b7be7 100644
--- a/src/main/managers/ConfigManager.ts
+++ b/src/main/managers/ConfigManager.ts
@@ -4,8 +4,10 @@ type ConfigValue = string | number | boolean | unknown[] | undefined;
interface AppConfig {
installDir?: string;
- currentVersion?: string;
+ currentKoboldBinary?: string;
selectedConfig?: string;
+ serverOnly?: boolean;
+ modelPath?: string;
[key: string]: ConfigValue;
}
@@ -55,12 +57,12 @@ export class ConfigManager {
this.saveConfig();
}
- getCurrentVersion(): string | undefined {
- return this.config.currentVersion;
+ getCurrentKoboldBinary(): string | undefined {
+ return this.config.currentKoboldBinary as string | undefined;
}
- setCurrentVersion(version: string) {
- this.config.currentVersion = version;
+ setCurrentKoboldBinary(binaryPath: string) {
+ this.config.currentKoboldBinary = binaryPath;
this.saveConfig();
}
@@ -72,4 +74,22 @@ export class ConfigManager {
this.config.selectedConfig = configName;
this.saveConfig();
}
+
+ getServerOnly(): boolean {
+ return this.config.serverOnly || false;
+ }
+
+ setServerOnly(serverOnly: boolean) {
+ this.config.serverOnly = serverOnly;
+ this.saveConfig();
+ }
+
+ getModelPath(): string | undefined {
+ return this.config.modelPath;
+ }
+
+ setModelPath(path: string) {
+ this.config.modelPath = path;
+ this.saveConfig();
+ }
}
diff --git a/src/main/managers/KoboldCppManager.ts b/src/main/managers/KoboldCppManager.ts
index fb1a70f..7112e6a 100644
--- a/src/main/managers/KoboldCppManager.ts
+++ b/src/main/managers/KoboldCppManager.ts
@@ -1,9 +1,18 @@
import { spawn, ChildProcess } from 'child_process';
import { join } from 'path';
-import { existsSync, readdirSync, statSync, createWriteStream } from 'fs';
+import {
+ existsSync,
+ readdirSync,
+ statSync,
+ createWriteStream,
+ chmodSync,
+ readFileSync,
+} from 'fs';
import { dialog } from 'electron';
-import { GitHubService } from '../services/GitHubService';
-import { ConfigManager } from './ConfigManager';
+import { GitHubService } from '@/main/services/GitHubService';
+import { ConfigManager } from '@/main/managers/ConfigManager';
+import { WindowManager } from '@/main/managers/WindowManager';
+import { APP_NAME, DIALOG_TITLES, ROCM } from '@/constants/app';
interface GitHubAsset {
name: string;
@@ -39,7 +48,6 @@ interface ReleaseWithStatus {
export interface InstalledVersion {
version: string;
path: string;
- type: 'github' | 'rocm';
downloadDate: string;
filename: string;
}
@@ -49,13 +57,19 @@ export class KoboldCppManager {
private koboldProcess: ChildProcess | null = null;
private configManager: ConfigManager;
private githubService: GitHubService;
+ private windowManager: WindowManager;
- constructor(configManager: ConfigManager, githubService: GitHubService) {
+ constructor(
+ configManager: ConfigManager,
+ githubService: GitHubService,
+ windowManager: WindowManager
+ ) {
this.configManager = configManager;
this.githubService = githubService;
+ this.windowManager = windowManager;
this.installDir =
this.configManager.getInstallDir() ||
- join(process.env.HOME || process.env.USERPROFILE || '.', 'KoboldCpp');
+ join(process.env.HOME || process.env.USERPROFILE || '.', APP_NAME);
}
async downloadRelease(
@@ -105,14 +119,23 @@ export class KoboldCppManager {
reader.releaseLock();
}
+ if (process.platform !== 'win32') {
+ try {
+ chmodSync(filePath, 0o755);
+ } catch (error) {
+ console.warn('Failed to make binary executable:', error);
+ }
+ }
+
+ const currentBinary = this.configManager.getCurrentKoboldBinary();
+ if (!currentBinary) {
+ this.configManager.setCurrentKoboldBinary(filePath);
+ }
+
return filePath;
}
async getInstalledVersions(): Promise {
- const configData = this.configManager.get('installedVersions');
- const configVersions: InstalledVersion[] = Array.isArray(configData)
- ? (configData as unknown as InstalledVersion[])
- : [];
const scannedVersions: InstalledVersion[] = [];
try {
@@ -126,48 +149,26 @@ export class KoboldCppManager {
statSync(filePath).isFile() &&
(file.includes('koboldcpp') || file.includes('kobold'))
) {
- const existingVersion = configVersions.find(
- (v: InstalledVersion) => v.path === filePath
- );
+ try {
+ const detectedVersion = await this.getVersionFromBinary(filePath);
+ const version = detectedVersion || 'unknown';
- if (existingVersion) {
- scannedVersions.push(existingVersion);
- } else {
- try {
- const detectedVersion =
- await this.getVersionFromBinary(filePath);
- const version = detectedVersion || 'unknown';
+ const newVersion: InstalledVersion = {
+ version,
+ path: filePath,
+ downloadDate: new Date().toISOString(),
+ filename: file,
+ };
- const newVersion: InstalledVersion = {
- version,
- path: filePath,
- type: 'github',
- downloadDate: new Date().toISOString(),
- filename: file,
- };
-
- scannedVersions.push(newVersion);
- } catch (error) {
- console.warn(`Could not detect version for ${file}:`, error);
- }
+ scannedVersions.push(newVersion);
+ } catch (error) {
+ console.warn(`Could not detect version for ${file}:`, error);
}
}
}
}
} catch (error) {
console.warn('Error scanning install directory:', error);
- return configVersions.filter((version: InstalledVersion) =>
- existsSync(version.path)
- );
- }
-
- if (
- scannedVersions.length !== configVersions.length ||
- !scannedVersions.every((sv) =>
- configVersions.some((cv: InstalledVersion) => cv.path === sv.path)
- )
- ) {
- this.configManager.set('installedVersions', scannedVersions as unknown[]);
}
return scannedVersions;
@@ -210,60 +211,87 @@ export class KoboldCppManager {
return configFiles.sort((a, b) => a.name.localeCompare(b.name));
}
+ async parseConfigFile(filePath: string): Promise<{
+ gpulayers?: number;
+ contextsize?: number;
+ model_param?: string;
+ [key: string]: unknown;
+ } | null> {
+ try {
+ if (!existsSync(filePath)) {
+ return null;
+ }
+
+ const content = readFileSync(filePath, 'utf-8');
+ const config = JSON.parse(content);
+
+ return config;
+ } catch (error) {
+ console.warn('Error parsing config file:', error);
+ return null;
+ }
+ }
+
+ async selectModelFile(): Promise {
+ try {
+ const mainWindow = this.windowManager.getMainWindow();
+ if (!mainWindow) {
+ return null;
+ }
+
+ const result = await dialog.showOpenDialog(mainWindow, {
+ title: 'Select Model File',
+ filters: [
+ { name: 'GGUF Files', extensions: ['gguf'] },
+ { name: 'All Files', extensions: ['*'] },
+ ],
+ properties: ['openFile'],
+ });
+
+ if (result.canceled || result.filePaths.length === 0) {
+ return null;
+ }
+
+ return result.filePaths[0];
+ } catch (error) {
+ console.warn('Error selecting model file:', error);
+ return null;
+ }
+ }
+
async getCurrentVersion(): Promise {
const versions = await this.getInstalledVersions();
- const currentVersionString = this.configManager.getCurrentVersion();
+ const currentBinaryPath = this.configManager.getCurrentKoboldBinary();
- if (currentVersionString) {
- const found =
- versions.find((v) => v.version === currentVersionString) || null;
- return found;
+ if (currentBinaryPath) {
+ const found = versions.find((v) => v.path === currentBinaryPath);
+ if (found && existsSync(found.path)) {
+ return found;
+ }
+ // If the current binary no longer exists, clear it
+ this.configManager.setCurrentKoboldBinary('');
}
- const fallback =
+ // If no current binary is set, return the most recent one
+ return (
versions.sort(
(a, b) =>
new Date(b.downloadDate).getTime() -
new Date(a.downloadDate).getTime()
- )[0] || null;
- return fallback;
+ )[0] || null
+ );
}
async setCurrentVersion(version: string): Promise {
const versions = await this.getInstalledVersions();
const targetVersion = versions.find((v) => v.version === version);
- if (!targetVersion || !existsSync(targetVersion.path)) {
- if (targetVersion) {
- const updatedVersions = versions.filter((v) => v.version !== version);
- this.configManager.set(
- 'installedVersions',
- updatedVersions as unknown[]
- );
- }
- return false;
+ if (targetVersion && existsSync(targetVersion.path)) {
+ this.configManager.setCurrentKoboldBinary(targetVersion.path);
+ return true;
}
- this.configManager.setCurrentVersion(version);
-
- const installedVersionsData = this.configManager.get('installedVersions');
- const installedVersions: InstalledVersion[] = Array.isArray(
- installedVersionsData
- )
- ? (installedVersionsData as unknown as InstalledVersion[])
- : [];
- const versionToUpdate = installedVersions.find(
- (v: InstalledVersion) => v.version === version
- );
-
- if (versionToUpdate) {
- this.configManager.set(
- 'installedVersions',
- installedVersions as unknown[]
- );
- }
-
- return true;
+ return false;
}
async getVersionFromBinary(binaryPath: string): Promise {
@@ -338,48 +366,33 @@ export class KoboldCppManager {
return this.installDir;
}
+ getWindowManager() {
+ return this.windowManager;
+ }
+
async selectInstallDirectory(): Promise {
const result = await dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
- title: 'Select KoboldCpp Installation Directory',
+ title: DIALOG_TITLES.SELECT_INSTALL_DIR,
defaultPath: this.installDir,
+ buttonLabel: 'Select Directory',
});
if (!result.canceled && result.filePaths.length > 0) {
- const newPath = join(result.filePaths[0], 'KoboldCpp');
- this.installDir = newPath;
- this.configManager.setInstallDir(newPath);
- return newPath;
+ this.installDir = result.filePaths[0];
+ this.configManager.setInstallDir(result.filePaths[0]);
+
+ const mainWindow = this.windowManager.getMainWindow();
+ if (mainWindow) {
+ mainWindow.webContents.send('install-dir-changed', result.filePaths[0]);
+ }
+
+ return result.filePaths[0];
}
return null;
}
- async addInstalledVersion(
- version: string,
- path: string,
- type: 'github' | 'rocm' = 'github'
- ) {
- const versions = await this.getInstalledVersions();
-
- const filteredVersions = versions.filter((v) => v.version !== version);
- const filename = path.split(/[/\\]/).pop() || 'unknown';
-
- const newVersion: InstalledVersion = {
- version,
- path,
- type,
- downloadDate: new Date().toISOString(),
- filename,
- };
-
- this.configManager.set('installedVersions', [
- ...filteredVersions,
- newVersion,
- ] as unknown[]);
- this.configManager.setCurrentVersion(version);
- }
-
async launchKobold(
versionPath: string,
args: string[] = [],
@@ -439,7 +452,6 @@ export class KoboldCppManager {
name: string;
url: string;
size: number;
- type: 'rocm';
version?: string;
} | null> {
const platform = process.platform;
@@ -451,15 +463,14 @@ export class KoboldCppManager {
const version = latestRelease?.tag_name?.replace(/^v/, '') || 'unknown';
return {
- name: 'koboldcpp-linux-x64-rocm',
- url: 'https://koboldai.org/cpplinuxrocm',
- size: 1024 * 1024 * 1024,
- type: 'rocm',
+ name: ROCM.BINARY_NAME,
+ url: ROCM.DOWNLOAD_URL,
+ size: ROCM.SIZE_BYTES,
version,
};
}
- async downloadROCm(): Promise<{
+ async downloadROCm(onProgress?: (progress: number) => void): Promise<{
success: boolean;
path?: string;
error?: string;
@@ -469,11 +480,11 @@ export class KoboldCppManager {
if (platform !== 'linux') {
return {
success: false,
- error: 'ROCm version is only available for Linux',
+ error: ROCM.ERROR_MESSAGE,
};
}
- const response = await fetch('https://koboldai.org/cpplinuxrocm');
+ const response = await fetch(ROCM.DOWNLOAD_URL);
if (!response.ok) {
return {
success: false,
@@ -481,24 +492,52 @@ export class KoboldCppManager {
};
}
- const filePath = join(this.installDir, 'koboldcpp-linux-x64-rocm');
+ const totalBytes = ROCM.SIZE_BYTES;
+ let downloadedBytes = 0;
+
+ const reader = response.body?.getReader();
+ if (!reader) {
+ return {
+ success: false,
+ error: 'Failed to get response reader',
+ };
+ }
+
+ const filePath = join(this.installDir, ROCM.BINARY_NAME);
const writer = createWriteStream(filePath);
- response.body?.pipeTo(
- new WritableStream({
- write(chunk) {
- writer.write(chunk);
- },
- close() {
- writer.end();
- },
- })
- );
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
- await new Promise((resolve, reject) => {
- writer.on('finish', resolve);
- writer.on('error', reject);
- });
+ if (done) break;
+
+ downloadedBytes += value.length;
+ writer.write(value);
+
+ if (onProgress && totalBytes > 0) {
+ onProgress((downloadedBytes / totalBytes) * 100);
+ }
+ }
+
+ writer.end();
+
+ await new Promise((resolve, reject) => {
+ writer.on('finish', resolve);
+ writer.on('error', reject);
+ });
+ } finally {
+ reader.releaseLock();
+ }
+
+ // Make the binary executable on Unix-like systems (Linux/macOS)
+ if (process.platform !== 'win32') {
+ try {
+ chmodSync(filePath, 0o755);
+ } catch (error) {
+ console.warn('Failed to make ROCm binary executable:', error);
+ }
+ }
return {
success: true,
@@ -589,65 +628,122 @@ export class KoboldCppManager {
}
}
- async openInstallDialog(): Promise<{
- success: boolean;
- version?: string;
- path?: string;
- error?: string;
- }> {
- try {
- const result = await dialog.showOpenDialog({
- title: 'Select KoboldCpp executable',
- filters: [
- { name: 'Executables', extensions: ['exe', 'app', 'AppImage'] },
- { name: 'All Files', extensions: ['*'] },
- ],
- properties: ['openFile'],
- });
-
- if (!result.canceled && result.filePaths.length > 0) {
- const filePath = result.filePaths[0];
- const detectedVersion = await this.getVersionFromBinary(filePath);
- const version = detectedVersion || 'unknown';
-
- await this.addInstalledVersion(version, filePath);
-
- return {
- success: true,
- version,
- path: filePath,
- };
- }
-
- return { success: false, error: 'No file selected' };
- } catch (error) {
- return { success: false, error: (error as Error).message };
- }
- }
-
async launchKoboldCpp(
- args: string[] = []
+ args: string[] = [],
+ configFilePath?: string
): Promise<{ success: boolean; pid?: number; error?: string }> {
try {
+ if (this.koboldProcess) {
+ this.stopKoboldCpp();
+ }
+
const currentVersion = await this.getCurrentVersion();
if (!currentVersion || !existsSync(currentVersion.path)) {
return {
success: false,
- error: 'KoboldCpp not found or no version selected',
+ error: 'KoboldCpp not found',
};
}
- const child = spawn(currentVersion.path, args, {
- detached: true,
- stdio: 'ignore',
+ const finalArgs = [...args]; // Start with the provided arguments
+
+ if (configFilePath && existsSync(configFilePath)) {
+ finalArgs.push('--config', configFilePath);
+ }
+
+ const child = spawn(currentVersion.path, finalArgs, {
+ stdio: ['pipe', 'pipe', 'pipe'],
+ detached: false,
});
- await this.setCurrentVersion(currentVersion.version);
- child.unref();
+ this.koboldProcess = child;
+
+ const mainWindow = this.windowManager.getMainWindow();
+ if (mainWindow) {
+ child.stdout?.on('data', (data) => {
+ const output = data.toString();
+ mainWindow.webContents.send('kobold-output', output);
+ });
+
+ child.stderr?.on('data', (data) => {
+ const output = data.toString();
+ mainWindow.webContents.send('kobold-output', output);
+ });
+
+ child.on('exit', (code, signal) => {
+ const exitMessage = signal
+ ? `\nProcess terminated with signal ${signal}\n`
+ : `\nProcess exited with code ${code}\n`;
+ mainWindow.webContents.send('kobold-output', exitMessage);
+ this.koboldProcess = null;
+ });
+
+ child.on('error', (error) => {
+ mainWindow.webContents.send(
+ 'kobold-output',
+ `\nProcess error: ${error.message}\n`
+ );
+ this.koboldProcess = null;
+ });
+ }
return { success: true, pid: child.pid };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
+
+ stopKoboldCpp(): void {
+ if (this.koboldProcess) {
+ try {
+ // Try graceful termination first
+ this.koboldProcess.kill('SIGTERM');
+
+ // Force kill after 5 seconds if still running
+ setTimeout(() => {
+ if (this.koboldProcess && !this.koboldProcess.killed) {
+ this.koboldProcess.kill('SIGKILL');
+ }
+ }, 5000);
+
+ this.koboldProcess = null;
+ } catch (error) {
+ console.warn('Error stopping KoboldCpp process:', error);
+ this.koboldProcess = null;
+ }
+ }
+ }
+
+ // Method to handle app termination - ensures process cleanup
+ async cleanup(): Promise {
+ if (this.koboldProcess) {
+ return new Promise((resolve) => {
+ if (!this.koboldProcess) {
+ resolve();
+ return;
+ }
+
+ // Set up cleanup timeout
+ const cleanup = () => {
+ this.koboldProcess = null;
+ resolve();
+ };
+
+ // Listen for process exit
+ this.koboldProcess.once('exit', cleanup);
+ this.koboldProcess.once('error', cleanup);
+
+ // Try graceful shutdown
+ this.koboldProcess.kill('SIGTERM');
+
+ // Force kill after 3 seconds
+ setTimeout(() => {
+ if (this.koboldProcess && !this.koboldProcess.killed) {
+ this.koboldProcess.kill('SIGKILL');
+ }
+ cleanup();
+ }, 3000);
+ });
+ }
+ }
}
diff --git a/src/main/managers/WindowManager.ts b/src/main/managers/WindowManager.ts
index 58a41fa..792b708 100644
--- a/src/main/managers/WindowManager.ts
+++ b/src/main/managers/WindowManager.ts
@@ -1,8 +1,10 @@
-import { BrowserWindow, app, Menu, shell } from 'electron';
+import { BrowserWindow, app, Menu, shell, Tray, nativeImage } from 'electron';
import { join } from 'path';
export class WindowManager {
private mainWindow: BrowserWindow | null = null;
+ private tray: Tray | null = null;
+ private isQuitting = false;
createMainWindow(): BrowserWindow {
this.mainWindow = new BrowserWindow({
@@ -28,6 +30,14 @@ export class WindowManager {
this.mainWindow = null;
});
+ this.mainWindow.on('close', (event) => {
+ if (this.tray && !this.isQuitting) {
+ event.preventDefault();
+ this.mainWindow?.hide();
+ }
+ });
+
+ this.createSystemTray();
this.setupContextMenu();
return this.mainWindow;
}
@@ -36,10 +46,60 @@ export class WindowManager {
return this.mainWindow;
}
+ private createSystemTray() {
+ // Create system tray icon
+ const iconPath = join(process.cwd(), 'assets', 'icon.png');
+ this.tray = new Tray(nativeImage.createFromPath(iconPath));
+
+ this.tray.setToolTip('Friendly Kobold');
+
+ // Create context menu for tray
+ const trayMenu = Menu.buildFromTemplate([
+ {
+ label: 'Show',
+ click: () => {
+ this.mainWindow?.show();
+ },
+ },
+ {
+ label: 'Hide',
+ click: () => {
+ this.mainWindow?.hide();
+ },
+ },
+ { type: 'separator' },
+ {
+ label: 'Quit',
+ click: () => {
+ this.isQuitting = true;
+ app.quit();
+ },
+ },
+ ]);
+
+ this.tray.setContextMenu(trayMenu);
+
+ // Double-click to show/hide window
+ this.tray.on('double-click', () => {
+ if (this.mainWindow?.isVisible()) {
+ this.mainWindow.hide();
+ } else {
+ this.mainWindow?.show();
+ }
+ });
+ }
+
+ public cleanup() {
+ this.tray?.destroy();
+ this.tray = null;
+ }
+
private setupContextMenu() {
if (!this.mainWindow) return;
this.mainWindow.webContents.on('context-menu', (_event, params) => {
+ const hasLinkURL = !!params.linkURL;
+
const menu = Menu.buildFromTemplate([
{
label: 'Inspect Element',
@@ -53,10 +113,10 @@ export class WindowManager {
{ label: 'Paste', role: 'paste' },
{ type: 'separator' },
{ label: 'Select All', role: 'selectAll' },
- { type: 'separator' },
+ ...(hasLinkURL ? [{ type: 'separator' as const }] : []),
{
label: 'Open Link in Browser',
- visible: !!params.linkURL,
+ visible: hasLinkURL,
click: () => {
if (params.linkURL) {
shell.openExternal(params.linkURL);
@@ -78,6 +138,7 @@ export class WindowManager {
label: 'Quit',
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
click: () => {
+ this.isQuitting = true;
app.quit();
},
},
diff --git a/src/main/services/GitHubService.ts b/src/main/services/GitHubService.ts
index b7a0be1..e2fb27c 100644
--- a/src/main/services/GitHubService.ts
+++ b/src/main/services/GitHubService.ts
@@ -1,4 +1,5 @@
-import type { GitHubRelease } from '../../types/electron';
+import type { GitHubRelease } from '@/types/electron';
+import { GITHUB_API } from '@/constants/app';
export class GitHubService {
private lastApiCall = 0;
@@ -13,16 +14,14 @@ export class GitHubService {
}
try {
- const response = await fetch(
- 'https://api.github.com/repos/LostRuins/koboldcpp/releases/latest'
- );
+ const response = await fetch(GITHUB_API.LATEST_RELEASE_URL);
if (!response.ok) {
if (response.status === 403) {
console.warn(
- 'GitHub API rate limit reached, using cached data or fallback'
+ 'GitHub API rate limit reached, using cached data if available'
);
- return this.cachedRelease || this.getFallbackRelease();
+ return this.cachedRelease;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
@@ -32,7 +31,7 @@ export class GitHubService {
return this.cachedRelease;
} catch (error) {
console.error('Error fetching latest release:', error);
- return this.cachedRelease || this.getFallbackRelease();
+ return this.cachedRelease;
}
}
@@ -47,16 +46,14 @@ export class GitHubService {
}
try {
- const response = await fetch(
- 'https://api.github.com/repos/LostRuins/koboldcpp/releases'
- );
+ const response = await fetch(GITHUB_API.ALL_RELEASES_URL);
if (!response.ok) {
if (response.status === 403) {
- console.warn('GitHub API rate limit reached, using cached data');
- return this.cachedReleases.length > 0
- ? this.cachedReleases
- : [this.getFallbackRelease()];
+ console.warn(
+ 'GitHub API rate limit reached, using cached data if available'
+ );
+ return this.cachedReleases;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
@@ -66,32 +63,7 @@ export class GitHubService {
return this.cachedReleases;
} catch (error) {
console.error('Error fetching releases:', error);
- return this.cachedReleases.length > 0
- ? this.cachedReleases
- : [this.getFallbackRelease()];
+ return this.cachedReleases;
}
}
-
- private getFallbackRelease(): GitHubRelease {
- return {
- tag_name: 'v1.70.1',
- name: 'KoboldCpp v1.70.1 (Fallback)',
- published_at: new Date().toISOString(),
- body: 'Fallback release data - GitHub API unavailable',
- assets: [
- {
- name: 'koboldcpp-linux-x64',
- browser_download_url: 'https://koboldai.org/cpp',
- size: 50000000,
- created_at: new Date().toISOString(),
- },
- {
- name: 'koboldcpp-linux-x64-rocm',
- browser_download_url: 'https://koboldai.org/cpplinuxrocm',
- size: 80000000,
- created_at: new Date().toISOString(),
- },
- ],
- };
- }
}
diff --git a/src/main/utils/IPCHandlers.ts b/src/main/utils/IPCHandlers.ts
index 1478b11..bbfb7bf 100644
--- a/src/main/utils/IPCHandlers.ts
+++ b/src/main/utils/IPCHandlers.ts
@@ -1,9 +1,9 @@
-import { ipcMain } from 'electron';
+import { ipcMain, dialog } from 'electron';
import { shell, app } from 'electron';
-import { KoboldCppManager } from '../managers/KoboldCppManager';
-import { ConfigManager } from '../managers/ConfigManager';
-import { GitHubService } from '../services/GitHubService';
-import { GPUService } from '../services/GPUService';
+import { KoboldCppManager } from '@/main/managers/KoboldCppManager';
+import { ConfigManager } from '@/main/managers/ConfigManager';
+import { GitHubService } from '@/main/services/GitHubService';
+import { GPUService } from '@/main/services/GPUService';
export class IPCHandlers {
private koboldManager: KoboldCppManager;
@@ -32,11 +32,43 @@ export class IPCHandlers {
this.githubService.getAllReleases()
);
- ipcMain.handle(
- 'kobold:downloadRelease',
- async (_event, asset, onProgress) =>
- this.koboldManager.downloadRelease(asset, onProgress)
- );
+ ipcMain.handle('kobold:checkForUpdates', async () => {
+ const latest = await this.githubService.getLatestRelease();
+ return latest;
+ });
+
+ ipcMain.handle('kobold:openInstallDialog', async () => {
+ const result = await dialog.showOpenDialog({
+ properties: ['openDirectory'],
+ title: 'Select Installation Directory',
+ });
+
+ if (!result.canceled && result.filePaths.length > 0) {
+ return result.filePaths[0];
+ }
+ return null;
+ });
+
+ ipcMain.handle('kobold:downloadRelease', async (_event, asset) => {
+ try {
+ const mainWindow = this.koboldManager
+ .getWindowManager()
+ .getMainWindow();
+
+ const filePath = await this.koboldManager.downloadRelease(
+ asset,
+ (progress: number) => {
+ if (mainWindow) {
+ mainWindow.webContents.send('download-progress', progress);
+ }
+ }
+ );
+
+ return { success: true, path: filePath };
+ } catch (error) {
+ return { success: false, error: (error as Error).message };
+ }
+ });
ipcMain.handle('kobold:getInstalledVersions', () =>
this.koboldManager.getInstalledVersions()
@@ -74,12 +106,6 @@ export class IPCHandlers {
this.koboldManager.selectInstallDirectory()
);
- ipcMain.handle(
- 'kobold:addInstalledVersion',
- (_event, version, path, type) =>
- this.koboldManager.addInstalledVersion(version, path, type)
- );
-
ipcMain.handle('kobold:detectGPU', () => this.gpuService.detectGPU());
ipcMain.handle('kobold:getPlatform', () => ({
@@ -93,20 +119,20 @@ export class IPCHandlers {
ipcMain.handle('kobold:downloadROCm', async () => {
try {
- return await this.koboldManager.downloadROCm();
+ const mainWindow = this.koboldManager
+ .getWindowManager()
+ .getMainWindow();
+
+ return await this.koboldManager.downloadROCm((progress: number) => {
+ if (mainWindow) {
+ mainWindow.webContents.send('download-progress', progress);
+ }
+ });
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
- ipcMain.handle('kobold:launchKobold', (_event, versionPath, args) =>
- this.koboldManager.launchKobold(versionPath, args)
- );
-
- ipcMain.handle('kobold:stopKobold', () => this.koboldManager.stopKobold());
-
- ipcMain.handle('kobold:isRunning', () => this.koboldManager.isRunning());
-
ipcMain.handle('kobold:getInstalledVersion', () =>
this.koboldManager.getInstalledVersion()
);
@@ -115,20 +141,58 @@ export class IPCHandlers {
this.koboldManager.getVersionFromBinary(binaryPath)
);
- ipcMain.handle('kobold:checkForUpdates', () =>
- this.koboldManager.checkForUpdates()
- );
-
ipcMain.handle('kobold:getLatestReleaseWithStatus', () =>
this.koboldManager.getLatestReleaseWithDownloadStatus()
);
- ipcMain.handle('kobold:launchKoboldCpp', (_event, args) =>
- this.koboldManager.launchKoboldCpp(args)
+ ipcMain.handle('kobold:launchKoboldCpp', (_event, args, configFilePath) =>
+ this.koboldManager.launchKoboldCpp(args, configFilePath)
);
- ipcMain.handle('kobold:openInstallDialog', () =>
- this.koboldManager.openInstallDialog()
+ ipcMain.handle('kobold:stopKoboldCpp', () =>
+ this.koboldManager.stopKoboldCpp()
+ );
+
+ ipcMain.handle('kobold:confirmEject', async () => {
+ const mainWindow = this.koboldManager.getWindowManager().getMainWindow();
+ if (!mainWindow) return false;
+
+ const result = await dialog.showMessageBox(mainWindow, {
+ type: 'warning',
+ title: 'Confirm Eject',
+ message: 'Are you sure you want to stop KoboldCpp?',
+ detail:
+ 'This will terminate the running process and return to the launch screen.',
+ buttons: ['Cancel', 'Stop KoboldCpp'],
+ defaultId: 0,
+ cancelId: 0,
+ });
+
+ return result.response === 1; // Returns true if user clicked "Stop KoboldCpp"
+ });
+
+ ipcMain.handle('kobold:parseConfigFile', (_event, filePath) =>
+ this.koboldManager.parseConfigFile(filePath)
+ );
+
+ ipcMain.handle('kobold:selectModelFile', () =>
+ this.koboldManager.selectModelFile()
+ );
+
+ ipcMain.handle('config:getServerOnly', () =>
+ this.configManager.getServerOnly()
+ );
+
+ ipcMain.handle('config:setServerOnly', (_event, serverOnly) =>
+ this.configManager.setServerOnly(serverOnly)
+ );
+
+ ipcMain.handle('config:getModelPath', () =>
+ this.configManager.getModelPath()
+ );
+
+ ipcMain.handle('config:setModelPath', (_event, path) =>
+ this.configManager.setModelPath(path)
);
ipcMain.handle('config:get', (_event, key) => this.configManager.get(key));
diff --git a/src/preload/index.ts b/src/preload/index.ts
index bc65c81..1d871e5 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -1,5 +1,10 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron';
-import type { KoboldAPI, AppAPI, UpdateInfo } from '../types/electron';
+import type {
+ KoboldAPI,
+ AppAPI,
+ ConfigAPI,
+ UpdateInfo,
+} from '@/types/electron';
const koboldAPI: KoboldAPI = {
isInstalled: () => ipcRenderer.invoke('kobold:isInstalled'),
@@ -10,7 +15,6 @@ const koboldAPI: KoboldAPI = {
ipcRenderer.invoke('kobold:setCurrentVersion', version),
getVersionFromBinary: (binaryPath: string) =>
ipcRenderer.invoke('kobold:getVersionFromBinary', binaryPath),
- checkForUpdates: () => ipcRenderer.invoke('kobold:checkForUpdates'),
getLatestReleaseWithStatus: () =>
ipcRenderer.invoke('kobold:getLatestReleaseWithStatus'),
getROCmDownload: () => ipcRenderer.invoke('kobold:getROCmDownload'),
@@ -24,12 +28,19 @@ const koboldAPI: KoboldAPI = {
ipcRenderer.invoke('kobold:selectInstallDirectory'),
downloadRelease: (asset) =>
ipcRenderer.invoke('kobold:downloadRelease', asset),
- launchKoboldCpp: (args) => ipcRenderer.invoke('kobold:launchKoboldCpp', args),
+ launchKoboldCpp: (args?: string[], configFilePath?: string) =>
+ ipcRenderer.invoke('kobold:launchKoboldCpp', args, configFilePath),
openInstallDialog: () => ipcRenderer.invoke('kobold:openInstallDialog'),
+ checkForUpdates: () => ipcRenderer.invoke('kobold:checkForUpdates'),
getConfigFiles: () => ipcRenderer.invoke('kobold:getConfigFiles'),
getSelectedConfig: () => ipcRenderer.invoke('kobold:getSelectedConfig'),
setSelectedConfig: (configName: string) =>
ipcRenderer.invoke('kobold:setSelectedConfig', configName),
+ parseConfigFile: (filePath: string) =>
+ ipcRenderer.invoke('kobold:parseConfigFile', filePath),
+ selectModelFile: () => ipcRenderer.invoke('kobold:selectModelFile'),
+ stopKoboldCpp: () => ipcRenderer.invoke('kobold:stopKoboldCpp'),
+ confirmEject: () => ipcRenderer.invoke('kobold:confirmEject'),
onDownloadProgress: (callback) => {
ipcRenderer.on(
'download-progress',
@@ -42,6 +53,22 @@ const koboldAPI: KoboldAPI = {
(_: IpcRendererEvent, updateInfo: UpdateInfo) => callback(updateInfo)
);
},
+ onInstallDirChanged: (callback: (newPath: string) => void) => {
+ const handler = (_: IpcRendererEvent, newPath: string) => callback(newPath);
+ ipcRenderer.on('install-dir-changed', handler);
+
+ return () => {
+ ipcRenderer.removeListener('install-dir-changed', handler);
+ };
+ },
+ onKoboldOutput: (callback: (data: string) => void) => {
+ const handler = (_: IpcRendererEvent, data: string) => callback(data);
+ ipcRenderer.on('kobold-output', handler);
+
+ return () => {
+ ipcRenderer.removeListener('kobold-output', handler);
+ };
+ },
removeAllListeners: (channel) => {
ipcRenderer.removeAllListeners(channel);
},
@@ -52,7 +79,20 @@ const appAPI: AppAPI = {
openExternal: (url) => ipcRenderer.invoke('app:openExternal', url),
};
+const configAPI: ConfigAPI = {
+ getServerOnly: () => ipcRenderer.invoke('config:getServerOnly'),
+ setServerOnly: (serverOnly: boolean) =>
+ ipcRenderer.invoke('config:setServerOnly', serverOnly),
+ getModelPath: () => ipcRenderer.invoke('config:getModelPath'),
+ setModelPath: (path: string) =>
+ ipcRenderer.invoke('config:setModelPath', path),
+ get: (key: string) => ipcRenderer.invoke('config:get', key),
+ set: (key: string, value: unknown) =>
+ ipcRenderer.invoke('config:set', key, value),
+};
+
contextBridge.exposeInMainWorld('electronAPI', {
kobold: koboldAPI,
app: appAPI,
+ config: configAPI,
});
diff --git a/src/screens/DownloadScreen.tsx b/src/screens/DownloadScreen.tsx
index 1f2d997..043bf9f 100644
--- a/src/screens/DownloadScreen.tsx
+++ b/src/screens/DownloadScreen.tsx
@@ -1,16 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
-import {
- Card,
- Text,
- Title,
- Loader,
- Alert,
- Stack,
- Container,
- Progress,
-} from '@mantine/core';
-import { IconAlertCircle } from '@tabler/icons-react';
-import { DownloadOptionCard } from '../components/DownloadOptionCard';
+import { Card, Text, Title, Loader, Stack, Container } from '@mantine/core';
+import { useNotifications } from '@/hooks/useNotifications';
+import { DownloadOptionCard } from '@/components/DownloadOptionCard';
import {
getPlatformDisplayName,
filterAssetsByPlatform,
@@ -21,27 +12,15 @@ import {
isAssetRecommended,
sortAssetsByRecommendation,
} from '@/utils/assets';
+import { ROCM } from '@/constants/app';
+import type { GitHubAsset, GitHubRelease } from '@/types';
interface DownloadScreenProps {
- onInstallComplete: () => void;
+ onDownloadComplete: () => void;
}
-interface GitHubAsset {
- name: string;
- browser_download_url: string;
- size: number;
- created_at: string;
-}
-
-interface GitHubRelease {
- tag_name: string;
- name: string;
- published_at: string;
- body: string;
- assets: GitHubAsset[];
-}
-
-export const DownloadScreen = ({ onInstallComplete }: DownloadScreenProps) => {
+export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
+ const notify = useNotifications();
const [latestRelease, setLatestRelease] = useState(
null
);
@@ -53,15 +32,15 @@ export const DownloadScreen = ({ onInstallComplete }: DownloadScreenProps) => {
const [loading, setLoading] = useState(true);
const [downloading, setDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
- const [error, setError] = useState(null);
+ const [downloadingType, setDownloadingType] = useState<
+ 'asset' | 'rocm' | null
+ >(null);
const [rocmDownload, setRocmDownload] = useState<{
name: string;
url: string;
size: number;
- type: 'rocm';
version?: string;
} | null>(null);
- const [downloadingROCm, setDownloadingROCm] = useState(false);
const loadLatestReleaseAndPlatform = useCallback(async () => {
if (!window.electronAPI) return;
@@ -90,18 +69,25 @@ export const DownloadScreen = ({ onInstallComplete }: DownloadScreenProps) => {
setHasAMDGPU(false);
}
- const filtered = filterAssetsByPlatform(
- releaseData.assets,
- platformInfo.platform
- );
- setFilteredAssets(filtered);
+ if (releaseData) {
+ const filtered = filterAssetsByPlatform(
+ releaseData.assets,
+ platformInfo.platform
+ );
+ setFilteredAssets(filtered);
+ } else {
+ notify.error(
+ 'Error',
+ 'GitHub API is currently unavailable. Please try again later.'
+ );
+ }
} catch (err) {
- setError('Failed to load release information');
+ notify.error('Error', 'Failed to load release information');
console.error('Error loading release:', err);
} finally {
setLoading(false);
}
- }, []);
+ }, [notify]);
useEffect(() => {
loadLatestReleaseAndPlatform();
@@ -119,83 +105,73 @@ export const DownloadScreen = ({ onInstallComplete }: DownloadScreenProps) => {
};
}, [loadLatestReleaseAndPlatform]);
- const handleDownload = async () => {
- if (!selectedAsset || !window.electronAPI) return;
+ const handleDownload = async (type: 'asset' | 'rocm' = 'asset') => {
+ if (!window.electronAPI) return;
+ if (type === 'asset' && !selectedAsset) return;
try {
setDownloading(true);
setDownloadProgress(0);
- setError(null);
+ setDownloadingType(type);
const result =
- await window.electronAPI.kobold.downloadRelease(selectedAsset);
+ type === 'rocm'
+ ? await window.electronAPI.kobold.downloadROCm()
+ : await window.electronAPI.kobold.downloadRelease(selectedAsset!);
if (result.success) {
- onInstallComplete();
+ onDownloadComplete();
} else {
- setError(result.error || 'Download failed');
+ notify.error(
+ 'Download Failed',
+ result.error || `${type === 'rocm' ? 'ROCm' : ''} Download failed`
+ );
}
} catch (err) {
- setError('Download failed');
- console.error('Download error:', err);
+ notify.error(
+ 'Download Failed',
+ `${type === 'rocm' ? 'ROCm' : ''} Download failed`
+ );
+ console.error(`${type === 'rocm' ? 'ROCm' : ''} Download error:`, err);
} finally {
setDownloading(false);
setDownloadProgress(0);
+ setDownloadingType(null);
}
};
- const handleDownloadROCm = async () => {
- if (!window.electronAPI) return;
+ const renderROCmCard = () => {
+ if (!rocmDownload) return null;
- try {
- setDownloadingROCm(true);
- setDownloadProgress(0);
- setError(null);
-
- const result = await window.electronAPI.kobold.downloadROCm();
-
- if (result.success) {
- onInstallComplete();
- } else {
- setError(result.error || 'ROCm download failed');
- }
- } catch (err) {
- setError('ROCm download failed');
- console.error('ROCm download error:', err);
- } finally {
- setDownloadingROCm(false);
- setDownloadProgress(0);
- }
+ return (
+ {
+ if (!selectedROCm) {
+ setSelectedROCm(true);
+ setSelectedAsset(null);
+ }
+ }}
+ onDownload={(e) => {
+ e.stopPropagation();
+ handleDownload('rocm');
+ }}
+ />
+ );
};
return (
-
+
- {error && (
- }
- color="red"
- variant="light"
- >
- {error}
-
- )}
-
- {downloading && selectedAsset && (
-
-
- Downloading {selectedAsset.name}...
-
-
- {downloadProgress.toFixed(1)}% complete
-
-
-
- )}
-
- Available Downloads for Your Platform
+ Available Binaries for Your Platform
{loading ? (
@@ -234,31 +210,7 @@ export const DownloadScreen = ({ onInstallComplete }: DownloadScreenProps) => {
{filteredAssets.length > 0 || rocmDownload ? (
- {rocmDownload && hasAMDGPU && (
- {
- if (!selectedROCm) {
- setSelectedROCm(true);
- setSelectedAsset(null);
- }
- }}
- onDownload={(e) => {
- e.stopPropagation();
- handleDownloadROCm();
- }}
- />
- )}
+ {rocmDownload && hasAMDGPU && renderROCmCard()}
{sortAssetsByRecommendation(
filteredAssets,
@@ -274,7 +226,18 @@ export const DownloadScreen = ({ onInstallComplete }: DownloadScreenProps) => {
asset.name,
hasAMDGPU
)}
- isDownloading={downloading}
+ isDownloading={
+ downloading &&
+ downloadingType === 'asset' &&
+ selectedAsset?.name === asset.name
+ }
+ downloadProgress={
+ downloading &&
+ downloadingType === 'asset' &&
+ selectedAsset?.name === asset.name
+ ? downloadProgress
+ : 0
+ }
onClick={() => {
if (selectedAsset?.name !== asset.name) {
setSelectedAsset(asset);
@@ -288,48 +251,18 @@ export const DownloadScreen = ({ onInstallComplete }: DownloadScreenProps) => {
/>
))}
- {rocmDownload && !hasAMDGPU && (
- {
- if (!selectedROCm) {
- setSelectedROCm(true);
- setSelectedAsset(null);
- }
- }}
- onDownload={(e) => {
- e.stopPropagation();
- handleDownloadROCm();
- }}
- />
- )}
+ {rocmDownload && !hasAMDGPU && renderROCmCard()}
) : (
- }
- color="yellow"
- variant="light"
- >
+
No downloads available
No downloads available for your platform (
- {getPlatformDisplayName(userPlatform)}). This might
- be a new release that doesn't have builds ready
- yet.
+ {getPlatformDisplayName(userPlatform)}).
-
+
)}
>
)}
diff --git a/src/screens/LaunchScreen.tsx b/src/screens/LaunchScreen.tsx
index bd24abf..4bfaac0 100644
--- a/src/screens/LaunchScreen.tsx
+++ b/src/screens/LaunchScreen.tsx
@@ -7,31 +7,57 @@ import {
Stack,
Group,
ActionIcon,
- Select,
+ Switch,
+ Slider,
+ TextInput,
+ NumberInput,
+ Tooltip,
+ Checkbox,
} from '@mantine/core';
-import { IconFile, IconRefresh } from '@tabler/icons-react';
+import { RotateCcw, File, Info } from 'lucide-react';
import { useState, useEffect, useCallback } from 'react';
+import { ConfigFileSelect } from '@/components/ConfigFileSelect';
+import { useLaunchConfig } from '@/hooks/useLaunchConfig';
+import type { ConfigFile } from '@/types';
-interface ConfigFile {
- name: string;
- path: string;
- size: number;
+interface LaunchScreenProps {
+ onLaunch: () => void;
}
-export const LaunchScreen = () => {
+export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
const [configFiles, setConfigFiles] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedFile, setSelectedFile] = useState(null);
const [, setInstallDir] = useState('');
+ const {
+ serverOnly,
+ gpuLayers,
+ autoGpuLayers,
+ contextSize,
+ modelPath,
+ additionalArguments,
+ parseAndApplyConfigFile,
+ loadSavedSettings,
+ loadConfigFromFile,
+ handleServerOnlyChange,
+ handleGpuLayersChange,
+ handleAutoGpuLayersChange,
+ handleContextSizeChangeWithStep,
+ handleModelPathChange,
+ handleSelectModelFile,
+ handleAdditionalArgumentsChange,
+ } = useLaunchConfig();
const loadConfigFiles = useCallback(async () => {
try {
setLoading(true);
+
const [files, currentDir, savedConfig] = await Promise.all([
window.electronAPI.kobold.getConfigFiles(),
window.electronAPI.kobold.getCurrentInstallDir(),
window.electronAPI.kobold.getSelectedConfig(),
]);
+
setConfigFiles(files);
setInstallDir(currentDir);
@@ -40,73 +66,78 @@ export const LaunchScreen = () => {
} else if (files.length > 0 && !selectedFile) {
setSelectedFile(files[0].name);
}
+
+ await loadSavedSettings();
+
+ const currentSelectedFile = await loadConfigFromFile(files, savedConfig);
+ if (currentSelectedFile && !selectedFile) {
+ setSelectedFile(currentSelectedFile);
+ }
} finally {
setLoading(false);
}
- }, [selectedFile]);
+ }, [selectedFile, loadSavedSettings, loadConfigFromFile]);
const handleFileSelection = async (fileName: string) => {
setSelectedFile(fileName);
await window.electronAPI.kobold.setSelectedConfig(fileName);
+
+ const selectedConfig = configFiles.find((f) => f.name === fileName);
+ if (selectedConfig) {
+ await parseAndApplyConfigFile(selectedConfig.path);
+ }
};
useEffect(() => {
void loadConfigFiles();
+
+ const handleInstallDirChange = () => {
+ void loadConfigFiles();
+ };
+
+ const cleanup = window.electronAPI.kobold.onInstallDirChanged(
+ handleInstallDirChange
+ );
+
+ return cleanup;
}, [loadConfigFiles]);
- const renderContent = () => {
- if (loading) {
- return (
-
- Loading configuration files...
-
- );
- }
-
- if (configFiles.length === 0) {
- return (
-
- No configuration files found in the installation directory.
-
- Please ensure your .kcpps or .kcppt files are in the correct location.
-
- );
- }
-
- const selectData = configFiles.map((file) => ({
- value: file.name,
- label: file.name,
- }));
-
- return (
-