diff --git a/package.json b/package.json index b76a623..74ed78e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gerbil", "productName": "Gerbil", - "version": "0.9.7", + "version": "0.9.8", "description": "Run Large Language Models locally", "main": "out/main/index.js", "homepage": "./", diff --git a/src/App.tsx b/src/App.tsx index b1ea04f..1c5d3b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { SettingsModal } from '@/components/settings/SettingsModal'; import { EjectConfirmModal } from '@/components/EjectConfirmModal'; import { ScreenTransition } from '@/components/ScreenTransition'; import { TitleBar } from '@/components/TitleBar'; +import { ErrorBoundary } from '@/components/ErrorBoundary'; import { useUpdateChecker } from '@/hooks/useUpdateChecker'; import { useKoboldVersions } from '@/hooks/useKoboldVersions'; import { useModalStore } from '@/stores/modal'; @@ -162,50 +163,52 @@ export const App = () => { /> - {currentScreen === null ? ( -
- - - - Loading... - - -
- ) : ( - <> - - - + + {currentScreen === null ? ( +
+ + + + Loading... + + +
+ ) : ( + <> + + + - - - + + + - - - + + + - - - - - )} + + + + + )} +
{ + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, _errorInfo: ErrorInfo) { + window.electronAPI?.logs?.logError( + 'App crashed with unhandled error:', + error + ); + } + + render() { + if (this.state.hasError) { + return ( +
+ + + + + Application Error + + + + + The application encountered an unexpected error and crashed. You + can view the error details in the logs folder. + + + {this.state.error && ( + + {this.state.error.message} + + )} + + + + + + + + + + +
+ ); + } + + return this.props.children; + } +} diff --git a/src/components/settings/VersionsTab.tsx b/src/components/settings/VersionsTab.tsx index 846ffe5..7e3d457 100644 --- a/src/components/settings/VersionsTab.tsx +++ b/src/components/settings/VersionsTab.tsx @@ -161,7 +161,12 @@ export const VersionsTab = () => { } }); - return versions; + return versions.sort((a, b) => { + if (a.isInstalled && !b.isInstalled) return -1; + if (!a.isInstalled && b.isInstalled) return 1; + + return 0; + }); }, [availableDownloads, installedVersions, currentVersion]); useEffect(() => { diff --git a/src/main/managers/OpenWebUIManager.ts b/src/main/managers/OpenWebUIManager.ts index c0cbcb4..056f632 100644 --- a/src/main/managers/OpenWebUIManager.ts +++ b/src/main/managers/OpenWebUIManager.ts @@ -1,6 +1,8 @@ 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 { LogManager } from './LogManager'; import { WindowManager } from './WindowManager'; @@ -48,9 +50,30 @@ export class OpenWebUIManager { }); } + private async getUvEnvironment(): Promise< + Record + > { + const env = { ...process.env }; + + const cargoPath = join(homedir(), '.cargo', 'bin'); + + try { + await access(cargoPath); + env.PATH = + process.platform === 'win32' + ? `${cargoPath};${env.PATH}` + : `${cargoPath}:${env.PATH}`; + } catch { + return env; + } + + return env; + } + async isUvAvailable(): Promise { try { - const testProcess = spawn('uv', ['--version'], { stdio: 'pipe' }); + const env = await this.getUvEnvironment(); + const testProcess = spawn('uv', ['--version'], { stdio: 'pipe', env }); return new Promise((resolve) => { const timeout = setTimeout(() => { @@ -73,14 +96,15 @@ export class OpenWebUIManager { } } - private createUvProcess( + private async createUvProcess( args: string[], env?: Record - ): ChildProcess { + ): Promise { + const uvEnv = await this.getUvEnvironment(); return spawn('uvx', args, { stdio: ['pipe', 'pipe', 'pipe'], detached: false, - env: { ...process.env, ...env }, + env: { ...uvEnv, ...env }, }); } @@ -150,7 +174,7 @@ export class OpenWebUIManager { const installDir = this.configManager.getInstallDir(); const openWebUIDataDir = join(installDir, 'openwebui-data'); - this.openWebUIProcess = this.createUvProcess(openWebUIArgs, { + this.openWebUIProcess = await this.createUvProcess(openWebUIArgs, { OPENAI_API_BASE_URL: `${koboldUrl}/v1`, OPENAI_API_KEY: 'kobold', DATA_DIR: openWebUIDataDir, diff --git a/src/main/managers/SillyTavernManager.ts b/src/main/managers/SillyTavernManager.ts index bcad7e0..39b2bf8 100644 --- a/src/main/managers/SillyTavernManager.ts +++ b/src/main/managers/SillyTavernManager.ts @@ -2,6 +2,7 @@ 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 { LogManager } from './LogManager'; @@ -86,9 +87,94 @@ export class SillyTavernManager { return join(dataRoot, 'default-user', 'settings.json'); } + private async tryAddPathToEnv( + env: Record, + path: string + ): Promise { + 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 + ): Promise { + 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 + > { + const env = { ...process.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'); + } + if (process.platform === 'win32') { + versionManagerPaths.push( + join(homedir(), 'AppData', 'Local', 'fnm', 'node-versions'), + join(homedir(), 'AppData', 'Roaming', 'nvm') + ); + } + + for (const systemPath of systemPaths) { + try { + await access(systemPath); + if (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 { try { - const testProcess = spawn('npx', ['--version'], { stdio: 'pipe' }); + const env = await this.getNodeEnvironment(); + const testProcess = spawn('npx', ['--version'], { stdio: 'pipe', env }); return new Promise((resolve) => { const timeout = setTimeout(() => { @@ -111,10 +197,12 @@ export class SillyTavernManager { } } - private createNpxProcess(args: string[]): ChildProcess { + private async createNpxProcess(args: string[]): Promise { + const env = await this.getNodeEnvironment(); return spawn('npx', args, { stdio: ['pipe', 'pipe', 'pipe'], detached: false, + env, }); } @@ -132,11 +220,11 @@ export class SillyTavernManager { 'SillyTavern settings not found, starting SillyTavern briefly to generate config...' ); - return new Promise((resolve, reject) => { - const initProcess = this.createNpxProcess( - SillyTavernManager.SILLYTAVERN_BASE_ARGS - ); + 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) => { @@ -393,7 +481,7 @@ export class SillyTavernManager { 'Final port check before starting SillyTavern...' ); - this.sillyTavernProcess = this.createNpxProcess(sillyTavernArgs); + this.sillyTavernProcess = await this.createNpxProcess(sillyTavernArgs); if (this.sillyTavernProcess.stdout) { this.sillyTavernProcess.stdout.on('data', (data: Buffer) => {