mirror of
https://github.com/lone-cloud/gerbil
synced 2026-06-03 09:33:10 -07:00
fully refactoring from classes to functions, don't allow switching frontends when launched
This commit is contained in:
parent
e56b581109
commit
4d0bbf31ca
34 changed files with 3001 additions and 3835 deletions
|
|
@ -143,6 +143,7 @@ const config = [
|
|||
|
||||
'arrow-body-style': ['error', 'as-needed'],
|
||||
'prefer-arrow-callback': ['error', { allowNamedFunctions: false }],
|
||||
'object-shorthand': ['error', 'always'],
|
||||
|
||||
'no-console': 'error',
|
||||
|
||||
|
|
|
|||
21
package.json
21
package.json
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "gerbil",
|
||||
"productName": "Gerbil",
|
||||
"version": "1.0.6",
|
||||
"version": "1.1.0",
|
||||
"description": "Run Large Language Models locally",
|
||||
"main": "out/main/index.js",
|
||||
"homepage": "./",
|
||||
|
|
@ -67,9 +67,10 @@
|
|||
"@mantine/hooks": "^8.3.0",
|
||||
"axios": "^1.11.0",
|
||||
"execa": "^9.6.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
"lucide-react": "^0.543.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-error-boundary": "^6.0.0",
|
||||
"systeminformation": "^5.27.8",
|
||||
"winston": "^3.17.0",
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
|
|
@ -90,15 +91,7 @@
|
|||
"out/**/*",
|
||||
"dist/**/*"
|
||||
],
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "assets/gerbil.desktop",
|
||||
"to": "assets/gerbil.desktop",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"extraFiles": [],
|
||||
"mac": {
|
||||
"compression": "maximum",
|
||||
"category": "public.app-category.productivity",
|
||||
|
|
@ -163,6 +156,12 @@
|
|||
"StartupWMClass": "Gerbil"
|
||||
}
|
||||
},
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "assets/gerbil.desktop",
|
||||
"to": "assets/gerbil.desktop"
|
||||
}
|
||||
],
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
|
|
|
|||
|
|
@ -224,6 +224,7 @@ export const App = () => {
|
|||
/>
|
||||
</AppShell.Main>
|
||||
<SettingsModal
|
||||
isOnInterfaceScreen={currentScreen === 'interface'}
|
||||
opened={settingsModalOpen}
|
||||
onClose={async () => {
|
||||
setSettingsModalOpen(false);
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,106 +1,85 @@
|
|||
import { Component, ReactNode, ErrorInfo } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Center, Stack, Text, Button, Alert, rem } from '@mantine/core';
|
||||
import { AlertTriangle, FolderOpen } from 'lucide-react';
|
||||
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
|
||||
import { safeExecute } from '@/utils/logger';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
const ErrorFallback = ({
|
||||
error,
|
||||
resetErrorBoundary,
|
||||
}: {
|
||||
error: Error;
|
||||
resetErrorBoundary: () => void;
|
||||
}) => (
|
||||
<Center h="100%" style={{ minHeight: '25rem' }}>
|
||||
<Stack align="center" gap="lg" maw={500}>
|
||||
<AlertTriangle size={64} color="var(--mantine-color-red-6)" />
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
<Text size="xl" fw={600} ta="center">
|
||||
Application Error
|
||||
</Text>
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
<Alert color="red" title="Something went wrong" w="100%">
|
||||
<Text size="sm" mb="md">
|
||||
The application encountered an unexpected error and crashed. You can
|
||||
view the error details in the logs folder.
|
||||
</Text>
|
||||
|
||||
componentDidCatch(error: Error, _errorInfo: ErrorInfo) {
|
||||
window.electronAPI?.logs?.logError(
|
||||
'App crashed with unhandled error:',
|
||||
error
|
||||
);
|
||||
}
|
||||
<Text size="xs" c="dimmed" style={{ fontFamily: 'monospace' }} mb="sm">
|
||||
{error.message}
|
||||
</Text>
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Center h="100%" style={{ minHeight: '25rem' }}>
|
||||
<Stack align="center" gap="lg" maw={500}>
|
||||
<AlertTriangle size={64} color="var(--mantine-color-red-6)" />
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
leftSection={
|
||||
<FolderOpen style={{ width: rem(16), height: rem(16) }} />
|
||||
}
|
||||
onClick={async () => {
|
||||
await safeExecute(
|
||||
() => window.electronAPI.app.showLogsFolder(),
|
||||
'Failed to open logs folder'
|
||||
);
|
||||
}}
|
||||
>
|
||||
Show Logs Folder
|
||||
</Button>
|
||||
</Alert>
|
||||
|
||||
<Text size="xl" fw={600} ta="center">
|
||||
Application Error
|
||||
</Text>
|
||||
<Stack gap="sm" w="100%">
|
||||
<Button onClick={resetErrorBoundary} variant="filled" fullWidth>
|
||||
Try Again
|
||||
</Button>
|
||||
|
||||
<Alert color="red" title="Something went wrong" w="100%">
|
||||
<Text size="sm" mb="md">
|
||||
The application encountered an unexpected error and crashed. You
|
||||
can view the error details in the logs folder.
|
||||
</Text>
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.electronAPI?.app?.closeWindow();
|
||||
}}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
fullWidth
|
||||
>
|
||||
Close Application
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
|
||||
{this.state.error && (
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
mb="sm"
|
||||
>
|
||||
{this.state.error.message}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
leftSection={
|
||||
<FolderOpen style={{ width: rem(16), height: rem(16) }} />
|
||||
}
|
||||
onClick={async () => {
|
||||
await safeExecute(
|
||||
() => window.electronAPI.app.showLogsFolder(),
|
||||
'Failed to open logs folder'
|
||||
);
|
||||
}}
|
||||
>
|
||||
Show Logs Folder
|
||||
</Button>
|
||||
</Alert>
|
||||
|
||||
<Stack gap="sm" w="100%">
|
||||
<Button
|
||||
onClick={() => {
|
||||
this.setState({ hasError: false, error: undefined });
|
||||
}}
|
||||
variant="filled"
|
||||
fullWidth
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.electronAPI?.app?.closeWindow();
|
||||
}}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
fullWidth
|
||||
>
|
||||
Close Application
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Center>
|
||||
export const ErrorBoundary = ({ children }: Props) => (
|
||||
<ReactErrorBoundary
|
||||
FallbackComponent={ErrorFallback}
|
||||
onError={(error) => {
|
||||
window.electronAPI?.logs?.logError(
|
||||
'App crashed with unhandled error:',
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactErrorBoundary>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
|
|||
};
|
||||
|
||||
const buildConfigData = () => ({
|
||||
autoGpuLayers: autoGpuLayers,
|
||||
autoGpuLayers,
|
||||
gpulayers: gpuLayers,
|
||||
contextsize: contextSize,
|
||||
model,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,13 @@ interface FrontendConfig {
|
|||
requirementCheck?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const GeneralTab = () => {
|
||||
interface GeneralTabProps {
|
||||
isOnInterfaceScreen?: boolean;
|
||||
}
|
||||
|
||||
export const GeneralTab = ({
|
||||
isOnInterfaceScreen = false,
|
||||
}: GeneralTabProps) => {
|
||||
const [installDir, setInstallDir] = useState<string>('');
|
||||
const [FrontendPreference, setFrontendPreference] =
|
||||
useState<FrontendPreference>('koboldcpp');
|
||||
|
|
@ -245,6 +251,7 @@ export const GeneralTab = () => {
|
|||
<Select
|
||||
value={FrontendPreference}
|
||||
onChange={handleFrontendPreferenceChange}
|
||||
disabled={isOnInterfaceScreen}
|
||||
data={frontendConfigs.map((config) => ({
|
||||
value: config.value,
|
||||
label: config.label,
|
||||
|
|
|
|||
|
|
@ -18,12 +18,14 @@ interface SettingsModalProps {
|
|||
opened: boolean;
|
||||
onClose: () => void;
|
||||
currentScreen?: Screen;
|
||||
isOnInterfaceScreen?: boolean;
|
||||
}
|
||||
|
||||
export const SettingsModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
currentScreen,
|
||||
isOnInterfaceScreen = false,
|
||||
}: SettingsModalProps) => {
|
||||
const [activeTab, setActiveTab] = useState('general');
|
||||
|
||||
|
|
@ -140,7 +142,7 @@ export const SettingsModal = ({
|
|||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="general">
|
||||
<GeneralTab />
|
||||
<GeneralTab isOnInterfaceScreen={isOnInterfaceScreen} />
|
||||
</Tabs.Panel>
|
||||
|
||||
{showVersionsTab && (
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ export const DEFAULT_CONTEXT_SIZE = 4096;
|
|||
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';
|
||||
|
||||
export const DEFAULT_AUTO_GPU_LAYERS = false;
|
||||
|
||||
export const SILLYTAVERN = {
|
||||
PORT: 3000,
|
||||
PROXY_PORT: 3001,
|
||||
|
|
|
|||
176
src/main/cli.ts
176
src/main/cli.ts
|
|
@ -5,97 +5,95 @@ import { terminateProcess } from '@/utils/process';
|
|||
import { pathExists, readJsonFile } from '@/utils/fs';
|
||||
import { getConfigDir } from '@/utils/path';
|
||||
|
||||
export class LightweightCliHandler {
|
||||
private async getCurrentKoboldBinary(): Promise<string | null> {
|
||||
try {
|
||||
const configPath = getConfigDir();
|
||||
if (!(await pathExists(configPath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = await readJsonFile<{ currentKoboldBinary?: string }>(
|
||||
configPath
|
||||
);
|
||||
return config?.currentKoboldBinary || null;
|
||||
} catch {
|
||||
async function getCurrentKoboldBinary(): Promise<string | null> {
|
||||
try {
|
||||
const configPath = getConfigDir();
|
||||
if (!(await pathExists(configPath))) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async handleCliMode(args: string[]): Promise<void> {
|
||||
const currentBinary = await this.getCurrentKoboldBinary();
|
||||
|
||||
if (!currentBinary) {
|
||||
console.error(
|
||||
'Error: No binary found. Please run the GUI first to download the binary.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!(await pathExists(currentBinary))) {
|
||||
console.error(`Error: Binary not found at: ${currentBinary}`);
|
||||
console.error('Please run the GUI to download and configure the binary.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
const child = spawn(currentBinary, args, {
|
||||
stdio: isWindows ? 'pipe' : 'inherit',
|
||||
detached: false,
|
||||
});
|
||||
|
||||
if (isWindows) {
|
||||
child.stdout?.setEncoding('utf8');
|
||||
child.stderr?.setEncoding('utf8');
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
process.stdout.write(data.toString());
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
process.stderr.write(data.toString());
|
||||
});
|
||||
|
||||
if (child.stdin && process.stdin.readable) {
|
||||
process.stdin.pipe(child.stdin);
|
||||
}
|
||||
}
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
console.log(`\nProcess terminated with signal: ${signal}`);
|
||||
process.exit(128 + (signal === 'SIGTERM' ? 15 : 2));
|
||||
} else if (code !== null) {
|
||||
process.exit(code);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(`Failed to start: ${error.message}`);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
const handleSignal = async () => {
|
||||
console.log('\nReceived termination signal, terminating...');
|
||||
if (!child.killed) {
|
||||
await terminateProcess(child, {
|
||||
timeoutMs: 5000,
|
||||
logError: (message, error) => {
|
||||
console.error(`${message} ${error.message}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGINT', handleSignal);
|
||||
process.on('SIGTERM', handleSignal);
|
||||
if (process.platform === 'win32') {
|
||||
process.on('SIGBREAK', handleSignal);
|
||||
}
|
||||
});
|
||||
const config = await readJsonFile<{ currentKoboldBinary?: string }>(
|
||||
configPath
|
||||
);
|
||||
return config?.currentKoboldBinary || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleCliMode(args: string[]): Promise<void> {
|
||||
const currentBinary = await getCurrentKoboldBinary();
|
||||
|
||||
if (!currentBinary) {
|
||||
console.error(
|
||||
'Error: No binary found. Please run the GUI first to download the binary.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!(await pathExists(currentBinary))) {
|
||||
console.error(`Error: Binary not found at: ${currentBinary}`);
|
||||
console.error('Please run the GUI to download and configure the binary.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
const child = spawn(currentBinary, args, {
|
||||
stdio: isWindows ? 'pipe' : 'inherit',
|
||||
detached: false,
|
||||
});
|
||||
|
||||
if (isWindows) {
|
||||
child.stdout?.setEncoding('utf8');
|
||||
child.stderr?.setEncoding('utf8');
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
process.stdout.write(data.toString());
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
process.stderr.write(data.toString());
|
||||
});
|
||||
|
||||
if (child.stdin && process.stdin.readable) {
|
||||
process.stdin.pipe(child.stdin);
|
||||
}
|
||||
}
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
console.log(`\nProcess terminated with signal: ${signal}`);
|
||||
process.exit(128 + (signal === 'SIGTERM' ? 15 : 2));
|
||||
} else if (code !== null) {
|
||||
process.exit(code);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(`Failed to start: ${error.message}`);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
const handleSignal = async () => {
|
||||
console.log('\nReceived termination signal, terminating...');
|
||||
if (!child.killed) {
|
||||
await terminateProcess(child, {
|
||||
timeoutMs: 5000,
|
||||
logError: (message, error) => {
|
||||
console.error(`${message} ${error.message}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGINT', handleSignal);
|
||||
process.on('SIGTERM', handleSignal);
|
||||
if (process.platform === 'win32') {
|
||||
process.on('SIGBREAK', handleSignal);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
122
src/main/gui.ts
122
src/main/gui.ts
|
|
@ -1,89 +1,67 @@
|
|||
import { app } from 'electron';
|
||||
|
||||
import { getWindowManager } from '@/main/managers/WindowManager';
|
||||
import { getConfigManager } from '@/main/managers/ConfigManager';
|
||||
import { getLogManager } from '@/main/managers/LogManager';
|
||||
import { getKoboldCppManager } from '@/main/managers/KoboldCppManager';
|
||||
import { getSillyTavernManager } from '@/main/managers/SillyTavernManager';
|
||||
import { getOpenWebUIManager } from '@/main/managers/OpenWebUIManager';
|
||||
import { getHardwareManager } from '@/main/managers/HardwareManager';
|
||||
import { getBinaryManager } from '@/main/managers/BinaryManager';
|
||||
import { IPCHandlers } from '@/main/ipc';
|
||||
import {
|
||||
createMainWindow,
|
||||
cleanup as cleanupWindow,
|
||||
} from '@/main/modules/window';
|
||||
import {
|
||||
initialize as initializeConfig,
|
||||
getInstallDir,
|
||||
} from '@/main/modules/config';
|
||||
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 { getConfigDir } from '@/utils/path';
|
||||
|
||||
export class GerbilApp {
|
||||
private ipcHandlers: IPCHandlers;
|
||||
export async function initializeApp(): Promise<void> {
|
||||
const installDir = getInstallDir();
|
||||
|
||||
constructor() {
|
||||
this.ipcHandlers = new IPCHandlers(
|
||||
getKoboldCppManager(),
|
||||
getConfigManager(getConfigDir()),
|
||||
getHardwareManager(),
|
||||
getBinaryManager(),
|
||||
getLogManager(),
|
||||
getSillyTavernManager(),
|
||||
getOpenWebUIManager(),
|
||||
getWindowManager()
|
||||
);
|
||||
}
|
||||
await app.whenReady();
|
||||
await initializeConfig();
|
||||
await ensureDir(installDir);
|
||||
|
||||
private async ensureInstallDirectory(): Promise<void> {
|
||||
const installDir = getConfigManager().getInstallDir();
|
||||
await ensureDir(installDir);
|
||||
}
|
||||
setupIPCHandlers();
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await app.whenReady();
|
||||
await getConfigManager().initialize();
|
||||
await this.ensureInstallDirectory();
|
||||
createMainWindow();
|
||||
|
||||
getWindowManager().createMainWindow();
|
||||
this.ipcHandlers.setupHandlers();
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform === 'darwin') {
|
||||
return;
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform === 'darwin') {
|
||||
return;
|
||||
}
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.quit();
|
||||
});
|
||||
app.on('before-quit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
app.on('before-quit', async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const cleanupPromises = [
|
||||
cleanup(),
|
||||
getSillyTavernManager().cleanup(),
|
||||
cleanupOpenWebUI(),
|
||||
];
|
||||
|
||||
try {
|
||||
const cleanupPromises = [
|
||||
getKoboldCppManager().cleanup(),
|
||||
getSillyTavernManager().cleanup(),
|
||||
getOpenWebUIManager().cleanup(),
|
||||
];
|
||||
const timeoutPromise = new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 10000);
|
||||
});
|
||||
await Promise.race([Promise.all(cleanupPromises), timeoutPromise]);
|
||||
} catch (error) {
|
||||
logError('Error during cleanup:', error as Error);
|
||||
}
|
||||
|
||||
await Promise.race([Promise.all(cleanupPromises), timeoutPromise]);
|
||||
} catch (error) {
|
||||
getLogManager().logError('Error during cleanup:', error as Error);
|
||||
}
|
||||
cleanupWindow();
|
||||
|
||||
getWindowManager().cleanup();
|
||||
app.exit(0);
|
||||
});
|
||||
|
||||
app.exit(0);
|
||||
});
|
||||
|
||||
app.on('will-quit', async (event) => {
|
||||
event.preventDefault();
|
||||
app.exit(0);
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (!getWindowManager().getMainWindow()) {
|
||||
getWindowManager().createMainWindow();
|
||||
}
|
||||
});
|
||||
}
|
||||
app.on('will-quit', async (event) => {
|
||||
event.preventDefault();
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,9 +23,8 @@ if (process.argv[1] === '--version') {
|
|||
import('./cli')
|
||||
.then(async (cliModule) => {
|
||||
const args = process.argv.slice(process.argv.indexOf('--cli') + 1);
|
||||
const handler = new cliModule.LightweightCliHandler();
|
||||
try {
|
||||
await handler.handleCliMode(args);
|
||||
await cliModule.handleCliMode(args);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('CLI mode error:', error);
|
||||
|
|
@ -39,8 +38,7 @@ if (process.argv[1] === '--version') {
|
|||
});
|
||||
} else {
|
||||
import('./gui').then((guiModule) => {
|
||||
const app = new guiModule.GerbilApp();
|
||||
app.initialize().catch((error: unknown) => {
|
||||
guiModule.initializeApp().catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to initialize Gerbil:', error);
|
||||
});
|
||||
|
|
|
|||
450
src/main/ipc.ts
450
src/main/ipc.ts
|
|
@ -1,250 +1,220 @@
|
|||
import { ipcMain, shell, app } from 'electron';
|
||||
import * as os from 'os';
|
||||
import type { KoboldCppManager } from '@/main/managers/KoboldCppManager';
|
||||
import type { ConfigManager } from '@/main/managers/ConfigManager';
|
||||
import type { LogManager } from '@/main/managers/LogManager';
|
||||
import type { SillyTavernManager } from '@/main/managers/SillyTavernManager';
|
||||
import type { OpenWebUIManager } from '@/main/managers/OpenWebUIManager';
|
||||
import type { WindowManager } from '@/main/managers/WindowManager';
|
||||
import { HardwareManager } from '@/main/managers/HardwareManager';
|
||||
import { BinaryManager } from '@/main/managers/BinaryManager';
|
||||
import {
|
||||
launchKoboldCpp,
|
||||
downloadRelease,
|
||||
getInstalledVersions,
|
||||
getCurrentVersion,
|
||||
getConfigFiles,
|
||||
saveConfigFile,
|
||||
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';
|
||||
|
||||
export class IPCHandlers {
|
||||
private koboldManager: KoboldCppManager;
|
||||
private configManager: ConfigManager;
|
||||
private logManager: LogManager;
|
||||
private sillyTavernManager: SillyTavernManager;
|
||||
private openWebUIManager: OpenWebUIManager;
|
||||
private hardwareManager: HardwareManager;
|
||||
private binaryManager: BinaryManager;
|
||||
private windowManager: WindowManager;
|
||||
async function launchKoboldCppWithCustomFrontends(args: string[] = []) {
|
||||
try {
|
||||
const frontendPreference = (await getConfig(
|
||||
'frontendPreference'
|
||||
)) as FrontendPreference;
|
||||
|
||||
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;
|
||||
}
|
||||
const result = await launchKoboldCpp(args, frontendPreference);
|
||||
|
||||
private async launchKoboldCppWithCustomFrontends(args: string[] = []) {
|
||||
try {
|
||||
const frontendPreference = (await this.configManager.get(
|
||||
'frontendPreference'
|
||||
)) as FrontendPreference;
|
||||
|
||||
const result = await this.koboldManager.launchKoboldCpp(
|
||||
args,
|
||||
frontendPreference
|
||||
);
|
||||
|
||||
if (frontendPreference === 'sillytavern') {
|
||||
this.sillyTavernManager.startFrontend(args);
|
||||
} else if (frontendPreference === 'openwebui') {
|
||||
this.openWebUIManager.startFrontend(args);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logManager.logError('Error in enhanced launch:', error as Error);
|
||||
return {
|
||||
success: false,
|
||||
error: (error as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setupHandlers() {
|
||||
ipcMain.handle('kobold:downloadRelease', async (_, asset) => ({
|
||||
success: true,
|
||||
path: await this.koboldManager.downloadRelease(asset),
|
||||
}));
|
||||
|
||||
ipcMain.handle('kobold:getInstalledVersions', () =>
|
||||
this.koboldManager.getInstalledVersions()
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:getCurrentVersion', () =>
|
||||
this.koboldManager.getCurrentVersion()
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:getConfigFiles', () =>
|
||||
this.koboldManager.getConfigFiles()
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:saveConfigFile', async (_, configName, configData) =>
|
||||
this.koboldManager.saveConfigFile(configName, configData)
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:getSelectedConfig', () =>
|
||||
this.configManager.getSelectedConfig()
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:setSelectedConfig', (_, configName) =>
|
||||
this.configManager.setSelectedConfig(configName)
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:setCurrentVersion', (_, version) =>
|
||||
this.koboldManager.setCurrentVersion(version)
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:getCurrentInstallDir', () =>
|
||||
this.configManager.getInstallDir()
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:selectInstallDirectory', () =>
|
||||
this.koboldManager.selectInstallDirectory()
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:detectGPU', () => this.hardwareManager.detectGPU());
|
||||
|
||||
ipcMain.handle('kobold:detectCPU', () => this.hardwareManager.detectCPU());
|
||||
|
||||
ipcMain.handle('kobold:detectGPUCapabilities', () =>
|
||||
this.hardwareManager.detectGPUCapabilities()
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:detectGPUMemory', () =>
|
||||
this.hardwareManager.detectGPUMemory()
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:detectROCm', () =>
|
||||
this.hardwareManager.detectROCm()
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:detectBackendSupport', () =>
|
||||
this.binaryManager.detectBackendSupport(this.koboldManager)
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
'kobold:getAvailableBackends',
|
||||
(_, includeDisabled = false) =>
|
||||
this.binaryManager.getAvailableBackends(
|
||||
this.koboldManager,
|
||||
includeDisabled
|
||||
)
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:getPlatform', () => process.platform);
|
||||
|
||||
ipcMain.handle('kobold:launchKoboldCpp', (_, args) =>
|
||||
this.launchKoboldCppWithCustomFrontends(args)
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:stopKoboldCpp', () => {
|
||||
this.koboldManager.stopKoboldCpp();
|
||||
this.sillyTavernManager.stopFrontend();
|
||||
this.openWebUIManager.stopFrontend();
|
||||
});
|
||||
|
||||
ipcMain.handle('kobold:parseConfigFile', (_, filePath) =>
|
||||
this.koboldManager.parseConfigFile(filePath)
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:selectModelFile', (_, title) =>
|
||||
this.koboldManager.selectModelFile(title)
|
||||
);
|
||||
|
||||
ipcMain.handle('config:get', (_, key) => this.configManager.get(key));
|
||||
|
||||
ipcMain.handle('config:set', (_, key, value) =>
|
||||
this.configManager.set(key, value)
|
||||
);
|
||||
|
||||
ipcMain.handle('app:getVersion', () => app.getVersion());
|
||||
|
||||
ipcMain.handle('app:getVersionInfo', () => ({
|
||||
appVersion: app.getVersion(),
|
||||
electronVersion: process.versions.electron,
|
||||
nodeVersion: process.versions.node,
|
||||
chromeVersion: process.versions.chrome,
|
||||
v8Version: process.versions.v8,
|
||||
osVersion: os.release(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
}));
|
||||
|
||||
ipcMain.handle('app:showLogsFolder', async () => {
|
||||
try {
|
||||
const logsDir = this.logManager.getLogsDirectory();
|
||||
await shell.openPath(logsDir);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
this.logManager.logError('Failed to open logs folder:', error as Error);
|
||||
throw new Error(
|
||||
`Failed to open logs folder: ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('app:minimizeWindow', () => {
|
||||
const mainWindow = this.windowManager.getMainWindow();
|
||||
mainWindow?.minimize();
|
||||
});
|
||||
|
||||
ipcMain.handle('app:maximizeWindow', () => {
|
||||
const mainWindow = this.windowManager.getMainWindow();
|
||||
if (mainWindow?.isMaximized()) {
|
||||
mainWindow.restore();
|
||||
} else {
|
||||
mainWindow?.maximize();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('app:closeWindow', () => {
|
||||
const mainWindow = this.windowManager.getMainWindow();
|
||||
mainWindow?.close();
|
||||
});
|
||||
|
||||
ipcMain.handle('app:getZoomLevel', async () => {
|
||||
const mainWindow = this.windowManager.getMainWindow();
|
||||
if (mainWindow) {
|
||||
return mainWindow.webContents.getZoomLevel();
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
ipcMain.handle('app:setZoomLevel', async (_, level: number) => {
|
||||
const mainWindow = this.windowManager.getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.setZoomLevel(level);
|
||||
await this.configManager.set('zoomLevel', level);
|
||||
}
|
||||
});
|
||||
|
||||
const mainWindow = this.windowManager.getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.once('did-finish-load', async () => {
|
||||
const savedZoomLevel = await this.configManager.get('zoomLevel');
|
||||
if (typeof savedZoomLevel === 'number') {
|
||||
mainWindow.webContents.setZoomLevel(savedZoomLevel);
|
||||
}
|
||||
});
|
||||
if (frontendPreference === 'sillytavern') {
|
||||
getSillyTavernManager().startFrontend(args);
|
||||
} else if (frontendPreference === 'openwebui') {
|
||||
startOpenWebUIFrontend(args);
|
||||
}
|
||||
|
||||
ipcMain.handle('logs:logError', (_, message: string, error?: Error) => {
|
||||
this.logManager.logError(message, error);
|
||||
});
|
||||
|
||||
ipcMain.handle('sillytavern:isNpxAvailable', () =>
|
||||
this.sillyTavernManager.isNpxAvailable()
|
||||
);
|
||||
|
||||
ipcMain.handle('openwebui:isUvAvailable', () =>
|
||||
this.openWebUIManager.isUvAvailable()
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logError('Error in enhanced launch:', error as Error);
|
||||
return {
|
||||
success: false,
|
||||
error: (error as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function setupIPCHandlers() {
|
||||
ipcMain.handle('kobold:downloadRelease', async (_, asset) => ({
|
||||
success: true,
|
||||
path: await downloadRelease(asset),
|
||||
}));
|
||||
|
||||
ipcMain.handle('kobold:getInstalledVersions', () => getInstalledVersions());
|
||||
|
||||
ipcMain.handle('kobold:getCurrentVersion', () => getCurrentVersion());
|
||||
|
||||
ipcMain.handle('kobold:getConfigFiles', () => getConfigFiles());
|
||||
|
||||
ipcMain.handle('kobold:saveConfigFile', async (_, configName, configData) =>
|
||||
saveConfigFile(configName, configData)
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:getSelectedConfig', () => getSelectedConfig());
|
||||
|
||||
ipcMain.handle('kobold:setSelectedConfig', (_, configName) =>
|
||||
setSelectedConfig(configName)
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:setCurrentVersion', (_, version) =>
|
||||
setCurrentVersion(version)
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:getCurrentInstallDir', () => getInstallDir());
|
||||
|
||||
ipcMain.handle('kobold:selectInstallDirectory', () =>
|
||||
selectInstallDirectory()
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:detectGPU', () => detectGPU());
|
||||
|
||||
ipcMain.handle('kobold:detectCPU', () => detectCPU());
|
||||
|
||||
ipcMain.handle('kobold:detectGPUCapabilities', () => detectGPUCapabilities());
|
||||
|
||||
ipcMain.handle('kobold:detectGPUMemory', () => detectGPUMemory());
|
||||
|
||||
ipcMain.handle('kobold:detectROCm', () => detectROCm());
|
||||
|
||||
ipcMain.handle('kobold:detectBackendSupport', () => detectBackendSupport());
|
||||
|
||||
ipcMain.handle('kobold:getAvailableBackends', (_, includeDisabled = false) =>
|
||||
getAvailableBackends(includeDisabled)
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:getPlatform', () => process.platform);
|
||||
|
||||
ipcMain.handle('kobold:launchKoboldCpp', (_, args) =>
|
||||
launchKoboldCppWithCustomFrontends(args)
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:stopKoboldCpp', () => {
|
||||
stopKoboldCpp();
|
||||
getSillyTavernManager().stopFrontend();
|
||||
stopOpenWebUIFrontend();
|
||||
});
|
||||
|
||||
ipcMain.handle('kobold:parseConfigFile', (_, filePath) =>
|
||||
parseConfigFile(filePath)
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:selectModelFile', (_, title) =>
|
||||
selectModelFile(title)
|
||||
);
|
||||
|
||||
ipcMain.handle('config:get', (_, key) => getConfig(key));
|
||||
|
||||
ipcMain.handle('config:set', (_, key, value) => setConfig(key, value));
|
||||
|
||||
ipcMain.handle('app:getVersion', () => app.getVersion());
|
||||
|
||||
ipcMain.handle('app:getVersionInfo', () => ({
|
||||
appVersion: app.getVersion(),
|
||||
electronVersion: process.versions.electron,
|
||||
nodeVersion: process.versions.node,
|
||||
chromeVersion: process.versions.chrome,
|
||||
v8Version: process.versions.v8,
|
||||
osVersion: os.release(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
}));
|
||||
|
||||
ipcMain.handle('app:showLogsFolder', async () => {
|
||||
try {
|
||||
const logsDir = getLogsDirectory();
|
||||
await shell.openPath(logsDir);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logError('Failed to open logs folder:', error as Error);
|
||||
throw new Error(
|
||||
`Failed to open logs folder: ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('app:minimizeWindow', () => {
|
||||
const mainWindow = getMainWindow();
|
||||
mainWindow?.minimize();
|
||||
});
|
||||
|
||||
ipcMain.handle('app:maximizeWindow', () => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow?.isMaximized()) {
|
||||
mainWindow.restore();
|
||||
} else {
|
||||
mainWindow?.maximize();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('app:closeWindow', () => {
|
||||
const mainWindow = getMainWindow();
|
||||
mainWindow?.close();
|
||||
});
|
||||
|
||||
ipcMain.handle('app:getZoomLevel', async () => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
return mainWindow.webContents.getZoomLevel();
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
ipcMain.handle('app:setZoomLevel', async (_, level: number) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.setZoomLevel(level);
|
||||
await setConfig('zoomLevel', level);
|
||||
}
|
||||
});
|
||||
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.once('did-finish-load', async () => {
|
||||
const savedZoomLevel = await getConfig('zoomLevel');
|
||||
if (typeof savedZoomLevel === 'number') {
|
||||
mainWindow.webContents.setZoomLevel(savedZoomLevel);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle('logs:logError', (_, message: string, error?: Error) => {
|
||||
logError(message, error);
|
||||
});
|
||||
|
||||
ipcMain.handle('sillytavern:isNpxAvailable', () =>
|
||||
getSillyTavernManager().isNpxAvailable()
|
||||
);
|
||||
|
||||
ipcMain.handle('openwebui:isUvAvailable', () => isUvAvailable());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
175
src/main/modules/binary.ts
Normal 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' }];
|
||||
}
|
||||
}
|
||||
94
src/main/modules/config.ts
Normal file
94
src/main/modules/config.ts
Normal 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();
|
||||
}
|
||||
472
src/main/modules/hardware.ts
Normal file
472
src/main/modules/hardware.ts
Normal 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;
|
||||
}
|
||||
740
src/main/modules/koboldcpp.ts
Normal file
740
src/main/modules/koboldcpp.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
95
src/main/modules/logging.ts
Normal file
95
src/main/modules/logging.ts
Normal 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');
|
||||
242
src/main/modules/openwebui.ts
Normal file
242
src/main/modules/openwebui.ts
Normal 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();
|
||||
}
|
||||
522
src/main/modules/sillytavern.ts
Normal file
522
src/main/modules/sillytavern.ts
Normal 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
195
src/main/modules/window.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { create } from 'zustand';
|
||||
import type { ConfigFile, SdConvDirectMode } from '@/types';
|
||||
import type { ImageModelPreset } from '@/constants/imageModelPresets';
|
||||
import { DEFAULT_CONTEXT_SIZE } from '@/constants';
|
||||
import { DEFAULT_AUTO_GPU_LAYERS, DEFAULT_CONTEXT_SIZE } from '@/constants';
|
||||
|
||||
interface LaunchConfigState {
|
||||
gpuLayers: number;
|
||||
|
|
@ -92,7 +92,7 @@ interface LaunchConfigState {
|
|||
|
||||
export const useLaunchConfigStore = create<LaunchConfigState>((set, get) => ({
|
||||
gpuLayers: 0,
|
||||
autoGpuLayers: true,
|
||||
autoGpuLayers: DEFAULT_AUTO_GPU_LAYERS,
|
||||
contextSize: DEFAULT_CONTEXT_SIZE,
|
||||
model: '',
|
||||
additionalArguments: '',
|
||||
|
|
@ -181,7 +181,7 @@ export const useLaunchConfigStore = create<LaunchConfigState>((set, get) => ({
|
|||
if (typeof configData.autoGpuLayers === 'boolean') {
|
||||
updates.autoGpuLayers = configData.autoGpuLayers;
|
||||
} else {
|
||||
updates.autoGpuLayers = true;
|
||||
updates.autoGpuLayers = DEFAULT_AUTO_GPU_LAYERS;
|
||||
}
|
||||
|
||||
if (typeof configData.gpulayers === 'number') {
|
||||
|
|
|
|||
1
src/utils/environment.ts
Normal file
1
src/utils/environment.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
|
|
@ -1,15 +1,7 @@
|
|||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { app } from 'electron';
|
||||
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 {
|
||||
return join(getConfigDirPath(), CONFIG_FILE_NAME);
|
||||
}
|
||||
|
|
|
|||
24
yarn.lock
24
yarn.lock
|
|
@ -191,7 +191,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/runtime@npm:^7.20.13":
|
||||
"@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.20.13":
|
||||
version: 7.28.4
|
||||
resolution: "@babel/runtime@npm:7.28.4"
|
||||
checksum: 10c0/792ce7af9750fb9b93879cc9d1db175701c4689da890e6ced242ea0207c9da411ccf16dc04e689cc01158b28d7898c40d75598f4559109f761c12ce01e959bf7
|
||||
|
|
@ -3649,10 +3649,11 @@ __metadata:
|
|||
execa: "npm:^9.6.0"
|
||||
globals: "npm:^16.3.0"
|
||||
jiti: "npm:^2.5.1"
|
||||
lucide-react: "npm:^0.542.0"
|
||||
lucide-react: "npm:^0.543.0"
|
||||
prettier: "npm:^3.6.2"
|
||||
react: "npm:^19.1.1"
|
||||
react-dom: "npm:^19.1.1"
|
||||
react-error-boundary: "npm:^6.0.0"
|
||||
rollup-plugin-visualizer: "npm:^6.0.3"
|
||||
systeminformation: "npm:^5.27.8"
|
||||
typescript: "npm:^5.9.2"
|
||||
|
|
@ -4766,12 +4767,12 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lucide-react@npm:^0.542.0":
|
||||
version: 0.542.0
|
||||
resolution: "lucide-react@npm:0.542.0"
|
||||
"lucide-react@npm:^0.543.0":
|
||||
version: 0.543.0
|
||||
resolution: "lucide-react@npm:0.543.0"
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
checksum: 10c0/3ccdb898a480f0194f93abef0b6f0347bc2647db24051e65a17c1dbd22ca0d10a7636d9af68c92665b42b6c9e761567a0d65944fe8568fb0e30a7ea57281ccec
|
||||
checksum: 10c0/76bb54f9c602ff5d44ec66f2786c035d92aaabca1e1abb380b0c8101dfb3290e52c82fa4d630e286ef07c05b63696e187f0ca3ae186535d9c5b4de7aa8825bd3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
@ -5686,6 +5687,17 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 16.13.1
|
||||
resolution: "react-is@npm:16.13.1"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue