fix the backends tab to work in offline mode and allow it to import new backend binaries

This commit is contained in:
Egor 2025-12-01 19:17:54 -08:00
parent d5fbf1df35
commit f825cd0d2f
5 changed files with 125 additions and 102 deletions

View file

@ -1,21 +1,7 @@
# Copilot Instructions for Gerbil
### General Coding
- Follow existing TypeScript/React patterns in the codebase
- Maintain consistency with the established project structure
- Use proper TypeScript types (avoid `any` when possible)
- Follow the ESLint configuration (includes SonarJS and security rules)
- Never create tests, docs or github workflows
- Stop asking me to run the "dev" script to test changes
- Try to move helper functions from component code to their own separate files to help minimize clutter
- Always use absolute imports (e.g. `import { MyComponent } from '@/components/MyComponent'`)
- Never add explicit return types to functions. We want to rely on implicit types as much as possible
### Logging and Error Handling
- **NEVER use console.\* calls** (console.log, console.error, console.warn, etc.) - they are blocked by ESLint
- **Backend errors**: Use `this.logManager.logError(message, error)` for main process errors
- **Frontend errors**: Use `window.electronAPI.logs.logError(message, error)` for renderer process errors
- All errors are logged asynchronously to avoid blocking the event loop
- Only use console.\* for critical system errors that must appear in terminal (very rare cases)
- **NEVER use console.\* calls** - they are blocked by ESLint. Use `logError()` from `@/utils/node/logging` (main process) or `window.electronAPI.logs.logError()` (renderer)
- Always use absolute imports: `import { X } from '@/components/X'`
- Never add explicit return types to functions - rely on TypeScript inference
- Never create tests, docs, or GitHub workflows
- Move helper functions out of component files into separate utility files

View file

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

View file

@ -0,0 +1,63 @@
import { useState } from 'react';
import { Text, Anchor } from '@mantine/core';
interface ImportBackendLinkProps {
disabled?: boolean;
onSuccess: () => void | Promise<void>;
onImportingChange?: (importing: boolean) => void;
}
export const ImportBackendLink = ({
disabled = false,
onSuccess,
onImportingChange,
}: ImportBackendLinkProps) => {
const [importing, setImporting] = useState(false);
const updateImporting = (value: boolean) => {
setImporting(value);
onImportingChange?.(value);
};
const [importError, setImportError] = useState<string | null>(null);
const isDisabled = disabled || importing;
const handleImport = async () => {
setImportError(null);
updateImporting(true);
try {
const result = await window.electronAPI.kobold.importLocalBackend();
if (result.success) {
await onSuccess();
} else if (result.error) {
setImportError(result.error);
}
} finally {
updateImporting(false);
}
};
return (
<>
{importError && (
<Text size="sm" c="red" ta="center" mb="xs">
{importError}
</Text>
)}
<Text size="sm" c="dimmed" ta="center">
Already have a backend downloaded?{' '}
<Anchor
component="button"
type="button"
size="sm"
disabled={isDisabled}
onClick={handleImport}
>
{importing ? 'Importing...' : 'Select a local file'}
</Anchor>
</Text>
</>
);
};

View file

@ -1,14 +1,7 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import {
Card,
Text,
Title,
Loader,
Stack,
Container,
Anchor,
} from '@mantine/core';
import { Card, Text, Title, Loader, Stack, Container } from '@mantine/core';
import { DownloadCard } from '@/components/DownloadCard';
import { ImportBackendLink } from '@/components/ImportBackendLink';
import { getPlatformDisplayName } from '@/utils/platform';
import { formatDownloadSize } from '@/utils/format';
import { getAssetDescription } from '@/utils/assets';
@ -30,7 +23,6 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
} = useKoboldBackendsStore();
const [downloadingAsset, setDownloadingAsset] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const downloadingItemRef = useRef<HTMLDivElement>(null);
@ -123,40 +115,11 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
</Text>
)}
{importError && (
<Text size="sm" c="red" ta="center">
{importError}
</Text>
)}
<Text size="sm" c="dimmed" ta="center">
Already have a backend downloaded?{' '}
<Anchor
component="button"
type="button"
size="sm"
disabled={importing || Boolean(downloading)}
onClick={async () => {
setImportError(null);
setImporting(true);
try {
const result =
await window.electronAPI.kobold.importLocalBackend();
if (result.success) {
onDownloadComplete();
} else if (result.error) {
setImportError(result.error);
}
} finally {
setImporting(false);
}
}}
>
{importing ? 'Importing...' : 'Select a local file'}
</Anchor>
</Text>
<ImportBackendLink
disabled={Boolean(downloading)}
onSuccess={onDownloadComplete}
onImportingChange={setImporting}
/>
</>
)}
</Stack>

