Adding new "Pre-Launch Commands" to the "Advanced" launch tab to allow launching commands before the launch

This commit is contained in:
Egor 2025-11-27 16:58:50 -08:00
parent 7a118c9449
commit 6100a4d0f0
15 changed files with 245 additions and 80 deletions

View file

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

View file

@ -6,8 +6,10 @@ import {
NumberInput,
Button,
SimpleGrid,
ActionIcon,
} from '@mantine/core';
import { useState, useEffect } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { InfoTooltip } from '@/components/InfoTooltip';
import { CheckboxWithTooltip } from '@/components/CheckboxWithTooltip';
import { CommandLineArgumentsModal } from '@/components/screens/Launch/CommandLineArgumentsModal';
@ -16,6 +18,7 @@ import { useLaunchConfig } from '@/hooks/useLaunchConfig';
export const AdvancedTab = () => {
const {
additionalArguments,
preLaunchCommands,
noshift,
flashattention,
noavx2,
@ -28,6 +31,7 @@ export const AdvancedTab = () => {
moecpu,
moeexperts,
handleAdditionalArgumentsChange,
handlePreLaunchCommandsChange,
handleNoshiftChange,
handleFlashattentionChange,
handleNoavx2Change,
@ -208,7 +212,7 @@ export const AdvancedTab = () => {
<Group mb="xs" justify="space-between">
<Group>
<Text size="sm" fw={500}>
Additional arguments
Additional Arguments
</Text>
<InfoTooltip label="Additional command line arguments to pass to the binary. Leave this empty if you don't know what they are." />
</Group>
@ -229,6 +233,57 @@ export const AdvancedTab = () => {
/>
</div>
<div>
<Group mb="xs">
<Text size="sm" fw={500}>
Pre-Launch Commands
</Text>
<InfoTooltip label="Shell commands to run before launching. Useful for starting local services or custom APIs." />
</Group>
<Stack gap="xs">
{preLaunchCommands.map((command, index) => (
<Group key={index} gap="xs">
<TextInput
placeholder="Enter a shell command"
value={command}
onChange={(event) => {
const newCommands = [...preLaunchCommands];
newCommands[index] = event.currentTarget.value;
handlePreLaunchCommandsChange(newCommands);
}}
style={{ flex: 1 }}
/>
<ActionIcon
variant="subtle"
color="red"
disabled={preLaunchCommands.length === 1}
onClick={() => {
const newCommands = preLaunchCommands.filter(
(_, i) => i !== index
);
handlePreLaunchCommandsChange(
newCommands.length === 0 ? [''] : newCommands
);
}}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
))}
<Button
variant="subtle"
size="xs"
leftSection={<Plus size={14} />}
onClick={() => {
handlePreLaunchCommandsChange([...preLaunchCommands, '']);
}}
style={{ alignSelf: 'flex-start' }}
>
Add Command
</Button>
</Stack>
</div>
<CommandLineArgumentsModal
opened={commandLineModalOpen}
onClose={() => setCommandLineModalOpen(false)}

View file

@ -796,7 +796,7 @@ export const CommandLineArgumentsModal = ({
<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.
&quot;Additional Arguments&quot; input field.
</Text>
<TextInput

View file

@ -31,6 +31,7 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
contextSize,
model,
additionalArguments,
preLaunchCommands,
port,
host,
multiuser,
@ -159,6 +160,7 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
contextsize: contextSize,
model,
additionalArguments,
preLaunchCommands: preLaunchCommands.filter((cmd) => cmd.trim() !== ''),
port,
host,
multiuser: multiuser ? 1 : 0,
@ -301,6 +303,7 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
usemmap,
debugmode,
additionalArguments,
preLaunchCommands,
sdt5xxl,
sdclipl,
sdclipg,
@ -338,6 +341,7 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
usemmap,
debugmode,
additionalArguments,
preLaunchCommands,
sdt5xxl,
sdclipl,
sdclipg,

View file

@ -77,11 +77,9 @@ export const AppearanceTab = ({
return (
<Stack gap="lg" h="100%">
<div>
<FrontendInterfaceSelector isOnInterfaceScreen={isOnInterfaceScreen} />
</div>
<div>
<Text fw={500} mb="sm">
<Text fw={500} mb="xs">
Theme
</Text>
<Text size="sm" c="dimmed" mb="md">
@ -138,7 +136,7 @@ export const AppearanceTab = ({
/>
</div>
<div>
<Text fw={500} mb="sm">
<Text fw={500} mb="xs">
Zoom Level
</Text>
<Group justify="space-between" align="center" mb="md">

View file

@ -223,13 +223,15 @@ export const FrontendInterfaceSelector = ({
return (
<>
<Text fw={500} mb="sm">
<div>
<Text fw={500} mb="xs">
Frontend Interface
</Text>
{getUnmetRequirements().length === 0 && (
<Text size="sm" c="dimmed" mb="md">
Choose which frontend interface to use for interacting with AI models
Choose which frontend interface to use for interacting with AI
models
</Text>
)}
@ -267,8 +269,10 @@ export const FrontendInterfaceSelector = ({
/>
{renderDisabledFrontendWarnings()}
</div>
<Text fw={500} mb="sm" mt="xl">
<div>
<Text fw={500} mb="xs">
Image Generation Frontend
</Text>
@ -286,6 +290,7 @@ export const FrontendInterfaceSelector = ({
]}
leftSection={<Image style={{ width: rem(16), height: rem(16) }} />}
/>
</div>
</>
);
};

View file

@ -25,7 +25,7 @@ export const GeneralTab = () => {
return (
<Stack gap="lg" h="100%">
<div>
<Text fw={500} mb="sm">
<Text fw={500} mb="xs">
Status Bar
</Text>
<Text size="sm" c="dimmed" mb="md">
@ -41,7 +41,7 @@ export const GeneralTab = () => {
</div>
<div>
<Text fw={500} mb="sm">
<Text fw={500} mb="xs">
System Tray
</Text>
<Text size="sm" c="dimmed" mb="md">

View file

@ -33,7 +33,7 @@ export const TroubleshootingTab = () => {
return (
<Stack gap="lg" h="100%">
<div>
<Text fw={500} mb="sm">
<Text fw={500} mb="xs">
Installation Directory
</Text>
<Text size="sm" c="dimmed" mb="md">
@ -70,7 +70,7 @@ export const TroubleshootingTab = () => {
</div>
<div>
<Text fw={500} mb="sm">
<Text fw={500} mb="xs">
Diagnostics
</Text>
<Text size="sm" c="dimmed" mb="md">

View file

@ -13,6 +13,7 @@ export const useLaunchConfig = () => {
contextSize: state.contextSize,
model: state.model,
additionalArguments: state.additionalArguments,
preLaunchCommands: state.preLaunchCommands,
port: state.port,
host: state.host,
multiuser: state.multiuser,
@ -65,6 +66,7 @@ export const useLaunchConfig = () => {
handleQuantmatmulChange: state.setQuantmatmul,
handleUsemmapChange: state.setUsemmap,
handleDebugmodeChange: state.setDebugmode,
handlePreLaunchCommandsChange: state.setPreLaunchCommands,
handleBackendChange: state.setBackend,
handleGpuDeviceSelectionChange: state.setGpuDeviceSelection,
handleTensorSplitChange: state.setTensorSplit,

View file

@ -31,6 +31,7 @@ interface LaunchArgs {
usemmap: boolean;
debugmode: boolean;
additionalArguments: string;
preLaunchCommands: string[];
sdt5xxl: string;
sdclipl: string;
sdclipg: string;
@ -283,7 +284,14 @@ export const useLaunchLogic = ({
args.push(...additionalArgs);
}
const result = await window.electronAPI.kobold.launchKoboldCpp(args);
const preLaunchCommands = launchArgs.preLaunchCommands.filter(
(cmd) => cmd.trim() !== ''
);
const result = await window.electronAPI.kobold.launchKoboldCpp(
args,
preLaunchCommands
);
if (result.success) {
onLaunch();

View file

@ -135,8 +135,8 @@ export function setupIPCHandlers() {
ipcMain.handle('kobold:getPlatform', () => platform);
ipcMain.handle('kobold:launchKoboldCpp', (_, args) =>
launchKoboldCppWithCustomFrontends(args)
ipcMain.handle('kobold:launchKoboldCpp', (_, args, preLaunchCommands) =>
launchKoboldCppWithCustomFrontends(args, preLaunchCommands)
);
ipcMain.handle('kobold:deleteRelease', (_, binaryPath) =>

View file

@ -1,4 +1,5 @@
import { spawn, ChildProcess } from 'child_process';
import { platform } from 'process';
import { terminateProcess } from '@/utils/node/process';
import { logError, safeExecute } from '@/utils/node/logging';
@ -10,6 +11,7 @@ import { getCurrentVersion } from '../version';
import {
getCurrentKoboldBinary,
get as getConfig,
getInstallDir,
} from '@/main/modules/config';
import { startFrontend as startSillyTavernFrontend } from '@/main/modules/sillytavern';
import { startFrontend as startOpenWebUIFrontend } from '@/main/modules/openwebui';
@ -23,6 +25,70 @@ import type {
} from '@/types';
let koboldProcess: ChildProcess | null = null;
const preLaunchProcesses = new Set<ChildProcess>();
function spawnPreLaunchCommands(commands: string[]) {
const installDir = getInstallDir();
const shell = platform === 'win32' ? 'cmd' : '/bin/sh';
const shellFlag = platform === 'win32' ? '/c' : '-c';
for (const command of commands) {
if (!command.trim()) continue;
sendKoboldOutput(`[PRE-LAUNCH] Running: ${command}\n`);
try {
const child = spawn(shell, [shellFlag, command], {
cwd: installDir,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
preLaunchProcesses.add(child);
child.stdout?.on('data', (data) => {
sendKoboldOutput(`[PRE-LAUNCH] ${data.toString()}`, true);
});
child.stderr?.on('data', (data) => {
sendKoboldOutput(`[PRE-LAUNCH] ${data.toString()}`, true);
});
child.on('error', (error) => {
sendKoboldOutput(
`[PRE-LAUNCH ERROR] Failed to run "${command}": ${error.message}\n`
);
preLaunchProcesses.delete(child);
});
child.on('exit', (code, signal) => {
preLaunchProcesses.delete(child);
if (code !== 0 && code !== null) {
sendKoboldOutput(
`[PRE-LAUNCH] Command "${command}" exited with code ${code}\n`
);
} else if (signal) {
sendKoboldOutput(
`[PRE-LAUNCH] Command "${command}" terminated with signal ${signal}\n`
);
}
});
} catch (error) {
sendKoboldOutput(
`[PRE-LAUNCH ERROR] Failed to start "${command}": ${error instanceof Error ? error.message : String(error)}\n`
);
}
}
}
async function stopPreLaunchProcesses() {
const terminations = Array.from(preLaunchProcesses).map((process) =>
terminateProcess(process)
);
await Promise.all(terminations);
preLaunchProcesses.clear();
}
async function resolveModelPaths(args: string[]) {
const resolvedArgs: string[] = [];
@ -75,13 +141,18 @@ async function resolveModelPaths(args: string[]) {
export async function launchKoboldCpp(
args: string[] = [],
frontendPreference: FrontendPreference = 'koboldcpp',
imageGenerationFrontendPreference?: ImageGenerationFrontendPreference
imageGenerationFrontendPreference?: ImageGenerationFrontendPreference,
preLaunchCommands: string[] = []
) {
try {
if (koboldProcess) {
await stopKoboldCpp();
}
if (preLaunchCommands.length > 0) {
spawnPreLaunchCommands(preLaunchCommands);
}
const currentVersion = await getCurrentVersion();
if (!currentVersion || !(await pathExists(currentVersion.path))) {
const rawPath = getCurrentKoboldBinary();
@ -213,11 +284,15 @@ export async function launchKoboldCpp(
export async function stopKoboldCpp() {
abortActiveDownloads();
await stopProxy();
stopProxy();
stopPreLaunchProcesses();
return terminateProcess(koboldProcess);
}
export const launchKoboldCppWithCustomFrontends = async (args: string[] = []) =>
export const launchKoboldCppWithCustomFrontends = async (
args: string[] = [],
preLaunchCommands: string[] = []
) =>
safeExecute(async () => {
const [frontendPreference, imageGenerationFrontendPreference] =
(await Promise.all([
@ -233,7 +308,8 @@ export const launchKoboldCppWithCustomFrontends = async (args: string[] = []) =>
const result = await launchKoboldCpp(
args,
frontendPreference,
imageGenerationFrontendPreference
imageGenerationFrontendPreference,
preLaunchCommands
);
if (

View file

@ -38,7 +38,8 @@ const koboldAPI: KoboldAPI = {
ipcRenderer.invoke('kobold:downloadRelease', asset, options),
deleteRelease: (binaryPath) =>
ipcRenderer.invoke('kobold:deleteRelease', binaryPath),
launchKoboldCpp: (args) => ipcRenderer.invoke('kobold:launchKoboldCpp', args),
launchKoboldCpp: (args, preLaunchCommands) =>
ipcRenderer.invoke('kobold:launchKoboldCpp', args, preLaunchCommands),
getConfigFiles: () => ipcRenderer.invoke('kobold:getConfigFiles'),
saveConfigFile: (configName, configData) =>
ipcRenderer.invoke('kobold:saveConfigFile', configName, configData),

View file

@ -9,6 +9,7 @@ interface LaunchConfigState {
contextSize: number;
model: string;
additionalArguments: string;
preLaunchCommands: string[];
port?: number;
host: string;
multiuser: boolean;
@ -63,6 +64,7 @@ interface LaunchConfigState {
setQuantmatmul: (quantmatmul: boolean) => void;
setUsemmap: (usemmap: boolean) => void;
setDebugmode: (debugmode: boolean) => void;
setPreLaunchCommands: (commands: string[]) => void;
setBackend: (backend: string) => void;
setGpuDeviceSelection: (selection: string) => void;
setTensorSplit: (split: string) => void;
@ -103,6 +105,7 @@ export const useLaunchConfigStore = create<LaunchConfigState>((set, get) => ({
contextSize: DEFAULT_CONTEXT_SIZE,
model: '',
additionalArguments: '',
preLaunchCommands: [''],
port: undefined,
host: '',
multiuser: false,
@ -162,6 +165,7 @@ export const useLaunchConfigStore = create<LaunchConfigState>((set, get) => ({
setQuantmatmul: (quantmatmul) => set({ quantmatmul }),
setUsemmap: (usemmap) => set({ usemmap }),
setDebugmode: (debugmode) => set({ debugmode }),
setPreLaunchCommands: (commands) => set({ preLaunchCommands: commands }),
setBackend: (backend) =>
set({
backend,
@ -225,6 +229,16 @@ export const useLaunchConfigStore = create<LaunchConfigState>((set, get) => ({
updates.additionalArguments = '';
}
if (Array.isArray(configData.preLaunchCommands)) {
const filteredCommands = configData.preLaunchCommands.filter(
(cmd): cmd is string => typeof cmd === 'string'
);
updates.preLaunchCommands =
filteredCommands.length === 0 ? [''] : filteredCommands;
} else {
updates.preLaunchCommands = [''];
}
if (typeof configData.port === 'number') {
updates.port = configData.port;
} else {

View file

@ -116,6 +116,7 @@ export interface KoboldConfig {
sdvaecpu?: boolean;
sdclipgpu?: boolean;
additionalArguments?: string;
preLaunchCommands?: string[];
moecpu?: number;
moeexperts?: number;
autoGpuLayers?: boolean;
@ -146,7 +147,8 @@ export interface KoboldAPI {
binaryPath: string
) => Promise<{ success: boolean; error?: string }>;
launchKoboldCpp: (
args?: string[]
args?: string[],
preLaunchCommands?: string[]
) => Promise<{ success: boolean; pid?: number; error?: string }>;
getConfigFiles: () => Promise<{ name: string; path: string; size: number }[]>;
saveConfigFile: (