sort installed versions first on the versions settings tab, try to detect and use local installs of vu and npx, new error boundary to try to keep the titlebar visible

This commit is contained in:
Egor 2025-09-03 00:44:25 -07:00
parent 4d529f9bc4
commit 59952c4e0a
6 changed files with 281 additions and 55 deletions

View file

@ -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": "./",

View file

@ -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 = () => {
/>
<AppShell.Main>
{currentScreen === null ? (
<Center h="100%" style={{ minHeight: '25rem' }}>
<Stack align="center" gap="lg">
<Loader size="xl" type="dots" />
<Text c="dimmed" size="lg">
Loading...
</Text>
</Stack>
</Center>
) : (
<>
<ScreenTransition
isActive={currentScreen === 'welcome'}
shouldAnimate={hasInitialized}
>
<WelcomeScreen onGetStarted={handleWelcomeComplete} />
</ScreenTransition>
<ErrorBoundary>
{currentScreen === null ? (
<Center h="100%" style={{ minHeight: '25rem' }}>
<Stack align="center" gap="lg">
<Loader size="xl" type="dots" />
<Text c="dimmed" size="lg">
Loading...
</Text>
</Stack>
</Center>
) : (
<>
<ScreenTransition
isActive={currentScreen === 'welcome'}
shouldAnimate={hasInitialized}
>
<WelcomeScreen onGetStarted={handleWelcomeComplete} />
</ScreenTransition>
<ScreenTransition
isActive={currentScreen === 'download'}
shouldAnimate={hasInitialized}
>
<DownloadScreen onDownloadComplete={handleDownloadComplete} />
</ScreenTransition>
<ScreenTransition
isActive={currentScreen === 'download'}
shouldAnimate={hasInitialized}
>
<DownloadScreen onDownloadComplete={handleDownloadComplete} />
</ScreenTransition>
<ScreenTransition
isActive={currentScreen === 'launch'}
shouldAnimate={hasInitialized}
>
<LaunchScreen onLaunch={handleLaunch} />
</ScreenTransition>
<ScreenTransition
isActive={currentScreen === 'launch'}
shouldAnimate={hasInitialized}
>
<LaunchScreen onLaunch={handleLaunch} />
</ScreenTransition>
<ScreenTransition
isActive={currentScreen === 'interface'}
shouldAnimate={hasInitialized}
>
<InterfaceScreen
activeTab={activeInterfaceTab}
onTabChange={setActiveInterfaceTab}
frontendPreference={frontendPreference}
/>
</ScreenTransition>
</>
)}
<ScreenTransition
isActive={currentScreen === 'interface'}
shouldAnimate={hasInitialized}
>
<InterfaceScreen
activeTab={activeInterfaceTab}
onTabChange={setActiveInterfaceTab}
frontendPreference={frontendPreference}
/>
</ScreenTransition>
</>
)}
</ErrorBoundary>
<UpdateAvailableModal
opened={showUpdateModal && !!binaryUpdateInfo}

View file

@ -0,0 +1,106 @@
import { Component, ReactNode, ErrorInfo } from 'react';
import { Center, Stack, Text, Button, Alert, rem } from '@mantine/core';
import { AlertTriangle, FolderOpen } from 'lucide-react';
import { safeExecute } from '@/utils/logger';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, _errorInfo: ErrorInfo) {
window.electronAPI?.logs?.logError(
'App crashed with unhandled error:',
error
);
}
render() {
if (this.state.hasError) {
return (
<Center h="100%" style={{ minHeight: '25rem' }}>
<Stack align="center" gap="lg" maw={500}>
<AlertTriangle size={64} color="var(--mantine-color-red-6)" />
<Text size="xl" fw={600} ta="center">
Application Error
</Text>
<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>
{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>
);
}
return this.props.children;
}
}

View file

@ -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(() => {

View file

@ -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<string, string | undefined>
> {
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<boolean> {
try {
const testProcess = spawn('uv', ['--version'], { stdio: 'pipe' });
const env = await this.getUvEnvironment();
const testProcess = spawn('uv', ['--version'], { stdio: 'pipe', env });
return new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => {
@ -73,14 +96,15 @@ export class OpenWebUIManager {
}
}
private createUvProcess(
private async createUvProcess(
args: string[],
env?: Record<string, string>
): ChildProcess {
): Promise<ChildProcess> {
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,

View file

@ -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<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 };
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<boolean> {
try {
const testProcess = spawn('npx', ['--version'], { stdio: 'pipe' });
const env = await this.getNodeEnvironment();
const testProcess = spawn('npx', ['--version'], { stdio: 'pipe', env });
return new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => {
@ -111,10 +197,12 @@ export class SillyTavernManager {
}
}
private createNpxProcess(args: string[]): ChildProcess {
private async createNpxProcess(args: string[]): Promise<ChildProcess> {
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) => {