better titlebar support for the AppHeader, new additional argument option modal

This commit is contained in:
Egor 2025-08-31 17:15:39 -07:00
parent 88c901a657
commit 4ddacd5ae6
11 changed files with 913 additions and 197 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "gerbil", "name": "gerbil",
"productName": "Gerbil", "productName": "Gerbil",
"version": "0.9.3", "version": "0.9.4",
"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

@ -11,6 +11,7 @@ import { ScreenTransition } from '@/components/ScreenTransition';
import { TitleBar } from '@/components/TitleBar'; import { TitleBar } from '@/components/TitleBar';
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 { safeExecute } from '@/utils/logger'; import { safeExecute } from '@/utils/logger';
import { TITLEBAR_HEIGHT } from '@/constants'; import { TITLEBAR_HEIGHT } from '@/constants';
import type { DownloadItem } from '@/types/electron'; import type { DownloadItem } from '@/types/electron';
@ -18,15 +19,15 @@ import type { InterfaceTab, FrontendPreference, Screen } from '@/types';
export const App = () => { export const App = () => {
const [currentScreen, setCurrentScreen] = useState<Screen | null>(null); const [currentScreen, setCurrentScreen] = useState<Screen | null>(null);
const [settingsOpened, setSettingsOpened] = useState(false);
const [hasInitialized, setHasInitialized] = useState(false); const [hasInitialized, setHasInitialized] = useState(false);
const [showEjectModal, setShowEjectModal] = useState(false);
const [activeInterfaceTab, setActiveInterfaceTab] = const [activeInterfaceTab, setActiveInterfaceTab] =
useState<InterfaceTab>('terminal'); useState<InterfaceTab>('terminal');
const [isImageGenerationMode, setIsImageGenerationMode] = useState(false); const [isImageGenerationMode, setIsImageGenerationMode] = useState(false);
const [frontendPreference, setFrontendPreference] = const [frontendPreference, setFrontendPreference] =
useState<FrontendPreference>('koboldcpp'); useState<FrontendPreference>('koboldcpp');
const { modals, setModalOpen } = useModalStore();
const { const {
updateInfo: binaryUpdateInfo, updateInfo: binaryUpdateInfo,
showUpdateModal, showUpdateModal,
@ -120,7 +121,7 @@ export const App = () => {
if (skipEjectConfirmation) { if (skipEjectConfirmation) {
performEject(); performEject();
} else { } else {
setShowEjectModal(true); setModalOpen('ejectConfirm', true);
} }
}; };
@ -147,31 +148,20 @@ export const App = () => {
} }
}; };
const isAnyModalOpen = settingsOpened || showEjectModal || showUpdateModal;
return ( return (
<AppShell <AppShell
header={{ height: TITLEBAR_HEIGHT }} header={{ height: TITLEBAR_HEIGHT }}
padding={currentScreen === 'interface' ? 0 : 'md'} padding={currentScreen === 'interface' ? 0 : 'md'}
> >
<AppShell.Header style={{ display: 'flex', flexDirection: 'column' }}> <TitleBar
<TitleBar currentScreen={currentScreen || 'welcome'}
currentScreen={currentScreen || 'welcome'} currentTab={activeInterfaceTab}
currentTab={activeInterfaceTab} onTabChange={setActiveInterfaceTab}
onTabChange={setActiveInterfaceTab} onEject={handleEject}
onEject={handleEject} onOpenSettings={() => setModalOpen('settings', true)}
onOpenSettings={() => setSettingsOpened(true)} />
isModalOpen={isAnyModalOpen}
/> <AppShell.Main>
</AppShell.Header>
<AppShell.Main
style={{
position: 'relative',
overflow: 'hidden',
minHeight:
currentScreen === 'welcome' ? '100vh' : 'calc(100vh - 2rem)',
}}
>
{currentScreen === null ? ( {currentScreen === null ? (
<Center h="100%" style={{ minHeight: '25rem' }}> <Center h="100%" style={{ minHeight: '25rem' }}>
<Stack align="center" gap="lg"> <Stack align="center" gap="lg">
@ -238,9 +228,9 @@ export const App = () => {
)} )}
</AppShell.Main> </AppShell.Main>
<SettingsModal <SettingsModal
opened={settingsOpened} opened={modals.settings}
onClose={async () => { onClose={async () => {
setSettingsOpened(false); setModalOpen('settings', false);
const preference = await safeExecute( const preference = await safeExecute(
() => () =>
window.electronAPI.config.get( window.electronAPI.config.get(
@ -253,8 +243,8 @@ export const App = () => {
currentScreen={currentScreen || undefined} currentScreen={currentScreen || undefined}
/> />
<EjectConfirmModal <EjectConfirmModal
opened={showEjectModal} opened={modals.ejectConfirm}
onClose={() => setShowEjectModal(false)} onClose={() => setModalOpen('ejectConfirm', false)}
onConfirm={handleEjectConfirm} onConfirm={handleEjectConfirm}
/> />
</AppShell> </AppShell>

View file

@ -0,0 +1,643 @@
import {
Modal,
Text,
Stack,
Group,
Badge,
Accordion,
Code,
TextInput,
Button,
} from '@mantine/core';
import { useState } from 'react';
interface CommandLineArgumentsModalProps {
opened: boolean;
onClose: () => void;
onAddArgument?: (argument: string) => void;
}
interface ArgumentInfo {
flag: string;
aliases?: string[];
description: string;
metavar?: string;
default?: string | number;
type?: string;
choices?: string[];
category: string;
}
const UI_COVERED_ARGS = new Set([
'--model',
'--sdmodel',
'--gpulayers',
'--contextsize',
'--port',
'--host',
'--multiuser',
'--multiplayer',
'--remotetunnel',
'--nocertify',
'--websearch',
'--noshift',
'--flashattention',
'--noavx2',
'--failsafe',
'--usemmap',
'--moeexperts',
'--moecpu',
'--usecuda',
'--usevulkan',
'--useclblast',
'--tensorsplit',
'--sdt5xxl',
'--sdclipl',
'--sdclipg',
'--sdphotomaker',
'--sdvae',
'--sdlora',
'--sdflashattention',
'--sdconvdirect',
]);
const COMMAND_LINE_ARGUMENTS: ArgumentInfo[] = [
{
flag: '--threads',
aliases: ['-t'],
description:
'Use a custom number of threads if specified. Otherwise, uses an amount based on CPU cores',
metavar: '[threads]',
type: 'int',
category: 'Basic',
},
{
flag: '--launch',
description: 'Launches a web browser when load is completed.',
type: 'boolean',
category: 'Basic',
},
{
flag: '--config',
description:
'Load settings from a .kcpps file. Other arguments will be ignored',
metavar: '[filename]',
type: 'string',
category: 'Basic',
},
{
flag: '--version',
description: 'Prints version and exits.',
type: 'boolean',
category: 'Advanced',
},
{
flag: '--analyze',
description:
'Reads the metadata, weight types and tensor names in any GGUF file.',
metavar: '[filename]',
default: '',
category: 'Advanced',
},
{
flag: '--maingpu',
aliases: ['--main-gpu', '-mg'],
description:
'Only used in a multi-gpu setup. Sets the index of the main GPU that will be used.',
metavar: '[Device ID]',
type: 'int',
default: -1,
category: 'Advanced',
},
{
flag: '--blasbatchsize',
aliases: ['--batch-size', '-b'],
description:
'Sets the batch size used in BLAS processing (default 512). Setting it to -1 disables BLAS mode, but keeps other benefits like GPU offload.',
type: 'int',
choices: [
'-1',
'16',
'32',
'64',
'128',
'256',
'512',
'1024',
'2048',
'4096',
],
default: 512,
category: 'Advanced',
},
{
flag: '--blasthreads',
aliases: ['--threads-batch'],
description:
'Use a different number of threads during BLAS if specified. Otherwise, has the same value as --threads',
metavar: '[threads]',
type: 'int',
default: 0,
category: 'Advanced',
},
{
flag: '--lora',
description: 'GGUF models only, applies a lora file on top of model.',
metavar: '[lora_filename]',
type: 'string[]',
category: 'Advanced',
},
{
flag: '--loramult',
description: 'Multiplier for the Text LORA model to be applied.',
metavar: '[amount]',
type: 'float',
default: 1.0,
category: 'Advanced',
},
{
flag: '--nofastforward',
description:
'If set, do not attempt to fast forward GGUF context (always reprocess). Will also enable noshift',
type: 'boolean',
category: 'Advanced',
},
{
flag: '--useswa',
description:
'If set, allows Sliding Window Attention (SWA) KV Cache, which saves memory but cannot be used with context shifting.',
type: 'boolean',
category: 'Advanced',
},
{
flag: '--ropeconfig',
description:
'If set, uses customized RoPE scaling from configured frequency scale and frequency base (e.g. --ropeconfig 0.25 10000). Otherwise, uses NTK-Aware scaling set automatically based on context size.',
metavar: '[rope-freq-scale] [rope-freq-base]',
default: '0.0 10000.0',
type: 'float[]',
category: 'Advanced',
},
{
flag: '--overridenativecontext',
description:
'Overrides the native trained context of the loaded model with a custom value to be used for Rope scaling.',
metavar: '[trained context]',
type: 'int',
default: 0,
category: 'Advanced',
},
{
flag: '--usemlock',
aliases: ['--mlock'],
description:
'Enables mlock, preventing the RAM used to load the model from being paged out. Not usually recommended.',
type: 'boolean',
category: 'Advanced',
},
{
flag: '--debugmode',
description: 'Shows additional debug info in the terminal.',
type: 'int',
default: 0,
category: 'Advanced',
},
{
flag: '--onready',
description:
'An optional shell command to execute after the model has been loaded.',
metavar: '[shell command]',
type: 'string',
default: '',
category: 'Advanced',
},
{
flag: '--benchmark',
description:
'Do not start server, instead run benchmarks. If filename is provided, appends results to provided file.',
metavar: '[filename]',
type: 'string',
default: 'stdout',
category: 'Advanced',
},
{
flag: '--prompt',
aliases: ['-p'],
description:
'Passing a prompt string triggers a direct inference, loading the model, outputs the response to stdout and exits. Can be used alone or with benchmark.',
metavar: '[prompt]',
type: 'string',
default: '',
category: 'Advanced',
},
{
flag: '--cli',
description:
'Does not launch KoboldCpp HTTP server. Instead, enables KoboldCpp from the command line, accepting interactive console input and displaying responses to the terminal.',
type: 'boolean',
category: 'Advanced',
},
{
flag: '--genlimit',
aliases: ['--promptlimit'],
description:
'Sets the maximum number of generated tokens, it will restrict all generations to this or lower. Also usable with --prompt or --benchmark.',
metavar: '[token limit]',
type: 'int',
default: 0,
category: 'Advanced',
},
{
flag: '--highpriority',
description:
'Experimental flag. If set, increases the process CPU priority, potentially speeding up generation. Use caution.',
type: 'boolean',
category: 'Advanced',
},
{
flag: '--foreground',
description:
'Windows only. Sends the terminal to the foreground every time a new prompt is generated. This helps avoid some idle slowdown issues.',
type: 'boolean',
category: 'Advanced',
},
{
flag: '--preloadstory',
description:
'Configures a prepared story json save file to be hosted on the server, which frontends (such as KoboldAI Lite) can access over the API.',
metavar: '[savefile]',
default: '',
category: 'Advanced',
},
{
flag: '--savedatafile',
description:
'If enabled, creates or opens a persistent database file on the server, that allows users to save and load their data remotely. A new file is created if it does not exist.',
metavar: '[savefile]',
default: '',
category: 'Advanced',
},
{
flag: '--quiet',
description:
'Enable quiet mode, which hides generation inputs and outputs in the terminal. Quiet mode is automatically enabled when running a horde worker.',
type: 'boolean',
category: 'Advanced',
},
{
flag: '--ssl',
description:
'Allows all content to be served over SSL instead. A valid UNENCRYPTED SSL cert and key .pem files must be provided',
metavar: '[cert_pem] [key_pem]',
type: 'string[]',
category: 'Advanced',
},
{
flag: '--password',
description:
'Enter a password required to use this instance. This key will be required for all text endpoints. Image endpoints are not secured.',
metavar: '[API key]',
default: 'None',
category: 'Advanced',
},
{
flag: '--mmproj',
description:
'Select a multimodal projector file for vision models like LLaVA.',
metavar: '[filename]',
default: '',
category: 'Multimodal',
},
{
flag: '--mmprojcpu',
aliases: ['--no-mmproj-offload'],
description: 'Force CLIP for Vision mmproj always on CPU.',
type: 'boolean',
category: 'Multimodal',
},
{
flag: '--visionmaxres',
description:
'Clamp MMProj vision maximum allowed resolution. Allowed values are between 512 to 2048 px (default 1024).',
metavar: '[max px]',
type: 'int',
default: 1024,
category: 'Multimodal',
},
{
flag: '--draftmodel',
aliases: ['--model-draft', '-md'],
description:
'Load a small draft model for speculative decoding. It will be fully offloaded. Vocab must match the main model.',
metavar: '[filename]',
default: '',
category: 'Speculative Decoding',
},
{
flag: '--draftamount',
aliases: ['--draft-max', '--draft-n'],
description: 'How many tokens to draft per chunk before verifying results',
metavar: '[tokens]',
type: 'int',
default: 16,
category: 'Speculative Decoding',
},
{
flag: '--draftgpulayers',
aliases: ['--gpu-layers-draft', '--n-gpu-layers-draft', '-ngld'],
description:
'How many layers to offload to GPU for the draft model (default=full offload)',
metavar: '[layers]',
type: 'int',
default: 999,
category: 'Speculative Decoding',
},
{
flag: '--quantkv',
description:
'Sets the KV cache data type quantization, 0=f16, 1=q8, 2=q4. Requires Flash Attention for full effect, otherwise only K cache is quantized.',
metavar: '[quantization level 0/1/2]',
type: 'int',
choices: ['0', '1', '2'],
default: 0,
category: 'Performance',
},
{
flag: '--defaultgenamt',
description:
'How many tokens to generate by default, if not specified. Must be smaller than context size. Usually, your frontend GUI will override this.',
type: 'int',
default: 768,
category: 'Performance',
},
{
flag: '--nobostoken',
description:
'Prevents BOS token from being added at the start of any prompt. Usually NOT recommended for most models.',
type: 'boolean',
category: 'Performance',
},
{
flag: '--enableguidance',
description:
'Enables the use of Classifier-Free-Guidance, which allows the use of negative prompts. Has performance and memory impact.',
type: 'boolean',
category: 'Performance',
},
{
flag: '--admin',
description:
'Enables admin mode, allowing you to unload and reload different configurations or models.',
type: 'boolean',
category: 'Administration',
},
{
flag: '--adminpassword',
description:
'Require a password to access admin functions. You are strongly advised to use one for publically accessible instances!',
metavar: '[password]',
default: 'None',
category: 'Administration',
},
{
flag: '--admindir',
description:
'Specify a directory to look for .kcpps configs in, which can be used to swap models.',
metavar: '[directory]',
default: '',
category: 'Administration',
},
{
flag: '--hordemodelname',
description: 'Sets your AI Horde display model name.',
metavar: '[name]',
default: '',
category: 'Horde Worker',
},
{
flag: '--hordeworkername',
description: 'Sets your AI Horde worker name.',
metavar: '[name]',
default: '',
category: 'Horde Worker',
},
{
flag: '--hordekey',
description: 'Sets your AI Horde API key.',
metavar: '[apikey]',
default: '',
category: 'Horde Worker',
},
{
flag: '--sdthreads',
description:
'Use a different number of threads for image generation if specified. Otherwise, has the same value as --threads.',
metavar: '[threads]',
type: 'int',
default: 0,
category: 'Image Generation',
},
{
flag: '--sdquant',
description:
'If specified, loads the model quantized to save memory. 0=off, 1=q8, 2=q4',
metavar: '[quantization level 0/1/2]',
type: 'int',
choices: ['0', '1', '2'],
default: 0,
category: 'Image Generation',
},
{
flag: '--whispermodel',
description:
'Specify a Whisper .bin model to enable Speech-To-Text transcription.',
metavar: '[filename]',
default: '',
category: 'Audio',
},
{
flag: '--ttsmodel',
description: 'Specify the TTS Text-To-Speech GGUF model.',
metavar: '[filename]',
default: '',
category: 'Audio',
},
{
flag: '--ttsgpu',
description: 'Use the GPU for TTS.',
type: 'boolean',
category: 'Audio',
},
{
flag: '--embeddingsmodel',
description:
'Specify an embeddings model to be loaded for generating embedding vectors.',
metavar: '[filename]',
default: '',
category: 'Embeddings',
},
{
flag: '--embeddingsgpu',
description:
'Attempts to offload layers of the embeddings model to GPU. Usually not needed.',
type: 'boolean',
category: 'Embeddings',
},
];
const AVAILABLE_ARGUMENTS = COMMAND_LINE_ARGUMENTS.filter(
(arg) => !UI_COVERED_ARGS.has(arg.flag)
);
export const CommandLineArgumentsModal = ({
opened,
onClose,
onAddArgument,
}: CommandLineArgumentsModalProps) => {
const [searchQuery, setSearchQuery] = useState('');
const argumentsByCategory = AVAILABLE_ARGUMENTS.reduce(
(acc, arg) => {
if (!acc[arg.category]) {
acc[arg.category] = [];
}
acc[arg.category].push(arg);
return acc;
},
{} as Record<string, ArgumentInfo[]>
);
const filteredCategories = Object.entries(argumentsByCategory).reduce(
(acc, [category, args]) => {
const filteredArgs = args.filter(
(arg) =>
arg.flag.toLowerCase().includes(searchQuery.toLowerCase()) ||
arg.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
(arg.aliases &&
arg.aliases.some((alias) =>
alias.toLowerCase().includes(searchQuery.toLowerCase())
))
);
if (filteredArgs.length > 0) {
acc[category] = filteredArgs;
}
return acc;
},
{} as Record<string, ArgumentInfo[]>
);
const handleAddArgument = (arg: ArgumentInfo) => {
if (onAddArgument) {
let argumentToAdd = arg.flag;
if (arg.type !== 'boolean' && arg.metavar) {
argumentToAdd += ' ';
}
onAddArgument(argumentToAdd);
}
};
const renderArgument = (arg: ArgumentInfo) => (
<Stack
key={arg.flag}
gap="xs"
p="sm"
style={{ borderLeft: '3px solid var(--mantine-color-blue-4)' }}
>
<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>
{arg.aliases &&
arg.aliases.map((alias) => (
<Code key={alias} style={{ fontSize: '12px' }} c="dimmed">
{alias}
</Code>
))}
{arg.type && (
<Badge size="xs" variant="light" color="blue">
{arg.type}
</Badge>
)}
</Group>
{onAddArgument && (
<Button
size="xs"
variant="light"
onClick={() => handleAddArgument(arg)}
>
Add
</Button>
)}
</Group>
<Text size="sm" c="dimmed">
{arg.description}
</Text>
{(arg.metavar || arg.default !== undefined || arg.choices) && (
<Group gap="lg">
{arg.metavar && (
<Text size="xs" c="dimmed">
<strong>Format:</strong> {arg.metavar}
</Text>
)}
{arg.default !== undefined && (
<Text size="xs" c="dimmed">
<strong>Default:</strong> {String(arg.default)}
</Text>
)}
{arg.choices && (
<Text size="xs" c="dimmed">
<strong>Choices:</strong> {arg.choices.join(', ')}
</Text>
)}
</Group>
)}
</Stack>
);
return (
<Modal
opened={opened}
onClose={onClose}
title="Available Command Line Arguments"
size="xl"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
These are additional command line arguments that can be added to the
&quot;Additional Arguments&quot; field.
</Text>
<TextInput
placeholder="Search arguments..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.currentTarget.value)}
/>
<Accordion variant="separated">
{Object.entries(filteredCategories).map(([category, args]) => (
<Accordion.Item key={category} value={category}>
<Accordion.Control>
<Group justify="space-between">
<Text fw={600}>{category}</Text>
<Badge size="sm" variant="light">
{args.length} argument{args.length !== 1 ? 's' : ''}
</Badge>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">{args.map(renderArgument)}</Stack>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
</Stack>
</Modal>
);
};

View file

@ -5,6 +5,7 @@ import {
Image, Image,
Select, Select,
useComputedColorScheme, useComputedColorScheme,
AppShell,
} from '@mantine/core'; } from '@mantine/core';
import { import {
Minus, Minus,
@ -17,9 +18,10 @@ import {
import { useState } from 'react'; import { useState } from 'react';
import { soundAssets, playSound, initializeAudio } from '@/utils/sounds'; import { soundAssets, playSound, initializeAudio } from '@/utils/sounds';
import { useAppUpdateChecker } from '@/hooks/useAppUpdateChecker'; import { useAppUpdateChecker } from '@/hooks/useAppUpdateChecker';
import { useModalStore } from '@/stores/modal';
import iconUrl from '/icon.png'; import iconUrl from '/icon.png';
import type { InterfaceTab, Screen } from '@/types';
import { PRODUCT_NAME, TITLEBAR_HEIGHT } from '@/constants'; import { PRODUCT_NAME, TITLEBAR_HEIGHT } from '@/constants';
import type { InterfaceTab, Screen } from '@/types';
interface TitleBarProps { interface TitleBarProps {
currentScreen: Screen; currentScreen: Screen;
@ -27,7 +29,6 @@ interface TitleBarProps {
onTabChange?: (tab: InterfaceTab) => void; onTabChange?: (tab: InterfaceTab) => void;
onEject?: () => void; onEject?: () => void;
onOpenSettings?: () => void; onOpenSettings?: () => void;
isModalOpen?: boolean;
} }
export const TitleBar = ({ export const TitleBar = ({
@ -36,12 +37,12 @@ export const TitleBar = ({
onTabChange, onTabChange,
onEject, onEject,
onOpenSettings, onOpenSettings,
isModalOpen = false,
}: TitleBarProps) => { }: TitleBarProps) => {
const computedColorScheme = useComputedColorScheme('light', { const computedColorScheme = useComputedColorScheme('light', {
getInitialValueInEffect: false, getInitialValueInEffect: false,
}); });
const { hasUpdate, openReleasePage } = useAppUpdateChecker(); const { hasUpdate, openReleasePage } = useAppUpdateChecker();
const { isAnyModalOpen } = useModalStore();
const [logoClickCount, setLogoClickCount] = useState(0); const [logoClickCount, setLogoClickCount] = useState(0);
const [isElephantMode, setIsElephantMode] = useState(false); const [isElephantMode, setIsElephantMode] = useState(false);
const [isMouseSqueaking, setIsMouseSqueaking] = useState(false); const [isMouseSqueaking, setIsMouseSqueaking] = useState(false);
@ -87,171 +88,177 @@ export const TitleBar = ({
}; };
return ( return (
<Box <AppShell.Header style={{ display: 'flex', flexDirection: 'column' }}>
style={{
height: TITLEBAR_HEIGHT,
padding: '0.125rem 0.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor:
computedColorScheme === 'dark'
? 'var(--mantine-color-dark-7)'
: 'var(--mantine-color-gray-0)',
borderBottom: '1px solid var(--mantine-color-default-border)',
WebkitAppRegion: isModalOpen ? 'no-drag' : 'drag',
userSelect: 'none',
position: 'relative',
}}
>
<Group gap="0.5rem" align="center" style={{ WebkitAppRegion: 'no-drag' }}>
<Image
src={iconUrl}
alt={PRODUCT_NAME}
w={24}
h={24}
style={{
minWidth: 24,
minHeight: 24,
cursor: 'pointer',
userSelect: 'none',
transition: 'transform 0.15s ease-in-out',
transform: isElephantMode
? 'scale(1.3) rotate(5deg)'
: 'scale(1) rotate(0deg)',
animation: isElephantMode
? 'elephantShake 1.5s ease-in-out'
: isMouseSqueaking
? 'mouseSqueak 0.3s ease-in-out'
: 'none',
}}
onClick={handleLogoClick}
/>
</Group>
<Box <Box
style={{ style={{
position: 'absolute', height: TITLEBAR_HEIGHT,
left: '50%', padding: '0.125rem 0.5rem',
transform: 'translateX(-50%)', display: 'flex',
WebkitAppRegion: 'no-drag', alignItems: 'center',
justifyContent: 'space-between',
backgroundColor:
computedColorScheme === 'dark'
? 'var(--mantine-color-dark-7)'
: 'var(--mantine-color-gray-0)',
borderBottom: '1px solid var(--mantine-color-default-border)',
WebkitAppRegion: isAnyModalOpen() ? 'no-drag' : 'drag',
userSelect: 'none',
position: 'relative',
}} }}
> >
{currentScreen === 'interface' && ( <Group
<Select gap="0.5rem"
placeholder="Interface" align="center"
value={currentTab} style={{ WebkitAppRegion: 'no-drag' }}
onChange={(value) => {
if (value === 'eject') {
onEject?.();
} else {
onTabChange?.(value as InterfaceTab);
}
}}
data={[
{
value: 'chat',
label: 'Chat',
},
{ value: 'terminal', label: 'Terminal' },
{ value: 'eject', label: 'Eject' },
]}
allowDeselect={false}
variant="unstyled"
size="sm"
style={{
textAlign: 'center',
minWidth: '120px',
}}
styles={{
input: {
textAlign: 'center',
backgroundColor: 'transparent',
border: 'none',
fontWeight: 500,
userSelect: 'none',
cursor: 'pointer',
},
}}
/>
)}
</Box>
<Group gap="0.125rem" style={{ WebkitAppRegion: 'no-drag' }}>
{hasUpdate && (
<ActionIcon
variant="subtle"
color="orange"
size="2rem"
onClick={openReleasePage}
aria-label="New release available"
style={{
borderRadius: '0.25rem',
margin: '0.125rem',
}}
>
<CircleFadingArrowUp size="1.25rem" />
</ActionIcon>
)}
<ActionIcon
variant="subtle"
size="2rem"
onClick={onOpenSettings}
aria-label="Open settings"
style={{
borderRadius: '0.25rem',
margin: '0.125rem',
}}
> >
<Settings size="1.25rem" /> <Image
</ActionIcon> src={iconUrl}
alt={PRODUCT_NAME}
w={24}
h={24}
style={{
minWidth: 24,
minHeight: 24,
cursor: 'pointer',
userSelect: 'none',
transition: 'transform 0.15s ease-in-out',
transform: isElephantMode
? 'scale(1.3) rotate(5deg)'
: 'scale(1) rotate(0deg)',
animation: isElephantMode
? 'elephantShake 1.5s ease-in-out'
: isMouseSqueaking
? 'mouseSqueak 0.3s ease-in-out'
: 'none',
}}
onClick={handleLogoClick}
/>
</Group>
<Box <Box
style={{ style={{
width: '0.0625rem', position: 'absolute',
height: '1.25rem', left: '50%',
backgroundColor: 'var(--mantine-color-default-border)', transform: 'translateX(-50%)',
margin: '0 0.25rem', WebkitAppRegion: 'no-drag',
}} }}
/> >
{currentScreen === 'interface' && (
<Select
placeholder="Interface"
value={currentTab}
onChange={(value) => {
if (value === 'eject') {
onEject?.();
} else {
onTabChange?.(value as InterfaceTab);
}
}}
data={[
{
value: 'chat',
label: 'Chat',
},
{ value: 'terminal', label: 'Terminal' },
{ value: 'eject', label: 'Eject' },
]}
allowDeselect={false}
variant="unstyled"
size="sm"
style={{
textAlign: 'center',
minWidth: '120px',
}}
styles={{
input: {
textAlign: 'center',
backgroundColor: 'transparent',
border: 'none',
fontWeight: 500,
userSelect: 'none',
cursor: 'pointer',
},
}}
/>
)}
</Box>
<Group gap="0.125rem" style={{ WebkitAppRegion: 'no-drag' }}>
{hasUpdate && (
<ActionIcon
variant="subtle"
color="orange"
size="2rem"
onClick={openReleasePage}
aria-label="New release available"
style={{
borderRadius: '0.25rem',
margin: '0.125rem',
}}
>
<CircleFadingArrowUp size="1.25rem" />
</ActionIcon>
)}
{[
{
icon: <Minus size="1rem" />,
onClick: handleMinimize,
color: undefined,
label: 'Minimize window',
},
{
icon: isMaximized ? <Copy size="1rem" /> : <Square size="1rem" />,
onClick: handleMaximize,
color: undefined,
label: isMaximized ? 'Restore window' : 'Maximize window',
},
{
icon: <X size="1.25rem" />,
onClick: handleClose,
color: 'red' as const,
label: 'Close window',
},
].map((button, index) => (
<ActionIcon <ActionIcon
key={index}
variant="subtle" variant="subtle"
size="2rem" size="2rem"
onClick={button.onClick} onClick={onOpenSettings}
color={button.color} aria-label="Open settings"
aria-label={button.label}
style={{ style={{
borderRadius: '0.25rem', borderRadius: '0.25rem',
margin: '0.125rem', margin: '0.125rem',
}} }}
> >
{button.icon} <Settings size="1.25rem" />
</ActionIcon> </ActionIcon>
))}
</Group> <Box
</Box> style={{
width: '0.0625rem',
height: '1.25rem',
backgroundColor: 'var(--mantine-color-default-border)',
margin: '0 0.25rem',
}}
/>
{[
{
icon: <Minus size="1rem" />,
onClick: handleMinimize,
color: undefined,
label: 'Minimize window',
},
{
icon: isMaximized ? <Copy size="1rem" /> : <Square size="1rem" />,
onClick: handleMaximize,
color: undefined,
label: isMaximized ? 'Restore window' : 'Maximize window',
},
{
icon: <X size="1.25rem" />,
onClick: handleClose,
color: 'red' as const,
label: 'Close window',
},
].map((button, index) => (
<ActionIcon
key={index}
variant="subtle"
size="2rem"
onClick={button.onClick}
color={button.color}
aria-label={button.label}
style={{
borderRadius: '0.25rem',
margin: '0.125rem',
}}
>
{button.icon}
</ActionIcon>
))}
</Group>
</Box>
</AppShell.Header>
); );
}; };

View file

@ -10,7 +10,7 @@ import { ChevronDown } from 'lucide-react';
import styles from '@/styles/layout.module.css'; import styles from '@/styles/layout.module.css';
import { SERVER_READY_SIGNALS } from '@/constants'; import { SERVER_READY_SIGNALS } from '@/constants';
import { handleTerminalOutput, processTerminalContent } from '@/utils/terminal'; import { handleTerminalOutput, processTerminalContent } from '@/utils/terminal';
import { useLaunchConfigStore } from '@/stores/launchConfigStore'; import { useLaunchConfigStore } from '@/stores/launchConfig';
import type { FrontendPreference } from '@/types'; import type { FrontendPreference } from '@/types';
interface TerminalTabProps { interface TerminalTabProps {

View file

@ -1,8 +1,17 @@
import { Stack, Group, Text, TextInput, NumberInput } from '@mantine/core'; import {
Stack,
Group,
Text,
TextInput,
NumberInput,
Button,
} from '@mantine/core';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { InfoTooltip } from '@/components/InfoTooltip'; import { InfoTooltip } from '@/components/InfoTooltip';
import { CheckboxWithTooltip } from '@/components/CheckboxWithTooltip'; import { CheckboxWithTooltip } from '@/components/CheckboxWithTooltip';
import { CommandLineArgumentsModal } from '@/components/CommandLineArgumentsModal';
import { useLaunchConfig } from '@/hooks/useLaunchConfig'; import { useLaunchConfig } from '@/hooks/useLaunchConfig';
import { useModalStore } from '@/stores/modal';
import { safeExecute } from '@/utils/logger'; import { safeExecute } from '@/utils/logger';
export const AdvancedTab = () => { export const AdvancedTab = () => {
@ -29,12 +38,21 @@ export const AdvancedTab = () => {
handleMoecpuChange, handleMoecpuChange,
handleMoeexpertsChange, handleMoeexpertsChange,
} = useLaunchConfig(); } = useLaunchConfig();
const { modals, setModalOpen } = useModalStore();
const [backendSupport, setBackendSupport] = useState<{ const [backendSupport, setBackendSupport] = useState<{
noavx2: boolean; noavx2: boolean;
failsafe: boolean; failsafe: boolean;
} | null>(null); } | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const handleAddArgument = (newArgument: string) => {
const currentArgs = additionalArguments.trim();
const updatedArgs = currentArgs
? `${currentArgs} ${newArgument}`
: newArgument;
handleAdditionalArgumentsChange(updatedArgs);
};
const isGpuBackend = backend === 'cuda' || backend === 'rocm'; const isGpuBackend = backend === 'cuda' || backend === 'rocm';
useEffect(() => { useEffect(() => {
@ -62,11 +80,6 @@ export const AdvancedTab = () => {
return ( return (
<Stack gap="md"> <Stack gap="md">
<div> <div>
<Group gap="xs" align="center" mb="md">
<Text size="sm" fw={600}>
Performance Options
</Text>
</Group>
<Stack gap="md"> <Stack gap="md">
<Group gap="lg" align="flex-start" wrap="nowrap"> <Group gap="lg" align="flex-start" wrap="nowrap">
<CheckboxWithTooltip <CheckboxWithTooltip
@ -155,11 +168,6 @@ export const AdvancedTab = () => {
</div> </div>
<div> <div>
<Group gap="xs" align="center" mb="md">
<Text size="sm" fw={600}>
Mixture of Experts (MoE) Settings
</Text>
</Group>
<Stack gap="md"> <Stack gap="md">
<Group gap="lg" align="flex-start" wrap="nowrap"> <Group gap="lg" align="flex-start" wrap="nowrap">
<div style={{ flex: 1, minWidth: 200 }}> <div style={{ flex: 1, minWidth: 200 }}>
@ -204,11 +212,20 @@ export const AdvancedTab = () => {
</div> </div>
<div> <div>
<Group gap="xs" align="center" mb="xs"> <Group mb="md" justify="space-between">
<Text size="sm" fw={500}> <Group>
Additional arguments <Text size="sm" fw={500}>
</Text> Additional arguments
<InfoTooltip label="Additional command line arguments to pass to the KoboldCPP binary. Leave this empty if you don't know what they are." /> </Text>
<InfoTooltip label="Additional command line arguments to pass to the KoboldCPP binary. Leave this empty if you don't know what they are." />
</Group>
<Button
size="xs"
variant="light"
onClick={() => setModalOpen('commandLineArguments', true)}
>
View Available Arguments
</Button>
</Group> </Group>
<TextInput <TextInput
placeholder="Additional command line arguments" placeholder="Additional command line arguments"
@ -218,6 +235,12 @@ export const AdvancedTab = () => {
} }
/> />
</div> </div>
<CommandLineArgumentsModal
opened={modals.commandLineArguments}
onClose={() => setModalOpen('commandLineArguments', false)}
onAddArgument={handleAddArgument}
/>
</Stack> </Stack>
); );
}; };

View file

@ -314,7 +314,7 @@ export const LaunchScreen = ({
onChange={setActiveTab} onChange={setActiveTab}
styles={{ styles={{
root: { root: {
maxHeight: '24rem', maxHeight: '22rem',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
}, },

View file

@ -1,4 +1,4 @@
import { useLaunchConfigStore } from '@/stores/launchConfigStore'; import { useLaunchConfigStore } from '@/stores/launchConfig';
import { import {
type ImageModelPreset, type ImageModelPreset,
IMAGE_MODEL_PRESETS, IMAGE_MODEL_PRESETS,

35
src/stores/modal.ts Normal file
View file

@ -0,0 +1,35 @@
import { create } from 'zustand';
interface ModalState {
modals: {
settings: boolean;
ejectConfirm: boolean;
updateAvailable: boolean;
commandLineArguments: boolean;
};
setModalOpen: (
modalName: keyof ModalState['modals'],
isOpen: boolean
) => void;
isAnyModalOpen: () => boolean;
}
export const useModalStore = create<ModalState>((set, get) => ({
modals: {
settings: false,
ejectConfirm: false,
updateAvailable: false,
commandLineArguments: false,
},
setModalOpen: (modalName, isOpen) =>
set((state) => ({
modals: {
...state.modals,
[modalName]: isOpen,
},
})),
isAnyModalOpen: () => {
const { modals } = get();
return Object.values(modals).some(Boolean);
},
}));

View file

@ -1,5 +1,23 @@
@import '@mantine/core/styles.css'; @import '@mantine/core/styles.css';
.mantine-AppShell-main {
overflow: auto !important;
height: calc(100vh - 2.5rem) !important;
position: relative !important;
top: 2.5rem !important;
margin-top: 0 !important;
padding-top: 1rem !important;
padding-bottom: 0 !important;
min-height: calc(100vh - 2.5rem) !important;
}
.mantine-AppShell-root {
--app-shell-header-offset: 2.5rem;
--app-shell-navbar-offset: 0rem;
--app-shell-aside-offset: 0rem;
--app-shell-footer-offset: 0rem;
}
/* TODO: something (mantine?) is setting this sometimes to "none" on the terminal tab */ /* TODO: something (mantine?) is setting this sometimes to "none" on the terminal tab */
body { body {
user-select: auto !important; user-select: auto !important;