mirror of
https://github.com/lone-cloud/gerbil
synced 2026-06-03 09:33:10 -07:00
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:
parent
3f37af7ce9
commit
1bc34cb3c1
6 changed files with 281 additions and 55 deletions
|
|
@ -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": "./",
|
||||
|
|
|
|||
|
|
@ -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,6 +163,7 @@ export const App = () => {
|
|||
/>
|
||||
|
||||
<AppShell.Main>
|
||||
<ErrorBoundary>
|
||||
{currentScreen === null ? (
|
||||
<Center h="100%" style={{ minHeight: '25rem' }}>
|
||||
<Stack align="center" gap="lg">
|
||||
|
|
@ -206,6 +208,7 @@ export const App = () => {
|
|||
</ScreenTransition>
|
||||
</>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
|
||||
<UpdateAvailableModal
|
||||
opened={showUpdateModal && !!binaryUpdateInfo}
|
||||
|
|
|
|||
106
src/components/ErrorBoundary.tsx
Normal file
106
src/components/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
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) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue