bug fixes and small improvements, new zoom level setting

This commit is contained in:
Egor 2025-09-01 01:54:17 -07:00
parent 4ddacd5ae6
commit 851e37534e
37 changed files with 382 additions and 187 deletions

View file

@ -72,6 +72,10 @@ The AUR package automatically handles installation, desktop integration, and sys
<img src="screenshots/gen-img.png" alt="Image Generation" width="600">
### Seemless SillyTavern integration
<img src="screenshots/sillytavern.png" alt="SillyTavern integration" width="600">
</div>
<!-- markdownlint-enable MD033 -->

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 71 KiB

BIN
screenshots/sillytavern.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 188 KiB

View file

@ -22,7 +22,6 @@ export const App = () => {
const [hasInitialized, setHasInitialized] = useState(false);
const [activeInterfaceTab, setActiveInterfaceTab] =
useState<InterfaceTab>('terminal');
const [isImageGenerationMode, setIsImageGenerationMode] = useState(false);
const [frontendPreference, setFrontendPreference] =
useState<FrontendPreference>('koboldcpp');
@ -159,6 +158,7 @@ export const App = () => {
onTabChange={setActiveInterfaceTab}
onEject={handleEject}
onOpenSettings={() => setModalOpen('settings', true)}
frontendPreference={frontendPreference}
/>
<AppShell.Main>
@ -191,10 +191,7 @@ export const App = () => {
isActive={currentScreen === 'launch'}
shouldAnimate={hasInitialized}
>
<LaunchScreen
onLaunch={handleLaunch}
onLaunchModeChange={setIsImageGenerationMode}
/>
<LaunchScreen onLaunch={handleLaunch} />
</ScreenTransition>
<ScreenTransition
@ -204,28 +201,25 @@ export const App = () => {
<InterfaceScreen
activeTab={activeInterfaceTab}
onTabChange={setActiveInterfaceTab}
isImageGenerationMode={isImageGenerationMode}
frontendPreference={frontendPreference}
/>
</ScreenTransition>
</>
)}
{showUpdateModal && binaryUpdateInfo && (
<UpdateAvailableModal
opened={showUpdateModal}
onClose={dismissUpdate}
currentVersion={binaryUpdateInfo.currentVersion}
availableUpdate={binaryUpdateInfo.availableUpdate}
onUpdate={handleBinaryUpdate}
isDownloading={
downloading === binaryUpdateInfo.availableUpdate.name
}
downloadProgress={
downloadProgress[binaryUpdateInfo.availableUpdate.name] || 0
}
/>
)}
<UpdateAvailableModal
opened={showUpdateModal && !!binaryUpdateInfo}
onClose={dismissUpdate}
currentVersion={binaryUpdateInfo?.currentVersion}
availableUpdate={binaryUpdateInfo?.availableUpdate}
onUpdate={handleBinaryUpdate}
isDownloading={downloading === binaryUpdateInfo?.availableUpdate.name}
downloadProgress={
binaryUpdateInfo
? downloadProgress[binaryUpdateInfo.availableUpdate.name] || 0
: 0
}
/>
</AppShell.Main>
<SettingsModal
opened={modals.settings}

View file

@ -549,10 +549,12 @@ export const CommandLineArgumentsModal = ({
>
<Group gap="xs" wrap="wrap" justify="space-between">
<Group gap="xs" wrap="wrap" style={{ flex: 1 }}>
<Code style={{ fontSize: '14px', fontWeight: 600 }}>{arg.flag}</Code>
<Code style={{ fontSize: '0.875rem', fontWeight: 600 }}>
{arg.flag}
</Code>
{arg.aliases &&
arg.aliases.map((alias) => (
<Code key={alias} style={{ fontSize: '12px' }} c="dimmed">
<Code key={alias} style={{ fontSize: '0.75em' }} c="dimmed">
{alias}
</Code>
))}

View file

@ -22,11 +22,6 @@ export const ScreenTransition = ({
<div
style={{
...styles,
position: isActive ? 'static' : 'absolute',
width: '100%',
top: 0,
left: 0,
zIndex: isActive ? 1 : 0,
}}
>
{children}

View file

@ -19,9 +19,10 @@ import { useState } from 'react';
import { soundAssets, playSound, initializeAudio } from '@/utils/sounds';
import { useAppUpdateChecker } from '@/hooks/useAppUpdateChecker';
import { useModalStore } from '@/stores/modal';
import { useLaunchConfigStore } from '@/stores/launchConfig';
import iconUrl from '/icon.png';
import { PRODUCT_NAME, TITLEBAR_HEIGHT } from '@/constants';
import type { InterfaceTab, Screen } from '@/types';
import { FRONTENDS, PRODUCT_NAME, TITLEBAR_HEIGHT } from '@/constants';
import type { FrontendPreference, InterfaceTab, Screen } from '@/types';
interface TitleBarProps {
currentScreen: Screen;
@ -29,6 +30,7 @@ interface TitleBarProps {
onTabChange?: (tab: InterfaceTab) => void;
onEject?: () => void;
onOpenSettings?: () => void;
frontendPreference?: FrontendPreference;
}
export const TitleBar = ({
@ -37,16 +39,19 @@ export const TitleBar = ({
onTabChange,
onEject,
onOpenSettings,
frontendPreference,
}: TitleBarProps) => {
const computedColorScheme = useComputedColorScheme('light', {
getInitialValueInEffect: false,
});
const { hasUpdate, openReleasePage } = useAppUpdateChecker();
const { isAnyModalOpen } = useModalStore();
const { isImageGenerationMode } = useLaunchConfigStore();
const [logoClickCount, setLogoClickCount] = useState(0);
const [isElephantMode, setIsElephantMode] = useState(false);
const [isMouseSqueaking, setIsMouseSqueaking] = useState(false);
const [isMaximized, setIsMaximized] = useState(false);
const [isSelectOpen, setIsSelectOpen] = useState(false);
const handleMinimize = () => {
window.electronAPI.app.minimizeWindow();
@ -101,7 +106,8 @@ export const TitleBar = ({
? 'var(--mantine-color-dark-7)'
: 'var(--mantine-color-gray-0)',
borderBottom: '1px solid var(--mantine-color-default-border)',
WebkitAppRegion: isAnyModalOpen() ? 'no-drag' : 'drag',
WebkitAppRegion:
isAnyModalOpen() || isSelectOpen ? 'no-drag' : 'drag',
userSelect: 'none',
position: 'relative',
}}
@ -154,10 +160,17 @@ export const TitleBar = ({
onTabChange?.(value as InterfaceTab);
}
}}
onDropdownOpen={() => setIsSelectOpen(true)}
onDropdownClose={() => setIsSelectOpen(false)}
data={[
{
value: 'chat',
label: 'Chat',
label:
frontendPreference === 'sillytavern'
? FRONTENDS.SILLYTAVERN
: isImageGenerationMode
? FRONTENDS.STABLE_UI
: FRONTENDS.KOBOLDAI_LITE,
},
{ value: 'terminal', label: 'Terminal' },
{ value: 'eject', label: 'Eject' },
@ -167,7 +180,7 @@ export const TitleBar = ({
size="sm"
style={{
textAlign: 'center',
minWidth: '120px',
minWidth: '7.5rem',
}}
styles={{
input: {

View file

@ -19,8 +19,8 @@ import { safeExecute } from '@/utils/logger';
interface UpdateAvailableModalProps {
opened: boolean;
onClose: () => void;
currentVersion: InstalledVersion;
availableUpdate: DownloadItem;
currentVersion?: InstalledVersion;
availableUpdate?: DownloadItem;
onUpdate: (download: DownloadItem) => Promise<void>;
isDownloading?: boolean;
downloadProgress?: number;
@ -38,11 +38,13 @@ export const UpdateAvailableModal = ({
const [isUpdating, setIsUpdating] = useState(false);
const handleUpdate = async () => {
setIsUpdating(true);
await safeExecute(async () => {
await onUpdate(availableUpdate);
onClose();
}, 'Failed to update:');
if (availableUpdate) {
setIsUpdating(true);
await safeExecute(async () => {
await onUpdate(availableUpdate);
onClose();
}, 'Failed to update:');
}
setIsUpdating(false);
};
@ -65,7 +67,7 @@ export const UpdateAvailableModal = ({
Current Version
</Text>
<Text fw={500} size="sm">
{currentVersion.version}
{currentVersion?.version}
</Text>
</div>
@ -78,14 +80,16 @@ export const UpdateAvailableModal = ({
Available Version
</Text>
<Text fw={500} size="sm" c="orange">
{availableUpdate.version}
{availableUpdate?.version}
</Text>
</div>
</Group>
<Text size="xs" c="dimmed">
Binary: {getDisplayNameFromPath(currentVersion)}
</Text>
{currentVersion && (
<Text size="xs" c="dimmed">
Binary: {getDisplayNameFromPath(currentVersion)}
</Text>
)}
<Group gap="xs" align="center" mt="xs">
<Text size="xs" c="dimmed">
@ -93,17 +97,17 @@ export const UpdateAvailableModal = ({
</Text>
<Anchor
size="xs"
href={`https://github.com/${GITHUB_API.KOBOLDCPP_REPO}/releases/tag/v${availableUpdate.version}`}
href={`https://github.com/${GITHUB_API.KOBOLDCPP_REPO}/releases/tag/v${availableUpdate?.version}`}
target="_blank"
rel="noopener noreferrer"
onClick={() =>
window.electronAPI.app.openExternal(
`https://github.com/${GITHUB_API.KOBOLDCPP_REPO}/releases/tag/v${availableUpdate.version}`
`https://github.com/${GITHUB_API.KOBOLDCPP_REPO}/releases/tag/v${availableUpdate?.version}`
)
}
>
<Group gap={4} align="center">
<span>v{availableUpdate.version}</span>
<span>v{availableUpdate?.version}</span>
<ExternalLink size={12} />
</Group>
</Anchor>

View file

@ -65,7 +65,7 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
}, [downloading]);
return (
<Container size="sm">
<Container size="sm" mt="md">
<Stack gap="xl">
<Card withBorder radius="md" shadow="sm">
<Stack gap="lg">

View file

@ -1,22 +1,20 @@
import { useRef } from 'react';
import { Box, Text, Stack } from '@mantine/core';
import { SILLYTAVERN, FRONTENDS } from '@/constants';
import type { ServerTabMode, FrontendPreference } from '@/types';
import { SILLYTAVERN, FRONTENDS, TITLEBAR_HEIGHT } from '@/constants';
import { useLaunchConfigStore } from '@/stores/launchConfig';
import type { FrontendPreference } from '@/types';
interface ServerTabProps {
serverUrl?: string;
isServerReady?: boolean;
mode: ServerTabMode;
frontendPreference?: FrontendPreference;
}
export const ServerTab = ({
serverUrl,
isServerReady,
mode,
frontendPreference = 'koboldcpp',
}: ServerTabProps) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const { isImageGenerationMode } = useLaunchConfigStore();
if (!isServerReady || !serverUrl) {
return (
@ -34,8 +32,8 @@ export const ServerTab = ({
Waiting for the KoboldCpp server to start...
</Text>
<Text c="dimmed" size="sm">
The {mode === 'chat' ? 'chat' : 'image generation'} interface will
load automatically when ready
The {isImageGenerationMode ? 'image generation' : 'chat'} interface
will load automatically when ready
</Text>
</Stack>
</Box>
@ -49,23 +47,21 @@ export const ServerTab = ({
iframeUrl = SILLYTAVERN.PROXY_URL;
title = FRONTENDS.SILLYTAVERN;
} else {
iframeUrl = mode === 'image-generation' ? `${serverUrl}/sdui` : serverUrl;
title =
mode === 'image-generation'
? FRONTENDS.STABLE_UI
: FRONTENDS.KOBOLDAI_LITE;
iframeUrl = isImageGenerationMode ? `${serverUrl}/sdui` : serverUrl;
title = isImageGenerationMode
? FRONTENDS.STABLE_UI
: FRONTENDS.KOBOLDAI_LITE;
}
return (
<Box
style={{
width: '100%',
height: 'calc(100vh - 2rem)',
height: `calc(100vh - ${TITLEBAR_HEIGHT})`,
overflow: 'hidden',
}}
>
<iframe
ref={iframeRef}
src={iframeUrl}
title={title}
style={{

View file

@ -8,7 +8,7 @@ import {
} from '@mantine/core';
import { ChevronDown } from 'lucide-react';
import styles from '@/styles/layout.module.css';
import { SERVER_READY_SIGNALS } from '@/constants';
import { SERVER_READY_SIGNALS, TITLEBAR_HEIGHT } from '@/constants';
import { handleTerminalOutput, processTerminalContent } from '@/utils/terminal';
import { useLaunchConfigStore } from '@/stores/launchConfig';
import type { FrontendPreference } from '@/types';
@ -104,7 +104,7 @@ export const TerminalTab = ({
return (
<Box
style={{
height: 'calc(100vh - 2rem)',
height: `calc(100vh - ${TITLEBAR_HEIGHT})`,
display: 'flex',
flexDirection: 'column',
backgroundColor: isDark
@ -133,7 +133,7 @@ export const TerminalTab = ({
margin: 0,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: '14px',
fontSize: '0.875rem',
lineHeight: 1.4,
color: isDark
? 'var(--mantine-color-gray-0)'
@ -158,10 +158,10 @@ export const TerminalTab = ({
onClick={scrollToBottom}
style={{
position: 'absolute',
bottom: '20px',
right: '20px',
bottom: '1.25rem',
right: '1.25rem',
zIndex: 10,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
boxShadow: '0 0.125rem 0.5rem rgba(0, 0, 0, 0.3)',
}}
aria-label="Scroll to bottom"
>

View file

@ -6,14 +6,12 @@ import type { InterfaceTab, FrontendPreference } from '@/types';
interface InterfaceScreenProps {
activeTab?: InterfaceTab | null;
onTabChange?: (tab: InterfaceTab) => void;
isImageGenerationMode?: boolean;
frontendPreference?: FrontendPreference;
}
export const InterfaceScreen = ({
activeTab,
onTabChange,
isImageGenerationMode = false,
frontendPreference = 'koboldcpp',
}: InterfaceScreenProps) => {
const [serverUrl, setServerUrl] = useState<string>('');
@ -48,7 +46,6 @@ export const InterfaceScreen = ({
<ServerTab
serverUrl={serverUrl}
isServerReady={isServerReady}
mode={isImageGenerationMode ? 'image-generation' : 'chat'}
frontendPreference={frontendPreference}
/>
</div>

View file

@ -6,9 +6,9 @@ import { useLaunchConfig } from '@/hooks/useLaunchConfig';
export const GeneralTab = () => {
const {
modelPath,
model,
contextSize,
handleModelPathChange,
handleModelChange,
handleSelectModelFile,
handleContextSizeChangeWithStep,
} = useLaunchConfig();
@ -19,10 +19,10 @@ export const GeneralTab = () => {
<ModelFileField
label="Text Model File"
value={modelPath}
value={model}
placeholder="Select a .gguf model file or enter a direct URL to file"
tooltip="Select a GGUF text generation model file for chat and completion tasks."
onChange={handleModelPathChange}
onChange={handleModelChange}
onSelectFile={handleSelectModelFile}
showSearchHF
searchUrl="https://huggingface.co/models?pipeline_tag=text-generation&library=gguf&sort=trending"

View file

@ -15,13 +15,9 @@ import type { ConfigFile } from '@/types';
interface LaunchScreenProps {
onLaunch: () => void;
onLaunchModeChange?: (isImageMode: boolean) => void;
}
export const LaunchScreen = ({
onLaunch,
onLaunchModeChange,
}: LaunchScreenProps) => {
export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
const [configFiles, setConfigFiles] = useState<ConfigFile[]>([]);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [, setInstallDir] = useState<string>('');
@ -33,7 +29,7 @@ export const LaunchScreen = ({
gpuLayers,
autoGpuLayers,
contextSize,
modelPath,
model,
additionalArguments,
port,
host,
@ -65,19 +61,18 @@ export const LaunchScreen = ({
moeexperts,
parseAndApplyConfigFile,
loadConfigFromFile,
handleModelPathChange,
handleModelChange,
handleBackendChange,
} = useLaunchConfig();
const { isLaunching, handleLaunch } = useLaunchLogic({
modelPath,
model,
sdmodel,
onLaunch,
onLaunchModeChange,
});
const { warnings: combinedWarnings } = useWarnings({
modelPath,
model,
sdmodel,
backend,
noavx2,
@ -97,26 +92,26 @@ export const LaunchScreen = ({
}, [backend, handleBackendChange]);
const setInitialDefaults = useCallback(
async (currentModelPath: string, currentSdModel: string) => {
async (currentModel: string, currentSdModel: string) => {
await tryExecute(async () => {
if (
!defaultsSetRef.current &&
!currentModelPath.trim() &&
!currentModel.trim() &&
!currentSdModel.trim()
) {
handleModelPathChange(DEFAULT_MODEL_URL);
handleModelChange(DEFAULT_MODEL_URL);
defaultsSetRef.current = true;
}
}, 'Failed to set initial defaults:');
},
[handleModelPathChange]
[handleModelChange]
);
useEffect(() => {
if (configLoaded && !defaultsSetRef.current) {
void setHappyDefaults();
if (!modelPath.trim() && !sdmodel.trim()) {
void setInitialDefaults(modelPath, sdmodel);
if (!model.trim() && !sdmodel.trim()) {
void setInitialDefaults(model, sdmodel);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -160,7 +155,7 @@ export const LaunchScreen = ({
autoGpuLayers: autoGpuLayers,
gpulayers: gpuLayers,
contextsize: contextSize,
model: modelPath,
model,
additionalArguments,
port,
host,
@ -290,7 +285,7 @@ export const LaunchScreen = ({
};
return (
<Container size="sm">
<Container size="sm" mt="md">
<Stack gap="md">
<Card
withBorder
@ -314,7 +309,7 @@ export const LaunchScreen = ({
onChange={setActiveTab}
styles={{
root: {
maxHeight: '22rem',
maxHeight: '59vh',
display: 'flex',
flexDirection: 'column',
},
@ -354,18 +349,18 @@ export const LaunchScreen = ({
<WarningDisplay warnings={combinedWarnings}>
<Button
radius="md"
disabled={(!modelPath && !sdmodel) || isLaunching}
disabled={(!model && !sdmodel) || isLaunching}
onClick={handleLaunchClick}
size="lg"
variant="filled"
color="blue"
style={{
fontWeight: 600,
fontSize: '16px',
padding: '12px 28px',
minWidth: '120px',
fontSize: '1em',
padding: '0.75rem 1.75rem',
minWidth: '7.5rem',
textTransform: 'uppercase',
letterSpacing: '0.5px',
letterSpacing: '0.03125rem',
}}
>
Launch

View file

@ -18,7 +18,7 @@ interface WelcomeScreenProps {
}
export const WelcomeScreen = ({ onGetStarted }: WelcomeScreenProps) => (
<Container size="md">
<Container size="md" mt="md">
<Stack gap="xl">
<Card withBorder radius="md" shadow="sm" p="xl">
<Stack gap="lg" align="center">
@ -66,21 +66,13 @@ export const WelcomeScreen = ({ onGetStarted }: WelcomeScreenProps) => (
- Everything runs on your computer, no data sent to servers
</Text>
</List.Item>
<List.Item>
<Text>
<Text component="span" fw={500}>
No subscription fees
</Text>{' '}
- Use powerful AI models for free once downloaded
</Text>
</List.Item>
<List.Item>
<Text>
<Text component="span" fw={500}>
Hardware acceleration
</Text>{' '}
- Supports CUDA, ROCm, Vulkan, and CLBlast backends for faster
inference
- Supports CUDA, ROCm, Vulkan, CLBlast and CPU backends for
faster inference
</Text>
</List.Item>
</List>

View file

@ -122,7 +122,12 @@ export const AboutTab = () => {
<Stack gap="xs">
{versionItems.map((item, index) => (
<Group key={index} gap="md" align="center" wrap="nowrap">
<Text size="sm" fw={500} c="dimmed" style={{ minWidth: '120px' }}>
<Text
size="sm"
fw={500}
c="dimmed"
style={{ minWidth: '7.5rem' }}
>
{item.label}:
</Text>
<Text

View file

@ -6,8 +6,18 @@ import {
rem,
useMantineColorScheme,
useComputedColorScheme,
Slider,
TextInput,
} from '@mantine/core';
import { Sun, Moon, Monitor } from 'lucide-react';
import { useState, useEffect } from 'react';
import { safeExecute } from '@/utils/logger';
import {
zoomLevelToPercentage,
percentageToZoomLevel,
isValidZoomPercentage,
} from '@/utils/zoom';
import { ZOOM } from '@/constants';
export const AppearanceTab = () => {
const { colorScheme, setColorScheme } = useMantineColorScheme();
@ -16,6 +26,51 @@ export const AppearanceTab = () => {
});
const isDark = computedColorScheme === 'dark';
const [zoomLevel, setZoomLevel] = useState<number>(ZOOM.DEFAULT_LEVEL);
const [zoomPercentage, setZoomPercentage] = useState<string>(
ZOOM.DEFAULT_PERCENTAGE.toString()
);
useEffect(() => {
const loadZoomLevel = async () => {
const currentZoom = await safeExecute(
() => window.electronAPI.app.getZoomLevel(),
'Failed to load zoom level:'
);
if (typeof currentZoom === 'number') {
setZoomLevel(currentZoom);
setZoomPercentage(zoomLevelToPercentage(currentZoom).toString());
}
};
void loadZoomLevel();
}, []);
const handleZoomChange = async (newZoomLevel: number) => {
setZoomLevel(newZoomLevel);
const percentage = zoomLevelToPercentage(newZoomLevel);
setZoomPercentage(percentage.toString());
await safeExecute(
() => window.electronAPI.app.setZoomLevel(newZoomLevel),
'Failed to set zoom level:'
);
};
const handleZoomPercentageChange = async (value: string) => {
setZoomPercentage(value);
const numValue = Number(value);
if (!isNaN(numValue) && isValidZoomPercentage(numValue)) {
const newZoomLevel = percentageToZoomLevel(numValue);
setZoomLevel(newZoomLevel);
await safeExecute(
() => window.electronAPI.app.setZoomLevel(newZoomLevel),
'Failed to set zoom level:'
);
}
};
return (
<Stack gap="lg" h="100%">
<div>
@ -72,6 +127,71 @@ export const AppearanceTab = () => {
]}
/>
</div>
<div>
<Text fw={500} mb="sm">
Zoom Level
</Text>
<Text size="sm" c="dimmed" mb="md">
Adjust the zoom level of the application interface
</Text>
<Group gap="md" align="flex-end">
<div style={{ flex: 1 }}>
<Group justify="space-between" align="center" mb="xs">
<TextInput
value={zoomPercentage}
onChange={(event) =>
handleZoomPercentageChange(event.currentTarget.value)
}
onBlur={(event) => {
const value = event.currentTarget.value;
const numValue = Number(value);
if (isNaN(numValue) || !isValidZoomPercentage(numValue)) {
setZoomPercentage(
zoomLevelToPercentage(zoomLevel).toString()
);
}
}}
rightSection={
<Text size="sm" c="dimmed">
%
</Text>
}
size="sm"
w={80}
styles={{
input: {
textAlign: 'center',
},
}}
/>
</Group>
<Slider
value={zoomLevel}
onChange={handleZoomChange}
min={ZOOM.MIN_LEVEL}
max={ZOOM.MAX_LEVEL}
step={0.25}
marks={[
{
value: ZOOM.MIN_LEVEL,
label: `${ZOOM.MIN_PERCENTAGE}%`,
},
{
value: ZOOM.DEFAULT_LEVEL,
label: `${ZOOM.DEFAULT_PERCENTAGE}%`,
},
{
value: ZOOM.MAX_LEVEL,
label: `${ZOOM.MAX_PERCENTAGE}%`,
},
]}
label={(value) => `${zoomLevelToPercentage(value)}%`}
style={{ marginBottom: '0.5rem' }}
/>
</div>
</Group>
</div>
</Stack>
);
};

View file

@ -68,14 +68,14 @@ export const SettingsModal = ({
lockScroll={false}
styles={{
body: {
height: '440px',
height: '27.5rem',
padding: 0,
display: 'flex',
flexDirection: 'column',
position: 'relative',
},
content: {
height: '500px',
height: '31.25rem',
paddingBottom: 0,
},
}}
@ -96,8 +96,8 @@ export const SettingsModal = ({
panel: {
height: '100%',
overflow: 'auto',
paddingLeft: '24px',
paddingRight: '24px',
paddingLeft: '1.5rem',
paddingRight: '1.5rem',
},
tabLabel: {
textAlign: 'left',

View file

@ -46,3 +46,12 @@ export const FRONTENDS = {
STABLE_UI: 'Stable UI',
SILLYTAVERN: 'SillyTavern',
} as const;
export const ZOOM = {
MIN_LEVEL: -5,
MAX_LEVEL: 5,
MIN_PERCENTAGE: 25,
MAX_PERCENTAGE: 500,
DEFAULT_LEVEL: 0,
DEFAULT_PERCENTAGE: 100,
} as const;

View file

@ -11,7 +11,7 @@ export const useLaunchConfig = () => {
gpuLayers: state.gpuLayers,
autoGpuLayers: state.autoGpuLayers,
contextSize: state.contextSize,
modelPath: state.modelPath,
model: state.model,
additionalArguments: state.additionalArguments,
port: state.port,
host: state.host,
@ -45,7 +45,7 @@ export const useLaunchConfig = () => {
handleGpuLayersChange: state.setGpuLayers,
handleAutoGpuLayersChange: state.setAutoGpuLayers,
handleContextSizeChangeWithStep: state.contextSizeChangeWithStep,
handleModelPathChange: state.setModelPath,
handleModelChange: state.setModel,
handleAdditionalArgumentsChange: state.setAdditionalArguments,
handlePortChange: state.setPort,
handleHostChange: state.setHost,

View file

@ -3,10 +3,9 @@ import { error } from '@/utils/logger';
import type { SdConvDirectMode } from '@/types';
interface UseLaunchLogicProps {
modelPath: string;
model: string;
sdmodel: string;
onLaunch: () => void;
onLaunchModeChange?: (isImageMode: boolean) => void;
}
interface LaunchArgs {
@ -45,7 +44,7 @@ interface LaunchArgs {
const buildModelArgs = (
isImageMode: boolean,
modelPath: string,
model: string,
sdmodel: string,
launchArgs: LaunchArgs
): string[] => {
@ -77,7 +76,7 @@ const buildModelArgs = (
args.push('--sdconvdirect', launchArgs.sdconvdirect);
}
} else {
args.push('--model', modelPath);
args.push('--model', model);
}
return args;
@ -239,17 +238,16 @@ function parseCLBlastDevice(deviceString: string): {
}
export const useLaunchLogic = ({
modelPath,
model,
sdmodel,
onLaunch,
onLaunchModeChange,
}: UseLaunchLogicProps) => {
const [isLaunching, setIsLaunching] = useState(false);
const handleLaunch = useCallback(
async (launchArgs: LaunchArgs) => {
const isImageMode = sdmodel.trim() !== '';
const isTextMode = modelPath.trim() !== '';
const isTextMode = model.trim() !== '';
if (isLaunching || (!isImageMode && !isTextMode)) {
return;
@ -261,7 +259,7 @@ export const useLaunchLogic = ({
try {
const args: string[] = [
...buildModelArgs(isImageMode, modelPath, sdmodel, launchArgs),
...buildModelArgs(isImageMode, model, sdmodel, launchArgs),
...buildConfigArgs(isImageMode, launchArgs),
...buildBackendArgs(launchArgs),
];
@ -276,9 +274,7 @@ export const useLaunchLogic = ({
const result = await window.electronAPI.kobold.launchKoboldCpp(args);
if (result.success) {
if (onLaunchModeChange) {
onLaunchModeChange(isImageMode);
}
onLaunch();
} else {
const errorMessage = result.error || 'Unknown launch error';
window.electronAPI.logs.logError(
@ -292,7 +288,7 @@ export const useLaunchLogic = ({
setIsLaunching(false);
}
},
[modelPath, sdmodel, isLaunching, onLaunch, onLaunchModeChange]
[model, sdmodel, isLaunching, onLaunch]
);
return {

View file

@ -1,4 +1,4 @@
import { useMemo, useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { safeExecute } from '@/utils/logger';
import type { BackendOption } from '@/types';
@ -8,7 +8,7 @@ export interface Warning {
}
interface UseWarningsProps {
modelPath: string;
model: string;
sdmodel: string;
backend?: string;
noavx2?: boolean;
@ -17,11 +17,11 @@ interface UseWarningsProps {
}
const checkModelWarnings = (
modelPath: string,
model: string,
sdmodel: string,
configLoaded: boolean
): Warning[] => {
const hasTextModel = modelPath?.trim() !== '';
const hasTextModel = model?.trim() !== '';
const hasImageModel = sdmodel.trim() !== '';
const showModelPriorityWarning = hasTextModel && hasImageModel;
const showNoModelWarning = !hasTextModel && !hasImageModel && configLoaded;
@ -230,7 +230,7 @@ const checkBackendWarnings = async (params?: {
};
export const useWarnings = ({
modelPath,
model,
sdmodel,
backend,
noavx2 = false,
@ -240,8 +240,8 @@ export const useWarnings = ({
const [backendWarnings, setBackendWarnings] = useState<Warning[]>([]);
const modelWarnings = useMemo(
() => checkModelWarnings(modelPath, sdmodel, configLoaded),
[modelPath, sdmodel, configLoaded]
() => checkModelWarnings(model, sdmodel, configLoaded),
[model, sdmodel, configLoaded]
);
const updateBackendWarnings = useCallback(async () => {

View file

@ -139,8 +139,8 @@ export class IPCHandlers {
this.koboldManager.parseConfigFile(filePath)
);
ipcMain.handle('kobold:selectModelFile', () =>
this.koboldManager.selectModelFile()
ipcMain.handle('kobold:selectModelFile', (_, title) =>
this.koboldManager.selectModelFile(title)
);
ipcMain.handle('config:get', (_, key) => this.configManager.get(key));
@ -165,9 +165,14 @@ export class IPCHandlers {
ipcMain.handle('app:openExternal', async (_, url) => {
try {
await shell.openExternal(url);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
this.logManager.logError(
'Failed to open external URL:',
error as Error
);
throw new Error(
`Failed to open external URL: ${(error as Error).message}`
);
}
});
@ -175,9 +180,11 @@ export class IPCHandlers {
try {
const logsDir = this.logManager.getLogsDirectory();
await shell.openPath(logsDir);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
this.logManager.logError('Failed to open logs folder:', error as Error);
throw new Error(
`Failed to open logs folder: ${(error as Error).message}`
);
}
});
@ -200,6 +207,32 @@ export class IPCHandlers {
mainWindow?.close();
});
ipcMain.handle('app:getZoomLevel', async () => {
const mainWindow = this.windowManager.getMainWindow();
if (mainWindow) {
return mainWindow.webContents.getZoomLevel();
}
return 0;
});
ipcMain.handle('app:setZoomLevel', async (_, level: number) => {
const mainWindow = this.windowManager.getMainWindow();
if (mainWindow) {
mainWindow.webContents.setZoomLevel(level);
await this.configManager.set('zoomLevel', level);
}
});
const mainWindow = this.windowManager.getMainWindow();
if (mainWindow) {
mainWindow.webContents.once('did-finish-load', async () => {
const savedZoomLevel = await this.configManager.get('zoomLevel');
if (typeof savedZoomLevel === 'number') {
mainWindow.webContents.setZoomLevel(savedZoomLevel);
}
});
}
ipcMain.handle('logs:logError', (_, message: string, error?: Error) => {
this.logManager.logError(message, error);
});

View file

@ -398,7 +398,7 @@ export class KoboldCppManager {
}
}
async selectModelFile(): Promise<string | null> {
async selectModelFile(title = 'Select Model File'): Promise<string | null> {
try {
const mainWindow = this.windowManager.getMainWindow();
if (!mainWindow) {
@ -406,9 +406,12 @@ export class KoboldCppManager {
}
const result = await dialog.showOpenDialog(mainWindow, {
title: 'Select Model File',
title,
filters: [
{ name: 'GGUF Files', extensions: ['gguf'] },
{
name: 'Model Files',
extensions: ['gguf', 'safetensors'],
},
{ name: 'All Files', extensions: ['*'] },
],
properties: ['openFile'],

View file

@ -32,7 +32,7 @@ export class WindowManager {
const iconImage = nativeImage.createFromPath(iconPath);
const { workAreaSize } = screen.getPrimaryDisplay();
const windowHeight = Math.floor(workAreaSize.height * 0.85);
const windowHeight = Math.floor(workAreaSize.height * 0.86);
this.mainWindow = new BrowserWindow({
width: 1000,

View file

@ -5,13 +5,12 @@ import type {
ConfigAPI,
LogsAPI,
SillyTavernAPI,
KoboldConfig,
} from '@/types/electron';
const koboldAPI: KoboldAPI = {
getInstalledVersions: () => ipcRenderer.invoke('kobold:getInstalledVersions'),
getCurrentVersion: () => ipcRenderer.invoke('kobold:getCurrentVersion'),
setCurrentVersion: (version: string) =>
setCurrentVersion: (version) =>
ipcRenderer.invoke('kobold:setCurrentVersion', version),
getPlatform: () => ipcRenderer.invoke('kobold:getPlatform'),
detectGPU: () => ipcRenderer.invoke('kobold:detectGPU'),
@ -21,24 +20,24 @@ const koboldAPI: KoboldAPI = {
detectGPUMemory: () => ipcRenderer.invoke('kobold:detectGPUMemory'),
detectROCm: () => ipcRenderer.invoke('kobold:detectROCm'),
detectBackendSupport: () => ipcRenderer.invoke('kobold:detectBackendSupport'),
getAvailableBackends: (includeDisabled?: boolean) =>
getAvailableBackends: (includeDisabled) =>
ipcRenderer.invoke('kobold:getAvailableBackends', includeDisabled),
getCurrentInstallDir: () => ipcRenderer.invoke('kobold:getCurrentInstallDir'),
selectInstallDirectory: () =>
ipcRenderer.invoke('kobold:selectInstallDirectory'),
downloadRelease: (asset) =>
ipcRenderer.invoke('kobold:downloadRelease', asset),
launchKoboldCpp: (args?: string[]) =>
ipcRenderer.invoke('kobold:launchKoboldCpp', args),
launchKoboldCpp: (args) => ipcRenderer.invoke('kobold:launchKoboldCpp', args),
getConfigFiles: () => ipcRenderer.invoke('kobold:getConfigFiles'),
saveConfigFile: (configName: string, configData: KoboldConfig) =>
saveConfigFile: (configName, configData) =>
ipcRenderer.invoke('kobold:saveConfigFile', configName, configData),
getSelectedConfig: () => ipcRenderer.invoke('kobold:getSelectedConfig'),
setSelectedConfig: (configName: string) =>
setSelectedConfig: (configName) =>
ipcRenderer.invoke('kobold:setSelectedConfig', configName),
parseConfigFile: (filePath: string) =>
parseConfigFile: (filePath) =>
ipcRenderer.invoke('kobold:parseConfigFile', filePath),
selectModelFile: () => ipcRenderer.invoke('kobold:selectModelFile'),
selectModelFile: (title) =>
ipcRenderer.invoke('kobold:selectModelFile', title),
stopKoboldCpp: () => ipcRenderer.invoke('kobold:stopKoboldCpp'),
onDownloadProgress: (callback) => {
ipcRenderer.on(
@ -46,7 +45,7 @@ const koboldAPI: KoboldAPI = {
(_: IpcRendererEvent, progress: number) => callback(progress)
);
},
onInstallDirChanged: (callback: (newPath: string) => void) => {
onInstallDirChanged: (callback) => {
const handler = (_: IpcRendererEvent, newPath: string) => callback(newPath);
ipcRenderer.on('install-dir-changed', handler);
@ -54,7 +53,7 @@ const koboldAPI: KoboldAPI = {
ipcRenderer.removeListener('install-dir-changed', handler);
};
},
onVersionsUpdated: (callback: () => void) => {
onVersionsUpdated: (callback) => {
const handler = () => callback();
ipcRenderer.on('versions-updated', handler);
@ -62,7 +61,7 @@ const koboldAPI: KoboldAPI = {
ipcRenderer.removeListener('versions-updated', handler);
};
},
onKoboldOutput: (callback: (data: string) => void) => {
onKoboldOutput: (callback) => {
const handler = (_: IpcRendererEvent, data: string) => callback(data);
ipcRenderer.on('kobold-output', handler);
@ -83,16 +82,17 @@ const appAPI: AppAPI = {
minimizeWindow: () => ipcRenderer.invoke('app:minimizeWindow'),
maximizeWindow: () => ipcRenderer.invoke('app:maximizeWindow'),
closeWindow: () => ipcRenderer.invoke('app:closeWindow'),
getZoomLevel: () => ipcRenderer.invoke('app:getZoomLevel'),
setZoomLevel: (level) => ipcRenderer.invoke('app:setZoomLevel', level),
};
const configAPI: ConfigAPI = {
get: (key: string) => ipcRenderer.invoke('config:get', key),
set: (key: string, value: unknown) =>
ipcRenderer.invoke('config:set', key, value),
get: (key) => ipcRenderer.invoke('config:get', key),
set: (key, value) => ipcRenderer.invoke('config:set', key, value),
};
const logsAPI: LogsAPI = {
logError: (message: string, error?: Error) =>
logError: (message, error) =>
ipcRenderer.invoke('logs:logError', message, error),
};

View file

@ -7,7 +7,7 @@ interface LaunchConfigState {
gpuLayers: number;
autoGpuLayers: boolean;
contextSize: number;
modelPath: string;
model: string;
additionalArguments: string;
port?: number;
host: string;
@ -37,11 +37,12 @@ interface LaunchConfigState {
sdconvdirect: SdConvDirectMode;
moecpu: number;
moeexperts: number;
isImageGenerationMode: boolean;
setGpuLayers: (layers: number) => void;
setAutoGpuLayers: (auto: boolean) => void;
setContextSize: (size: number) => void;
setModelPath: (path: string) => void;
setModel: (path: string) => void;
setAdditionalArguments: (args: string) => void;
setPort: (port?: number) => void;
setHost: (host: string) => void;
@ -93,7 +94,7 @@ export const useLaunchConfigStore = create<LaunchConfigState>((set, get) => ({
gpuLayers: 0,
autoGpuLayers: true,
contextSize: DEFAULT_CONTEXT_SIZE,
modelPath: '',
model: '',
additionalArguments: '',
port: undefined,
host: '',
@ -124,10 +125,12 @@ export const useLaunchConfigStore = create<LaunchConfigState>((set, get) => ({
moecpu: 0,
moeexperts: -1,
isImageGenerationMode: false,
setGpuLayers: (layers) => set({ gpuLayers: layers }),
setAutoGpuLayers: (auto) => set({ autoGpuLayers: auto }),
setContextSize: (size) => set({ contextSize: size }),
setModelPath: (path) => set({ modelPath: path }),
setModel: (path) => set({ model: path }),
setAdditionalArguments: (args) => set({ additionalArguments: args }),
setPort: (port) => set({ port }),
setHost: (host) => set({ host }),
@ -152,7 +155,11 @@ export const useLaunchConfigStore = create<LaunchConfigState>((set, get) => ({
setGpuDeviceSelection: (selection) => set({ gpuDeviceSelection: selection }),
setTensorSplit: (split) => set({ tensorSplit: split }),
setGpuPlatform: (platform) => set({ gpuPlatform: platform }),
setSdmodel: (model) => set({ sdmodel: model }),
setSdmodel: (model) =>
set({
sdmodel: model,
isImageGenerationMode: Boolean(model?.trim()),
}),
setSdt5xxl: (model) => set({ sdt5xxl: model }),
setSdclipl: (model) => set({ sdclipl: model }),
setSdclipg: (model) => set({ sdclipg: model }),
@ -190,7 +197,7 @@ export const useLaunchConfigStore = create<LaunchConfigState>((set, get) => ({
}
if (typeof configData.model === 'string') {
updates.modelPath = configData.model;
updates.model = configData.model;
}
if (typeof configData.additionalArguments === 'string') {
@ -316,6 +323,7 @@ export const useLaunchConfigStore = create<LaunchConfigState>((set, get) => ({
if (typeof configData.sdmodel === 'string') {
updates.sdmodel = configData.sdmodel;
updates.isImageGenerationMode = Boolean(configData.sdmodel?.trim());
}
if (typeof configData.sdt5xxl === 'string') {
@ -387,56 +395,75 @@ export const useLaunchConfigStore = create<LaunchConfigState>((set, get) => ({
},
selectModelFile: async () => {
const result = await window.electronAPI.kobold.selectModelFile();
const result = await window.electronAPI.kobold.selectModelFile(
'Select a Text Model File'
);
if (result) {
set({ modelPath: result });
set({ model: result });
}
},
selectSdmodelFile: async () => {
const result = await window.electronAPI.kobold.selectModelFile();
const result = await window.electronAPI.kobold.selectModelFile(
'Select a Image Gen. Model File'
);
if (result) {
set({ sdmodel: result });
set({
sdmodel: result,
isImageGenerationMode: Boolean(result?.trim()),
});
}
},
selectSdt5xxlFile: async () => {
const result = await window.electronAPI.kobold.selectModelFile();
const result = await window.electronAPI.kobold.selectModelFile(
'Select a SDT5XXL Model File'
);
if (result) {
set({ sdt5xxl: result });
}
},
selectSdcliplFile: async () => {
const result = await window.electronAPI.kobold.selectModelFile();
const result = await window.electronAPI.kobold.selectModelFile(
'Select a SDCLIP-L Model File'
);
if (result) {
set({ sdclipl: result });
}
},
selectSdclipgFile: async () => {
const result = await window.electronAPI.kobold.selectModelFile();
const result = await window.electronAPI.kobold.selectModelFile(
'Select a SDCLIP-G Model File'
);
if (result) {
set({ sdclipg: result });
}
},
selectSdphotomakerFile: async () => {
const result = await window.electronAPI.kobold.selectModelFile();
const result = await window.electronAPI.kobold.selectModelFile(
'Select a SDPhotoMaker Model File'
);
if (result) {
set({ sdphotomaker: result });
}
},
selectSdvaeFile: async () => {
const result = await window.electronAPI.kobold.selectModelFile();
const result = await window.electronAPI.kobold.selectModelFile(
'Select a SDVAE Model File'
);
if (result) {
set({ sdvae: result });
}
},
selectSdloraFile: async () => {
const result = await window.electronAPI.kobold.selectModelFile();
const result = await window.electronAPI.kobold.selectModelFile(
'Select a SDLORA Model File'
);
if (result) {
set({ sdlora: result });
}
@ -450,6 +477,7 @@ export const useLaunchConfigStore = create<LaunchConfigState>((set, get) => ({
applyImageModelPreset: (preset: ImageModelPreset) => {
set({
sdmodel: preset.sdmodel,
isImageGenerationMode: Boolean(preset.sdmodel?.trim()),
sdt5xxl: preset.sdt5xxl,
sdclipl: preset.sdclipl,
sdclipg: preset.sdclipg || '',

View file

@ -6,7 +6,7 @@
position: relative !important;
top: 2.5rem !important;
margin-top: 0 !important;
padding-top: 1rem !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
min-height: calc(100vh - 2.5rem) !important;
}

View file

@ -9,6 +9,6 @@
.terminalScrollArea {
flex: 1;
font-family: Monaco, Menlo, 'Ubuntu Mono', monospace;
font-size: 14px;
font-size: 0.875em;
line-height: 1.4;
}

View file

@ -57,7 +57,6 @@ export interface DownloadItem {
export interface KoboldConfig {
gpulayers?: number;
contextsize?: number;
model_param?: string;
port?: number;
host?: string;
multiuser?: number;
@ -122,7 +121,7 @@ export interface KoboldAPI {
getSelectedConfig: () => Promise<string | null>;
setSelectedConfig: (configName: string) => Promise<boolean>;
parseConfigFile: (filePath: string) => Promise<KoboldConfig | null>;
selectModelFile: () => Promise<string | null>;
selectModelFile: (title?: string) => Promise<string | null>;
stopKoboldCpp: () => void;
onDownloadProgress: (callback: (progress: number) => void) => void;
onInstallDirChanged: (callback: (newPath: string) => void) => () => void;
@ -150,6 +149,8 @@ export interface AppAPI {
minimizeWindow: () => void;
maximizeWindow: () => void;
closeWindow: () => void;
getZoomLevel: () => Promise<number>;
setZoomLevel: (level: number) => Promise<void>;
}
export interface ConfigAPI {

View file

@ -10,8 +10,6 @@ export type SdConvDirectMode = 'off' | 'vaeonly' | 'full';
export type FrontendPreference = 'koboldcpp' | 'sillytavern';
export type ServerTabMode = 'chat' | 'image-generation';
export type Screen = 'welcome' | 'download' | 'launch' | 'interface';
export interface GitHubAsset {

10
src/utils/zoom.ts Normal file
View file

@ -0,0 +1,10 @@
import { ZOOM } from '@/constants';
export const zoomLevelToPercentage = (zoomLevel: number): number =>
Math.round(Math.pow(1.2, zoomLevel) * 100);
export const percentageToZoomLevel = (percentage: number): number =>
Math.log(percentage / 100) / Math.log(1.2);
export const isValidZoomPercentage = (percentage: number): boolean =>
percentage >= ZOOM.MIN_PERCENTAGE && percentage <= ZOOM.MAX_PERCENTAGE;