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",
|
"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": "./",
|
||||||
|
|
|
||||||
85
src/App.tsx
85
src/App.tsx
|
|
@ -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}
|
||||||
|
|
|
||||||
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]);
|
}, [availableDownloads, installedVersions, currentVersion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue