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:
lone-cloud 2025-09-03 00:44:25 -07:00
parent 3f37af7ce9
commit 1bc34cb3c1
6 changed files with 281 additions and 55 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "gerbil", "name": "gerbil",
"productName": "Gerbil", "productName": "Gerbil",
"version": "0.9.7", "version": "0.9.8",
"description": "Run Large Language Models locally", "description": "Run Large Language Models locally",
"main": "out/main/index.js", "main": "out/main/index.js",
"homepage": "./", "homepage": "./",

View file

@ -9,6 +9,7 @@ import { SettingsModal } from '@/components/settings/SettingsModal';
import { EjectConfirmModal } from '@/components/EjectConfirmModal'; import { EjectConfirmModal } from '@/components/EjectConfirmModal';
import { ScreenTransition } from '@/components/ScreenTransition'; import { ScreenTransition } from '@/components/ScreenTransition';
import { TitleBar } from '@/components/TitleBar'; import { TitleBar } from '@/components/TitleBar';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { useUpdateChecker } from '@/hooks/useUpdateChecker'; import { useUpdateChecker } from '@/hooks/useUpdateChecker';
import { useKoboldVersions } from '@/hooks/useKoboldVersions'; import { useKoboldVersions } from '@/hooks/useKoboldVersions';
import { useModalStore } from '@/stores/modal'; import { useModalStore } from '@/stores/modal';
@ -162,50 +163,52 @@ export const App = () => {
/> />
<AppShell.Main> <AppShell.Main>
{currentScreen === null ? ( <ErrorBoundary>
<Center h="100%" style={{ minHeight: '25rem' }}> {currentScreen === null ? (
<Stack align="center" gap="lg"> <Center h="100%" style={{ minHeight: '25rem' }}>
<Loader size="xl" type="dots" /> <Stack align="center" gap="lg">
<Text c="dimmed" size="lg"> <Loader size="xl" type="dots" />
Loading... <Text c="dimmed" size="lg">
</Text> Loading...
</Stack> </Text>
</Center> </Stack>
) : ( </Center>
<> ) : (
<ScreenTransition <>
isActive={currentScreen === 'welcome'} <ScreenTransition
shouldAnimate={hasInitialized} isActive={currentScreen === 'welcome'}
> shouldAnimate={hasInitialized}
<WelcomeScreen onGetStarted={handleWelcomeComplete} /> >
</ScreenTransition> <WelcomeScreen onGetStarted={handleWelcomeComplete} />
</ScreenTransition>
<ScreenTransition <ScreenTransition
isActive={currentScreen === 'download'} isActive={currentScreen === 'download'}
shouldAnimate={hasInitialized} shouldAnimate={hasInitialized}
> >
<DownloadScreen onDownloadComplete={handleDownloadComplete} /> <DownloadScreen onDownloadComplete={handleDownloadComplete} />
</ScreenTransition> </ScreenTransition>
<ScreenTransition <ScreenTransition
isActive={currentScreen === 'launch'} isActive={currentScreen === 'launch'}
shouldAnimate={hasInitialized} shouldAnimate={hasInitialized}
> >
<LaunchScreen onLaunch={handleLaunch} /> <LaunchScreen onLaunch={handleLaunch} />
</ScreenTransition> </ScreenTransition>
<ScreenTransition <ScreenTransition
isActive={currentScreen === 'interface'} isActive={currentScreen === 'interface'}
shouldAnimate={hasInitialized} shouldAnimate={hasInitialized}
> >
<InterfaceScreen <InterfaceScreen
activeTab={activeInterfaceTab} activeTab={activeInterfaceTab}
onTabChange={setActiveInterfaceTab} onTabChange={setActiveInterfaceTab}
frontendPreference={frontendPreference} frontendPreference={frontendPreference}
/> />
</ScreenTransition> </ScreenTransition>
</> </>
)} )}
</ErrorBoundary>
<UpdateAvailableModal <UpdateAvailableModal
opened={showUpdateModal && !!binaryUpdateInfo} 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]); }, [availableDownloads, installedVersions, currentVersion]);
useEffect(() => { useEffect(() => {

View file

@ -1,6 +1,8 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import type { ChildProcess } from 'child_process'; import type { ChildProcess } from 'child_process';
import { join } from 'path'; import { join } from 'path';
import { homedir } from 'os';
import { access } from 'fs/promises';
import { LogManager } from './LogManager'; import { LogManager } from './LogManager';
import { WindowManager } from './WindowManager'; 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> { async isUvAvailable(): Promise<boolean> {
try { 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) => { return new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@ -73,14 +96,15 @@ export class OpenWebUIManager {
} }
} }
private createUvProcess( private async createUvProcess(
args: string[], args: string[],
env?: Record<string, string> env?: Record<string, string>
): ChildProcess { ): Promise<ChildProcess> {
const uvEnv = await this.getUvEnvironment();
return spawn('uvx', args, { return spawn('uvx', args, {
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
detached: false, detached: false,
env: { ...process.env, ...env }, env: { ...uvEnv, ...env },
}); });
} }
@ -150,7 +174,7 @@ export class OpenWebUIManager {
const installDir = this.configManager.getInstallDir(); const installDir = this.configManager.getInstallDir();
const openWebUIDataDir = join(installDir, 'openwebui-data'); 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_BASE_URL: `${koboldUrl}/v1`,
OPENAI_API_KEY: 'kobold', OPENAI_API_KEY: 'kobold',
DATA_DIR: openWebUIDataDir, DATA_DIR: openWebUIDataDir,

View file

@ -2,6 +2,7 @@ import { spawn } from 'child_process';
import { createServer, request, type Server } from 'http'; import { createServer, request, type Server } from 'http';
import { homedir } from 'os'; import { homedir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { access, readdir } from 'fs/promises';
import type { ChildProcess } from 'child_process'; import type { ChildProcess } from 'child_process';
import { LogManager } from './LogManager'; import { LogManager } from './LogManager';
@ -86,9 +87,94 @@ export class SillyTavernManager {
return join(dataRoot, 'default-user', 'settings.json'); 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> { async isNpxAvailable(): Promise<boolean> {
try { 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) => { return new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => { 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, { return spawn('npx', args, {
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
detached: false, detached: false,
env,
}); });
} }
@ -132,11 +220,11 @@ export class SillyTavernManager {
'SillyTavern settings not found, starting SillyTavern briefly to generate config...' 'SillyTavern settings not found, starting SillyTavern briefly to generate config...'
); );
return new Promise((resolve, reject) => { const initProcess = await this.createNpxProcess(
const initProcess = this.createNpxProcess( SillyTavernManager.SILLYTAVERN_BASE_ARGS
SillyTavernManager.SILLYTAVERN_BASE_ARGS );
);
return new Promise((resolve, reject) => {
let hasResolved = false; let hasResolved = false;
initProcess.on('exit', (code: number | null, signal: string | null) => { initProcess.on('exit', (code: number | null, signal: string | null) => {
@ -393,7 +481,7 @@ export class SillyTavernManager {
'Final port check before starting SillyTavern...' 'Final port check before starting SillyTavern...'
); );
this.sillyTavernProcess = this.createNpxProcess(sillyTavernArgs); this.sillyTavernProcess = await this.createNpxProcess(sillyTavernArgs);
if (this.sillyTavernProcess.stdout) { if (this.sillyTavernProcess.stdout) {
this.sillyTavernProcess.stdout.on('data', (data: Buffer) => { this.sillyTavernProcess.stdout.on('data', (data: Buffer) => {