View file

@ -7,9 +7,11 @@ import {
Loader,
Center,
Anchor,
Divider,
} from '@mantine/core';
import { ExternalLink } from 'lucide-react';
import { DownloadCard } from '@/components/DownloadCard';
import { ImportBackendLink } from '@/components/ImportBackendLink';
import { getAssetDescription } from '@/utils/assets';
import {
getDisplayNameFromPath,
@ -30,7 +32,6 @@ export const BackendsTab = () => {
downloading,
handleDownload: handleDownloadFromStore,
getLatestReleaseWithDownloadStatus,
initialize,
} = useKoboldBackendsStore();
const [installedBackends, setInstalledBackends] = useState<
@ -43,11 +44,32 @@ export const BackendsTab = () => {
const [latestRelease, setLatestRelease] = useState<ReleaseWithStatus | null>(
null
);
const [importing, setImporting] = useState(false);
const downloadingItemRef = useRef<HTMLDivElement>(null);
const loadInstalledBackends = useCallback(async () => {
setLoadingInstalled(true);
useEffect(() => {
const init = async () => {
setLoadingInstalled(true);
const [backends, current] = await Promise.all([
window.electronAPI.kobold.getInstalledBackends(),
window.electronAPI.kobold.getCurrentBackend(),
]);
setInstalledBackends(backends);
setCurrentBackend(current);
setLoadingInstalled(false);
const release = await getLatestReleaseWithDownloadStatus();
if (release) {
setLatestRelease(release);
}
};
init();
}, [getLatestReleaseWithDownloadStatus]);
const loadInstalledBackends = useCallback(async () => {
const [backends, current] = await Promise.all([
window.electronAPI.kobold.getInstalledBackends(),
window.electronAPI.kobold.getCurrentBackend(),
@ -55,34 +77,8 @@ export const BackendsTab = () => {
setInstalledBackends(backends);
setCurrentBackend(current);
setLoadingInstalled(false);
}, []);
const loadLatestRelease = useCallback(async () => {
const release = await getLatestReleaseWithDownloadStatus();
if (release) {
setLatestRelease(release);
}
}, [getLatestReleaseWithDownloadStatus]);
useEffect(() => {
if (availableDownloads.length === 0 && !loadingRemote && !loadingPlatform) {
initialize();
}
// eslint-disable-next-line react-hooks/set-state-in-effect
loadInstalledBackends();
loadLatestRelease();
}, [
loadInstalledBackends,
loadLatestRelease,
availableDownloads.length,
loadingRemote,
loadingPlatform,
initialize,
]);
const allBackends = useMemo((): BackendInfo[] => {
const backends: BackendInfo[] = [];
const processedInstalled = new Set<string>();
@ -235,23 +231,19 @@ export const BackendsTab = () => {
window.electronAPI.kobold.setCurrentBackend(backend.installedPath);
};
if (loadingInstalled || loadingPlatform || loadingRemote) {
if (loadingInstalled) {
return (
<Center h="100%">
<Stack align="center" gap="md">
<Loader size="lg" />
<Text c="dimmed">
{loadingInstalled && (loadingPlatform || loadingRemote)
? 'Loading backends...'
: loadingInstalled
? 'Scanning installed backends...'
: 'Checking for updates...'}
</Text>
<Text c="dimmed">Scanning installed backends...</Text>
</Stack>
</Center>
);
}
const isDisabled = downloading !== null || importing;
return (
<>
<Group justify="space-between" align="center" mb="sm">
@ -276,6 +268,17 @@ export const BackendsTab = () => {
)}
</Group>
{(loadingPlatform || loadingRemote) && (
<Card withBorder radius="md" padding="md" mb="sm">
<Group gap="sm" justify="center">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Checking for available downloads...
</Text>
</Group>
</Card>
)}
{allBackends.map((backend, index) => {
const isDownloading = downloading === backend.name;
@ -293,7 +296,7 @@ export const BackendsTab = () => {
: ''
}
description={getAssetDescription(backend.name)}
disabled={downloading !== null}
disabled={isDisabled}
onDownload={(e) => {
e.stopPropagation();
handleDownload(backend);
@ -323,6 +326,14 @@ export const BackendsTab = () => {
</Text>
</Card>
)}
<Divider my="md" />
<ImportBackendLink
disabled={isDisabled}
onSuccess={loadInstalledBackends}
onImportingChange={setImporting}
/>
</>
);
};