image generation implementation, standardized tooltips, cache all hardware detection data, better folder structure

This commit is contained in:
Egor 2025-08-15 23:22:56 -07:00
parent 21b6f87a53
commit 09f69c7c26
31 changed files with 1466 additions and 512 deletions

View file

@ -1,14 +1,5 @@
# Copilot Instructions for FriendlyKobold
## Code Style Preferences
### Comments
- Minimize comments in code - only add them when the code logic is genuinely confusing or complex
- Remove obvious/redundant comments that just describe what the code does
- Prefer self-documenting code with clear variable and function names
- Focus on "why" not "what" when comments are necessary
### General Coding
- Follow existing TypeScript/React patterns in the codebase
@ -18,3 +9,7 @@
- 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
### Scripting
- when debugging: try to run script commands that will work for both bash and fish shells

View file

@ -8,7 +8,7 @@ A koboldcpp manager.
- download and keep up-to-date your [koboldcpp](https://github.com/LostRuins/koboldcpp/releases) binary
- better surface the ROCm-specific builds of koboldcpp from YellowRoseCx and from [koboldai.org](https://koboldai.org/cpplinuxrocm)
- manage the koboldcpp binary to prevent it from running in the background indefinitely
- automatically unpack all downloaded koboldcpp binaries for significantly faster operations
- automatically unpack all downloaded koboldcpp binaries for significantly faster operation and reduced RAM+HDD utilization (up to ~4GB less RAM usage for ROCm)
### Prerequisites
@ -34,6 +34,10 @@ A koboldcpp manager.
Additional configurations have been written to help with ideal Wayland support, but as per current Electron guidelines, the user should set `ELECTRON_OZONE_PLATFORM_HINT` to `wayland` in their environment variable according to the [Electron Environment Variables documentation](https://www.electronjs.org/docs/latest/api/environment-variables#electron_ozone_platform_hint-linux).
### Future features
Not all koboldcpp features have currently been ported over the UI. As a workaround one may use the "Additional arguments" on the "Advanced" tab of the launcher to provide additional command line arguments if you know them.
### Future considerations
It would make a lot of sense to transition this project to Tauri from Electron. The app size should drop from ~80MB to ~10MB; however, users on obsolete OSes (with outdated WebViews) will very likely encounter issues. In addition, I would need to learn Rust to rewrite the BE (Electron main code), but at least we can re-use all the React code. The app would be much smaller, faster and memory efficient, but not work for some users. I think it's a worthy tradeoff.

View file

@ -106,6 +106,13 @@
"kobold",
"KOBOLDAI",
"koboldcpp",
"sdmodel",
"sdt5xxl",
"sdclipl",
"sdclipg",
"sdphotomaker",
"sdvae",
"sdapi",
"less",
"letterspacing",
"libvk",
@ -183,6 +190,8 @@
"subdomain",
"svg",
"swiftshader",
"safetensors",
"SDXL",
"tabler",
"Tauri",
"temp",

11
package-lock.json generated
View file

@ -11,7 +11,6 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@mantine/core": "^8.2.4",
"jiti": "^2.5.1",
"lucide-react": "^0.539.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
@ -20,7 +19,7 @@
"devDependencies": {
"@cspell/eslint-plugin": "^9.2.0",
"@eslint/js": "^9.33.0",
"@types/node": "^24.2.1",
"@types/node": "^24.3.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@types/systeminformation": "^3.54.1",
@ -41,6 +40,7 @@
"eslint-plugin-sonarjs": "^3.0.4",
"globals": "^16.3.0",
"husky": "^9.1.7",
"jiti": "^2.5.1",
"lint-staged": "^16.1.5",
"prettier": "^3.6.2",
"rollup-plugin-visualizer": "^6.0.3",
@ -3227,9 +3227,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.2.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
"integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
"version": "24.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
"integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -8393,6 +8393,7 @@
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
"integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"

View file

@ -48,7 +48,7 @@
"devDependencies": {
"@cspell/eslint-plugin": "^9.2.0",
"@eslint/js": "^9.33.0",
"@types/node": "^24.2.1",
"@types/node": "^24.3.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@types/systeminformation": "^3.54.1",
@ -69,6 +69,7 @@
"eslint-plugin-sonarjs": "^3.0.4",
"globals": "^16.3.0",
"husky": "^9.1.7",
"jiti": "^2.5.1",
"lint-staged": "^16.1.5",
"prettier": "^3.6.2",
"rollup-plugin-visualizer": "^6.0.3",
@ -79,7 +80,6 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@mantine/core": "^8.2.4",
"jiti": "^2.5.1",
"lucide-react": "^0.539.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",

View file

@ -3,7 +3,6 @@ import {
AppShell,
Group,
ActionIcon,
Tooltip,
rem,
Loader,
Center,
@ -15,12 +14,13 @@ import {
useMantineColorScheme,
} from '@mantine/core';
import { Settings, ArrowLeft } from 'lucide-react';
import { DownloadScreen } from '@/components/screens/DownloadScreen';
import { LaunchScreen } from '@/components/screens/LaunchScreen';
import { InterfaceScreen } from '@/components/screens/InterfaceScreen';
import { DownloadScreen } from '@/screens/Download';
import { LaunchScreen } from '@/screens/Launch';
import { InterfaceScreen } from '@/screens/Interface';
import { UpdateDialog } from '@/components/UpdateDialog';
import { SettingsModal } from '@/components/SettingsModal';
import { SettingsModal } from '@/components/settings/SettingsModal';
import { ScreenTransition } from '@/components/ScreenTransition';
import { StyledTooltip } from '@/components/StyledTooltip';
import type { UpdateInfo, InstalledVersion } from '@/types';
type Screen = 'download' | 'launch' | 'interface';
@ -34,6 +34,8 @@ export const App = () => {
const [activeInterfaceTab, setActiveInterfaceTab] = useState<string | null>(
'terminal'
);
const [isImageGenerationMode, setIsImageGenerationMode] =
useState<boolean>(false);
const [currentVersion, setCurrentVersion] = useState<InstalledVersion | null>(
null
);
@ -316,7 +318,7 @@ export const App = () => {
justifyContent: 'flex-end',
}}
>
<Tooltip label="Settings" position="bottom">
<StyledTooltip label="Settings" position="bottom">
<ActionIcon
variant="subtle"
color="gray"
@ -329,7 +331,7 @@ export const App = () => {
>
<Settings style={{ width: rem(20), height: rem(20) }} />
</ActionIcon>
</Tooltip>
</StyledTooltip>
</div>
</Group>
</AppShell.Header>
@ -362,7 +364,10 @@ export const App = () => {
isActive={currentScreen === 'launch'}
shouldAnimate={hasInitialized}
>
<LaunchScreen onLaunch={handleLaunch} />
<LaunchScreen
onLaunch={handleLaunch}
onLaunchModeChange={setIsImageGenerationMode}
/>
</ScreenTransition>
<ScreenTransition
@ -372,6 +377,7 @@ export const App = () => {
<InterfaceScreen
activeTab={activeInterfaceTab}
onTabChange={setActiveInterfaceTab}
isImageGenerationMode={isImageGenerationMode}
/>
</ScreenTransition>
</>

View file

@ -1,5 +1,6 @@
import { ActionIcon, Tooltip, useMantineColorScheme } from '@mantine/core';
import { ActionIcon } from '@mantine/core';
import { Info } from 'lucide-react';
import { StyledTooltip } from './StyledTooltip';
interface InfoTooltipProps {
label: string;
@ -11,34 +12,10 @@ export const InfoTooltip = ({
label,
multiline = true,
width = 300,
}: InfoTooltipProps) => {
const { colorScheme } = useMantineColorScheme();
const isDark = colorScheme === 'dark';
return (
<Tooltip
label={label}
multiline={multiline}
w={width}
withArrow
color={isDark ? 'dark' : 'gray'}
styles={{
tooltip: {
backgroundColor: isDark
? 'var(--mantine-color-dark-6)'
: 'var(--mantine-color-gray-1)',
color: isDark
? 'var(--mantine-color-gray-0)'
: 'var(--mantine-color-dark-7)',
border: isDark
? '1px solid var(--mantine-color-dark-4)'
: '1px solid var(--mantine-color-gray-3)',
},
}}
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
);
};
}: InfoTooltipProps) => (
<StyledTooltip label={label} multiline={multiline} w={width}>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</StyledTooltip>
);

View file

@ -0,0 +1,35 @@
import type { ReactNode } from 'react';
import { Tooltip, useMantineColorScheme } from '@mantine/core';
import type { TooltipProps } from '@mantine/core';
interface StyledTooltipProps extends Omit<TooltipProps, 'styles' | 'color'> {
children: ReactNode;
}
export const StyledTooltip = ({ children, ...props }: StyledTooltipProps) => {
const { colorScheme } = useMantineColorScheme();
const isDark = colorScheme === 'dark';
return (
<Tooltip
withArrow
color={isDark ? 'dark' : 'gray'}
styles={{
tooltip: {
backgroundColor: isDark
? 'var(--mantine-color-dark-6)'
: 'var(--mantine-color-gray-1)',
color: isDark
? 'var(--mantine-color-gray-0)'
: 'var(--mantine-color-dark-7)',
border: isDark
? '1px solid var(--mantine-color-dark-4)'
: '1px solid var(--mantine-color-gray-3)',
},
}}
{...props}
>
{children}
</Tooltip>
);
};

View file

@ -1,9 +1,9 @@
import { useState, useEffect } from 'react';
import { Modal, Tabs, Text, Group, rem } from '@mantine/core';
import { Settings, Palette, SlidersHorizontal, GitBranch } from 'lucide-react';
import { GeneralTab } from './settings/GeneralTab';
import { VersionsTab } from './settings/VersionsTab';
import { AppearanceTab } from './settings/AppearanceTab';
import { GeneralTab } from './GeneralTab';
import { VersionsTab } from './VersionsTab';
import { AppearanceTab } from './AppearanceTab';
interface SettingsModalProps {
opened: boolean;
@ -28,6 +28,8 @@ export const SettingsModal = ({
useEffect(() => {
if (opened) {
setActiveTab('general');
const originalOverflow = document.body.style.overflow;
const scrollbarWidth =
window.innerWidth - document.documentElement.clientWidth;

View file

@ -11,13 +11,13 @@ import {
} from '@mantine/core';
import { RotateCcw } from 'lucide-react';
import { DownloadCard } from '@/components/DownloadCard';
import { isAssetCompatibleWithPlatform } from '@/utils/platform';
import { getAssetDescription } from '@/utils/assets';
import type {
InstalledVersion,
GitHubAsset,
GitHubRelease,
} from '@/types/electron';
import {
getAssetDescription,
sortAssetsByRecommendation,
isAssetRecommended,
} from '@/utils/assets';
import { useKoboldVersions } from '@/hooks/useKoboldVersions';
import type { InstalledVersion } from '@/types/electron';
interface VersionInfo {
name: string;
@ -31,30 +31,26 @@ interface VersionInfo {
}
export const VersionsTab = () => {
const {
platformInfo,
latestRelease,
filteredAssets,
rocmDownload,
loadingPlatform,
loadingRemote,
downloading,
downloadProgress,
loadRemoteVersions,
handleDownload: sharedHandleDownload,
} = useKoboldVersions();
const [installedVersions, setInstalledVersions] = useState<
InstalledVersion[]
>([]);
const [currentVersion, setCurrentVersion] = useState<InstalledVersion | null>(
null
);
const [availableAssets, setAvailableAssets] = useState<GitHubAsset[]>([]);
const [rocmDownload, setRocmDownload] = useState<{
name: string;
url: string;
size: number;
version?: string;
} | null>(null);
const [latestRelease, setLatestRelease] = useState<GitHubRelease | null>(
null
);
const [userPlatform, setUserPlatform] = useState<string>('');
const [loadingInstalled, setLoadingInstalled] = useState(true);
const [loadingRemote, setLoadingRemote] = useState(true);
const [downloading, setDownloading] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState<{
[key: string]: number;
}>({});
const loadInstalledVersions = useCallback(async () => {
setLoadingInstalled(true);
@ -97,68 +93,9 @@ export const VersionsTab = () => {
}
}, []);
const loadRemoteVersions = useCallback(async () => {
if (!userPlatform) return;
setLoadingRemote(true);
try {
const [release, rocm] = await Promise.all([
window.electronAPI.kobold.getLatestRelease(),
window.electronAPI.kobold.getROCmDownload(),
]);
if (release) {
setLatestRelease(release);
const compatibleAssets = release.assets.filter((asset) =>
isAssetCompatibleWithPlatform(asset.name, userPlatform)
);
setAvailableAssets(compatibleAssets);
}
setRocmDownload(rocm);
} catch (error) {
console.error('Failed to load remote versions:', error);
} finally {
setLoadingRemote(false);
}
}, [userPlatform]);
useEffect(() => {
const loadPlatform = async () => {
try {
const platform = await window.electronAPI.kobold.getPlatform();
setUserPlatform(platform.platform);
} catch (error) {
console.error('Failed to load platform:', error);
}
};
loadPlatform();
}, []);
useEffect(() => {
loadInstalledVersions();
if (userPlatform) {
loadRemoteVersions();
}
}, [userPlatform, loadInstalledVersions, loadRemoteVersions]);
useEffect(() => {
const handleProgress = (progress: number) => {
if (downloading) {
setDownloadProgress((prev) => ({
...prev,
[downloading]: progress,
}));
}
};
window.electronAPI.kobold.onDownloadProgress?.(handleProgress);
return () => {
window.electronAPI.kobold.removeAllListeners?.('download-progress');
};
}, [downloading]);
}, [loadInstalledVersions]);
const getDisplayNameFromPath = (
installedVersion: InstalledVersion
@ -181,7 +118,7 @@ export const VersionsTab = () => {
const getAllVersions = (): VersionInfo[] => {
const versions: VersionInfo[] = [];
availableAssets.forEach((asset) => {
filteredAssets.forEach((asset) => {
const installedVersion = installedVersions.find((v) => {
const displayName = getDisplayNameFromPath(v);
return displayName === asset.name;
@ -260,38 +197,28 @@ export const VersionsTab = () => {
}
});
return versions;
return sortAssetsByRecommendation(versions, platformInfo.hasAMDGPU);
};
const handleDownload = async (version: VersionInfo) => {
setDownloading(version.name);
setDownloadProgress((prev) => ({ ...prev, [version.name]: 0 }));
try {
let result;
let success;
if (version.isROCm) {
result = await window.electronAPI.kobold.downloadROCm();
success = await sharedHandleDownload('rocm');
} else {
const asset = availableAssets.find((a) => a.name === version.name);
const asset = filteredAssets.find((a) => a.name === version.name);
if (!asset) {
throw new Error('Asset not found');
}
result = await window.electronAPI.kobold.downloadRelease(asset);
success = await sharedHandleDownload('asset', asset);
}
if (result.success) {
if (success) {
await loadInstalledVersions();
}
} catch (error) {
console.error('Failed to download:', error);
} finally {
setDownloading(null);
setDownloadProgress((prev) => {
const newProgress = { ...prev };
delete newProgress[version.name];
return newProgress;
});
}
};
@ -321,7 +248,7 @@ export const VersionsTab = () => {
}
};
const isLoading = loadingInstalled || loadingRemote;
const isLoading = loadingInstalled || loadingPlatform || loadingRemote;
if (isLoading) {
return (
@ -329,7 +256,7 @@ export const VersionsTab = () => {
<Stack align="center" gap="md">
<Loader size="lg" />
<Text c="dimmed">
{loadingInstalled && loadingRemote
{loadingInstalled && (loadingPlatform || loadingRemote)
? 'Loading versions...'
: loadingInstalled
? 'Scanning installed versions...'
@ -374,6 +301,10 @@ export const VersionsTab = () => {
}
version={version.version}
description={getAssetDescription(version.name)}
isRecommended={isAssetRecommended(
version.name,
platformInfo.hasAMDGPU
)}
isCurrent={version.isCurrent}
isInstalled={version.isInstalled}
isDownloading={isDownloading}

View file

@ -0,0 +1,195 @@
import { useState, useEffect, useCallback } from 'react';
import { filterAssetsByPlatform } from '@/utils/platform';
import type { GitHubAsset, GitHubRelease } from '@/types';
interface PlatformInfo {
platform: string;
hasAMDGPU: boolean;
hasROCm: boolean;
}
interface ROCmDownload {
name: string;
url: string;
size: number;
version?: string;
}
interface UseKoboldVersionsReturn {
platformInfo: PlatformInfo;
latestRelease: GitHubRelease | null;
filteredAssets: GitHubAsset[];
rocmDownload: ROCmDownload | null;
loadingPlatform: boolean;
loadingRemote: boolean;
downloading: string | null;
downloadProgress: Record<string, number>;
loadRemoteVersions: () => Promise<void>;
handleDownload: (
type: 'asset' | 'rocm',
asset?: GitHubAsset
) => Promise<boolean>;
setDownloading: (value: string | null) => void;
setDownloadProgress: (
value:
| Record<string, number>
| ((prev: Record<string, number>) => Record<string, number>)
) => void;
}
export const useKoboldVersions = (): UseKoboldVersionsReturn => {
const [platformInfo, setPlatformInfo] = useState<PlatformInfo>({
platform: '',
hasAMDGPU: false,
hasROCm: false,
});
const [latestRelease, setLatestRelease] = useState<GitHubRelease | null>(
null
);
const [filteredAssets, setFilteredAssets] = useState<GitHubAsset[]>([]);
const [rocmDownload, setRocmDownload] = useState<ROCmDownload | null>(null);
const [loadingPlatform, setLoadingPlatform] = useState(true);
const [loadingRemote, setLoadingRemote] = useState(true);
const [downloading, setDownloading] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState<
Record<string, number>
>({});
const loadPlatformInfo = useCallback(async () => {
setLoadingPlatform(true);
try {
const platform = await window.electronAPI.kobold.getPlatform();
let hasAMDGPU = false;
let hasROCm = false;
try {
const gpuInfo = await window.electronAPI.kobold.detectGPU();
hasAMDGPU = gpuInfo.hasAMD;
if (gpuInfo.hasAMD) {
const rocmInfo = await window.electronAPI.kobold.detectROCm();
hasROCm = rocmInfo.supported;
}
} catch (gpuError) {
console.warn('GPU detection failed:', gpuError);
}
setPlatformInfo({
platform: platform.platform,
hasAMDGPU,
hasROCm,
});
} catch (error) {
console.error('Failed to load platform info:', error);
} finally {
setLoadingPlatform(false);
}
}, []);
const loadRemoteVersions = useCallback(async () => {
if (!platformInfo.platform) return;
setLoadingRemote(true);
try {
const [release, rocm] = await Promise.all([
window.electronAPI.kobold.getLatestRelease(),
window.electronAPI.kobold.getROCmDownload(),
]);
setLatestRelease(release);
setRocmDownload(rocm);
if (release) {
const filtered = filterAssetsByPlatform(
release.assets,
platformInfo.platform
);
setFilteredAssets(filtered);
}
} catch (error) {
console.error('Failed to load remote versions:', error);
} finally {
setLoadingRemote(false);
}
}, [platformInfo.platform]);
const handleDownload = useCallback(
async (type: 'asset' | 'rocm', asset?: GitHubAsset): Promise<boolean> => {
if (type === 'asset' && !asset) return false;
const downloadName =
type === 'asset' ? asset!.name : rocmDownload?.name || 'rocm';
setDownloading(downloadName);
setDownloadProgress((prev) => ({ ...prev, [downloadName]: 0 }));
try {
const result =
type === 'rocm'
? await window.electronAPI.kobold.downloadROCm()
: await window.electronAPI.kobold.downloadRelease(asset!);
return result.success !== false;
} catch (error) {
console.error(`Failed to download ${type}:`, error);
return false;
} finally {
setDownloading(null);
setDownloadProgress((prev) => {
const newProgress = { ...prev };
delete newProgress[downloadName];
return newProgress;
});
}
},
[rocmDownload?.name]
);
useEffect(() => {
loadPlatformInfo();
}, [loadPlatformInfo]);
useEffect(() => {
if (platformInfo.platform) {
loadRemoteVersions();
}
}, [platformInfo.platform, loadRemoteVersions]);
useEffect(() => {
const handleProgress = (progress: number) => {
if (downloading) {
setDownloadProgress((prev) => ({
...prev,
[downloading]: progress,
}));
}
};
window.electronAPI.kobold.onDownloadProgress?.(handleProgress);
return () => {
window.electronAPI.kobold.removeAllListeners?.('download-progress');
};
}, [downloading]);
return {
platformInfo,
latestRelease,
filteredAssets,
rocmDownload,
loadingPlatform,
loadingRemote,
downloading,
downloadProgress,
loadRemoteVersions,
handleDownload,
setDownloading,
setDownloadProgress,
};
};

View file

@ -1,5 +1,9 @@
import { useState, useCallback } from 'react';
import type { ConfigFile } from '@/types';
import {
getPresetByName,
type ImageModelPreset,
} from '@/utils/imageModelPresets';
export const useLaunchConfig = () => {
const [serverOnly, setServerOnly] = useState<boolean>(false);
@ -21,10 +25,24 @@ export const useLaunchConfig = () => {
const [failsafe, setFailsafe] = useState<boolean>(false);
const [backend, setBackend] = useState<string>('cpu');
const [sdmodel, setSdmodel] = useState<string>('');
const [sdt5xxl, setSdt5xxl] = useState<string>(
'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/t5xxl_fp8_e4m3fn.safetensors?download=true'
);
const [sdclipl, setSdclipl] = useState<string>(
'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/clip_l.safetensors?download=true'
);
const [sdclipg, setSdclipg] = useState<string>('');
const [sdphotomaker, setSdphotomaker] = useState<string>('');
const [sdvae, setSdvae] = useState<string>(
'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/ae.safetensors?download=true'
);
// eslint-disable-next-line sonarjs/cognitive-complexity
const parseAndApplyConfigFile = useCallback(async (configPath: string) => {
const configData =
await window.electronAPI.kobold.parseConfigFile(configPath);
if (configData) {
if (typeof configData.gpulayers === 'number') {
setGpuLayers(configData.gpulayers);
@ -124,7 +142,32 @@ export const useLaunchConfig = () => {
} else {
setBackend('cpu');
}
if (typeof configData.sdmodel === 'string') {
setSdmodel(configData.sdmodel);
}
if (typeof configData.sdt5xxl === 'string') {
setSdt5xxl(configData.sdt5xxl);
}
if (typeof configData.sdclipl === 'string') {
setSdclipl(configData.sdclipl);
}
if (typeof configData.sdclipg === 'string') {
setSdclipg(configData.sdclipg);
}
if (typeof configData.sdphotomaker === 'string') {
setSdphotomaker(configData.sdphotomaker);
}
if (typeof configData.sdvae === 'string') {
setSdvae(configData.sdvae);
}
} else {
const cpuCapabilities = await window.electronAPI.kobold.detectCPU();
setGpuLayers(0);
setContextSize(2048);
setPort(5001);
@ -136,14 +179,30 @@ export const useLaunchConfig = () => {
setWebsearch(false);
setNoshift(false);
setFlashattention(false);
setNoavx2(false);
setFailsafe(false);
setNoavx2(!cpuCapabilities.avx2);
setFailsafe(!cpuCapabilities.avx && !cpuCapabilities.avx2);
setBackend('cpu');
setSdmodel('');
setSdt5xxl(
'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/t5xxl_fp8_e4m3fn.safetensors?download=true'
);
setSdclipl(
'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/clip_l.safetensors?download=true'
);
setSdclipg('');
setSdphotomaker('');
setSdvae(
'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/ae.safetensors?download=true'
);
}
}, []);
const loadSavedSettings = useCallback(async () => {
const savedServerOnly = await window.electronAPI.config.getServerOnly();
const [savedServerOnly, cpuCapabilities] = await Promise.all([
window.electronAPI.config.getServerOnly(),
window.electronAPI.kobold.detectCPU(),
]);
setServerOnly(savedServerOnly);
setModelPath('');
@ -158,9 +217,22 @@ export const useLaunchConfig = () => {
setWebsearch(false);
setNoshift(false);
setFlashattention(false);
setNoavx2(false);
setFailsafe(false);
setNoavx2(!cpuCapabilities.avx2);
setFailsafe(!cpuCapabilities.avx && !cpuCapabilities.avx2);
setBackend('cpu');
setSdmodel('');
setSdt5xxl(
'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/t5xxl_fp8_e4m3fn.safetensors?download=true'
);
setSdclipl(
'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/clip_l.safetensors?download=true'
);
setSdclipg('');
setSdphotomaker('');
setSdvae(
'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/ae.safetensors?download=true'
);
}, []);
const loadConfigFromFile = useCallback(
@ -278,6 +350,91 @@ export const useLaunchConfig = () => {
setBackend(backend);
}, []);
const handleSdmodelChange = useCallback((path: string) => {
setSdmodel(path);
}, []);
const handleSelectSdmodelFile = useCallback(async () => {
const filePath = await window.electronAPI.kobold.selectModelFile();
if (filePath) {
setSdmodel(filePath);
}
}, []);
const handleSdt5xxlChange = useCallback((path: string) => {
setSdt5xxl(path);
}, []);
const handleSelectSdt5xxlFile = useCallback(async () => {
const filePath = await window.electronAPI.kobold.selectModelFile();
if (filePath) {
setSdt5xxl(filePath);
}
}, []);
const handleSdcliplChange = useCallback((path: string) => {
setSdclipl(path);
}, []);
const handleSelectSdcliplFile = useCallback(async () => {
const filePath = await window.electronAPI.kobold.selectModelFile();
if (filePath) {
setSdclipl(filePath);
}
}, []);
const handleSdclipgChange = useCallback((path: string) => {
setSdclipg(path);
}, []);
const handleSelectSdclipgFile = useCallback(async () => {
const filePath = await window.electronAPI.kobold.selectModelFile();
if (filePath) {
setSdclipg(filePath);
}
}, []);
const handleSdphotomakerChange = useCallback((path: string) => {
setSdphotomaker(path);
}, []);
const handleSelectSdphotomakerFile = useCallback(async () => {
const filePath = await window.electronAPI.kobold.selectModelFile();
if (filePath) {
setSdphotomaker(filePath);
}
}, []);
const handleSdvaeChange = useCallback((path: string) => {
setSdvae(path);
}, []);
const handleSelectSdvaeFile = useCallback(async () => {
const filePath = await window.electronAPI.kobold.selectModelFile();
if (filePath) {
setSdvae(filePath);
}
}, []);
const applyImageModelPreset = useCallback((preset: ImageModelPreset) => {
setSdmodel(preset.sdmodel);
setSdt5xxl(preset.sdt5xxl);
setSdclipl(preset.sdclipl);
setSdclipg(preset.sdclipg);
setSdphotomaker(preset.sdphotomaker);
setSdvae(preset.sdvae);
}, []);
const handleApplyPreset = useCallback(
(presetName: string) => {
const preset = getPresetByName(presetName);
if (preset) {
applyImageModelPreset(preset);
}
},
[applyImageModelPreset]
);
return {
serverOnly,
gpuLayers,
@ -297,6 +454,12 @@ export const useLaunchConfig = () => {
noavx2,
failsafe,
backend,
sdmodel,
sdt5xxl,
sdclipl,
sdclipg,
sdphotomaker,
sdvae,
parseAndApplyConfigFile,
loadSavedSettings,
@ -320,5 +483,19 @@ export const useLaunchConfig = () => {
handleNoavx2Change,
handleFailsafeChange,
handleBackendChange,
handleSdmodelChange,
handleSelectSdmodelFile,
handleSdt5xxlChange,
handleSelectSdt5xxlFile,
handleSdcliplChange,
handleSelectSdcliplFile,
handleSdclipgChange,
handleSelectSdclipgFile,
handleSdphotomakerChange,
handleSelectSdphotomakerFile,
handleSdvaeChange,
handleSelectSdvaeFile,
applyImageModelPreset,
handleApplyPreset,
};
};

View file

@ -7,6 +7,7 @@ import {
createWriteStream,
chmodSync,
readFileSync,
writeFileSync,
unlinkSync,
} from 'fs';
import { dialog } from 'electron';
@ -334,6 +335,52 @@ export class KoboldCppManager {
}
}
async saveConfigFile(
configName: string,
configData: {
gpulayers?: number;
contextsize?: number;
model_param?: string;
port?: number;
host?: string;
multiuser?: number;
multiplayer?: boolean;
remotetunnel?: boolean;
nocertify?: boolean;
websearch?: boolean;
noshift?: boolean;
flashattention?: boolean;
noavx2?: boolean;
failsafe?: boolean;
usecuda?: boolean;
usevulkan?: boolean;
useclblast?: boolean;
sdmodel?: string;
sdt5xxl?: string;
sdclipl?: string;
sdclipg?: string;
sdphotomaker?: string;
sdvae?: string;
[key: string]: unknown;
}
): Promise<boolean> {
try {
if (!this.installDir) {
console.error('No install directory found');
return false;
}
const configFileName = `${configName}.json`;
const configPath = join(this.installDir, configFileName);
writeFileSync(configPath, JSON.stringify(configData, null, 2), 'utf-8');
return true;
} catch (error) {
console.error('Error saving config file:', error);
return false;
}
}
async selectModelFile(): Promise<string | null> {
try {
const mainWindow = this.windowManager.getMainWindow();

View file

@ -1,3 +1,4 @@
/* eslint-disable no-comments/disallowComments */
import si from 'systeminformation';
import type {
CPUCapabilities,
@ -7,7 +8,15 @@ import type {
} from '@/types/hardware';
export class HardwareService {
private cpuCapabilitiesCache: CPUCapabilities | null = null;
private basicGPUInfoCache: BasicGPUInfo | null = null;
private gpuCapabilitiesCache: GPUCapabilities | null = null;
async detectCPU(): Promise<CPUCapabilities> {
if (this.cpuCapabilitiesCache) {
return this.cpuCapabilitiesCache;
}
try {
const [cpu, flags] = await Promise.all([si.cpu(), si.cpuFlags()]);
@ -25,22 +34,30 @@ export class HardwareService {
const avx = flags.includes('avx') || flags.includes('AVX');
const avx2 = flags.includes('avx2') || flags.includes('AVX2');
return {
this.cpuCapabilitiesCache = {
avx,
avx2,
cpuInfo: cpuInfo.length > 0 ? cpuInfo : ['CPU information unavailable'],
};
return this.cpuCapabilitiesCache;
} catch (error) {
console.warn('CPU detection failed:', error);
return {
const fallbackCapabilities = {
avx: false,
avx2: false,
cpuInfo: ['CPU detection failed'],
};
this.cpuCapabilitiesCache = fallbackCapabilities;
return fallbackCapabilities;
}
}
async detectGPU(): Promise<BasicGPUInfo> {
if (this.basicGPUInfoCache) {
return this.basicGPUInfoCache;
}
try {
const graphics = await si.graphics();
@ -76,23 +93,33 @@ export class HardwareService {
}
}
return {
this.basicGPUInfoCache = {
hasAMD,
hasNVIDIA,
gpuInfo:
gpuInfo.length > 0 ? gpuInfo : ['No GPU information available'],
};
return this.basicGPUInfoCache;
} catch (error) {
console.warn('GPU detection failed:', error);
return {
const fallbackGPUInfo = {
hasAMD: false,
hasNVIDIA: false,
gpuInfo: ['GPU detection failed'],
};
this.basicGPUInfoCache = fallbackGPUInfo;
return fallbackGPUInfo;
}
}
async detectGPUCapabilities(): Promise<GPUCapabilities> {
// WARNING: we're not worrying about the users that update their system
// during runtime and not restart. Should we be though?
if (this.gpuCapabilitiesCache) {
return this.gpuCapabilitiesCache;
}
const [cuda, rocm, vulkan, clblast] = await Promise.all([
this.detectCUDA(),
this.detectROCm(),
@ -100,7 +127,8 @@ export class HardwareService {
this.detectCLBlast(),
]);
return { cuda, rocm, vulkan, clblast };
this.gpuCapabilitiesCache = { cuda, rocm, vulkan, clblast };
return this.gpuCapabilitiesCache;
}
async detectAll(): Promise<HardwareInfo> {

View file

@ -78,6 +78,12 @@ export class IPCHandlers {
this.koboldManager.getConfigFiles()
);
ipcMain.handle(
'kobold:saveConfigFile',
async (_event, configName, configData) =>
this.koboldManager.saveConfigFile(configName, configData)
);
ipcMain.handle('kobold:getSelectedConfig', () =>
this.configManager.getSelectedConfig()
);

View file

@ -40,6 +40,35 @@ const koboldAPI: KoboldAPI = {
openInstallDialog: () => ipcRenderer.invoke('kobold:openInstallDialog'),
checkForUpdates: () => ipcRenderer.invoke('kobold:checkForUpdates'),
getConfigFiles: () => ipcRenderer.invoke('kobold:getConfigFiles'),
saveConfigFile: (
configName: string,
configData: {
gpulayers?: number;
contextsize?: number;
model_param?: string;
port?: number;
host?: string;
multiuser?: number;
multiplayer?: boolean;
remotetunnel?: boolean;
nocertify?: boolean;
websearch?: boolean;
noshift?: boolean;
flashattention?: boolean;
noavx2?: boolean;
failsafe?: boolean;
usecuda?: boolean;
usevulkan?: boolean;
useclblast?: boolean;
sdmodel?: string;
sdt5xxl?: string;
sdclipl?: string;
sdclipg?: string;
sdphotomaker?: string;
sdvae?: string;
[key: string]: unknown;
}
) => ipcRenderer.invoke('kobold:saveConfigFile', configName, configData),
getSelectedConfig: () => ipcRenderer.invoke('kobold:getSelectedConfig'),
setSelectedConfig: (configName: string) =>
ipcRenderer.invoke('kobold:setSelectedConfig', configName),

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useCallback } from 'react';
import {
Card,
Text,
@ -7,13 +7,10 @@ import {
Stack,
Container,
Badge,
Tooltip,
} from '@mantine/core';
import { DownloadCard } from '@/components/DownloadCard';
import {
getPlatformDisplayName,
filterAssetsByPlatform,
} from '@/utils/platform';
import { StyledTooltip } from '@/components/StyledTooltip';
import { getPlatformDisplayName } from '@/utils/platform';
import { formatFileSize } from '@/utils/fileSize';
import {
isAssetRecommended,
@ -21,127 +18,59 @@ import {
getAssetDescription,
} from '@/utils/assets';
import { ROCM } from '@/constants';
import type { GitHubAsset, GitHubRelease } from '@/types';
import { useKoboldVersions } from '@/hooks/useKoboldVersions';
import type { GitHubAsset } from '@/types';
interface DownloadScreenProps {
onDownloadComplete: () => void;
}
export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
const [latestRelease, setLatestRelease] = useState<GitHubRelease | null>(
null
);
const [filteredAssets, setFilteredAssets] = useState<GitHubAsset[]>([]);
const [userPlatform, setUserPlatform] = useState<string>('');
const [hasAMDGPU, setHasAMDGPU] = useState<boolean>(false);
const [hasROCm, setHasROCm] = useState<boolean>(false);
const [loading, setLoading] = useState(true);
const [downloading, setDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const {
platformInfo,
latestRelease,
filteredAssets,
rocmDownload,
loadingPlatform,
loadingRemote,
downloading,
downloadProgress,
handleDownload: sharedHandleDownload,
} = useKoboldVersions();
const [downloadingType, setDownloadingType] = useState<
'asset' | 'rocm' | null
>(null);
const [downloadingAsset, setDownloadingAsset] = useState<string | null>(null);
const [rocmDownload, setRocmDownload] = useState<{
name: string;
url: string;
size: number;
version?: string;
} | null>(null);
const loadLatestReleaseAndPlatform = useCallback(async () => {
try {
setLoading(true);
const [platformInfo, releaseData, rocmDownloadInfo] = await Promise.all([
window.electronAPI.kobold.getPlatform(),
window.electronAPI.kobold.getLatestRelease(),
window.electronAPI.kobold.getROCmDownload(),
]);
setUserPlatform(platformInfo.platform);
setLatestRelease(releaseData);
setRocmDownload(rocmDownloadInfo);
try {
const gpuInfo = await window.electronAPI.kobold.detectGPU();
setHasAMDGPU(gpuInfo.hasAMD);
if (gpuInfo.hasAMD) {
const rocmInfo = await window.electronAPI.kobold.detectROCm();
setHasROCm(rocmInfo.supported);
}
} catch (gpuError) {
console.warn(
'GPU detection failed, proceeding without GPU info:',
gpuError
);
setHasAMDGPU(false);
setHasROCm(false);
}
if (releaseData) {
const filtered = filterAssetsByPlatform(
releaseData.assets,
platformInfo.platform
);
setFilteredAssets(filtered);
} else {
console.error(
'GitHub API is currently unavailable. Please try again later.'
);
}
} catch (err) {
console.error('Failed to load release information:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadLatestReleaseAndPlatform();
window.electronAPI.kobold.onDownloadProgress((progress: number) => {
setDownloadProgress(progress);
});
return () => {
window.electronAPI.kobold.removeAllListeners('download-progress');
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loading = loadingPlatform || loadingRemote;
const handleDownload = useCallback(
async (type: 'asset' | 'rocm', asset?: GitHubAsset) => {
if (type === 'asset' && !asset) return;
setDownloading(true);
setDownloadingType(type);
setDownloadingAsset(type === 'asset' ? asset!.name : null);
setDownloadProgress(0);
try {
await (type === 'rocm'
? window.electronAPI.kobold.downloadROCm()
: await window.electronAPI.kobold.downloadRelease(asset!));
const success = await sharedHandleDownload(type, asset);
onDownloadComplete();
if (success) {
onDownloadComplete();
setTimeout(() => {
setDownloading(false);
setDownloadingType(null);
setDownloadingAsset(null);
setDownloadProgress(0);
}, 200);
setTimeout(() => {
setDownloadingType(null);
setDownloadingAsset(null);
}, 200);
}
} catch (error) {
console.error(`Failed to download ${type}:`, error);
setDownloading(false);
} finally {
setDownloadingType(null);
setDownloadingAsset(null);
setDownloadProgress(0);
}
},
[onDownloadComplete]
[sharedHandleDownload, onDownloadComplete]
);
const renderROCmCard = () => {
@ -152,10 +81,17 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
name={ROCM.BINARY_NAME}
size={formatFileSize(ROCM.SIZE_BYTES)}
description={getAssetDescription(ROCM.BINARY_NAME)}
isRecommended={isAssetRecommended(ROCM.BINARY_NAME, hasAMDGPU)}
isDownloading={downloading && downloadingType === 'rocm'}
downloadProgress={downloadingType === 'rocm' ? downloadProgress : 0}
disabled={downloading && downloadingType !== 'rocm'}
isRecommended={isAssetRecommended(
ROCM.BINARY_NAME,
platformInfo.hasAMDGPU
)}
isDownloading={Boolean(downloading) && downloadingType === 'rocm'}
downloadProgress={
downloadingType === 'rocm'
? downloadProgress[ROCM.BINARY_NAME] || 0
: 0
}
disabled={Boolean(downloading) && downloadingType !== 'rocm'}
onDownload={(e) => {
e.stopPropagation();
handleDownload('rocm');
@ -208,7 +144,7 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
{filteredAssets.length > 0 || rocmDownload ? (
<Stack gap="sm">
{hasAMDGPU && !hasROCm && (
{platformInfo.hasAMDGPU && !platformInfo.hasROCm && (
<Card withBorder p="md" bg="orange.0">
<Stack gap="xs">
<div
@ -221,7 +157,7 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
<Text fw={600} c="orange.9">
AMD GPU Detected
</Text>
<Tooltip
<StyledTooltip
label="ROCm is not installed. Install ROCm for optimal AMD GPU performance with KoboldCpp."
multiline
w={220}
@ -233,7 +169,7 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
>
ROCm Not Found
</Badge>
</Tooltip>
</StyledTooltip>
</div>
<Text size="sm" c="orange.8">
For best performance with your AMD GPU, consider
@ -243,11 +179,13 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
</Card>
)}
{rocmDownload && hasAMDGPU && renderROCmCard()}
{rocmDownload &&
platformInfo.hasAMDGPU &&
renderROCmCard()}
{sortAssetsByRecommendation(
filteredAssets,
hasAMDGPU
platformInfo.hasAMDGPU
).map((asset) => (
<DownloadCard
key={asset.name}
@ -256,22 +194,23 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
description={getAssetDescription(asset.name)}
isRecommended={isAssetRecommended(
asset.name,
hasAMDGPU
platformInfo.hasAMDGPU
)}
isDownloading={
downloading &&
Boolean(downloading) &&
downloadingType === 'asset' &&
downloadingAsset === asset.name
}
downloadProgress={
downloading &&
Boolean(downloading) &&
downloadingType === 'asset' &&
downloadingAsset === asset.name
? downloadProgress
? downloadProgress[asset.name] || 0
: 0
}
disabled={
downloading && downloadingAsset !== asset.name
Boolean(downloading) &&
downloadingAsset !== asset.name
}
onDownload={(e) => {
e.stopPropagation();
@ -280,7 +219,9 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
/>
))}
{rocmDownload && !hasAMDGPU && renderROCmCard()}
{rocmDownload &&
!platformInfo.hasAMDGPU &&
renderROCmCard()}
</Stack>
) : (
<Card withBorder p="md" bg="yellow.0" c="yellow.9">
@ -288,7 +229,7 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
<Text fw={500}>No downloads available</Text>
<Text size="sm">
No downloads available for your platform (
{getPlatformDisplayName(userPlatform)}).
{getPlatformDisplayName(platformInfo.platform)}).
</Text>
</Stack>
</Card>

View file

@ -1,12 +1,15 @@
import { useRef } from 'react';
import { Box, Text, Stack } from '@mantine/core';
interface ChatTabProps {
interface ImageGenerationTabProps {
serverUrl?: string;
isServerReady?: boolean;
}
export const ChatTab = ({ serverUrl, isServerReady }: ChatTabProps) => {
export const ImageGenerationTab = ({
serverUrl,
isServerReady,
}: ImageGenerationTabProps) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
if (!isServerReady || !serverUrl) {
@ -25,19 +28,21 @@ export const ChatTab = ({ serverUrl, isServerReady }: ChatTabProps) => {
Waiting for KoboldCpp server to start...
</Text>
<Text c="dimmed" size="sm">
The chat interface will load automatically when ready
The image generation interface will load automatically when ready
</Text>
</Stack>
</Box>
);
}
const imageGenerationUrl = `${serverUrl}/sdapi/v1`;
return (
<Box style={{ width: '100%', height: '100%', overflow: 'hidden' }}>
<iframe
ref={iframeRef}
src={serverUrl}
title="KoboldAI Lite Interface"
src={imageGenerationUrl}
title="KoboldCpp Image Generation Interface"
style={{
width: '100%',
height: '100%',

View file

@ -0,0 +1,64 @@
import { useRef } from 'react';
import { Box, Text, Stack } from '@mantine/core';
interface ServerTabProps {
serverUrl?: string;
isServerReady?: boolean;
mode: 'chat' | 'image-generation';
}
export const ServerTab = ({
serverUrl,
isServerReady,
mode,
}: ServerTabProps) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
if (!isServerReady || !serverUrl) {
return (
<Box
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Stack align="center" gap="md">
<Text c="dimmed" size="lg">
Waiting for KoboldCpp server to start...
</Text>
<Text c="dimmed" size="sm">
The {mode === 'chat' ? 'chat' : 'image generation'} interface will
load automatically when ready
</Text>
</Stack>
</Box>
);
}
const iframeUrl =
mode === 'image-generation' ? `${serverUrl}/sdapi/v1` : serverUrl;
const title =
mode === 'image-generation'
? 'KoboldCpp Image Generation Interface'
: 'KoboldAI Lite Interface';
return (
<Box style={{ width: '100%', height: '100%', overflow: 'hidden' }}>
<iframe
ref={iframeRef}
src={iframeUrl}
title={title}
style={{
width: '100%',
height: '100%',
border: 'none',
borderRadius: 'inherit',
}}
allow="clipboard-read; clipboard-write"
/>
</Box>
);
};

View file

@ -1,16 +1,18 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Container } from '@mantine/core';
import { ChatTab } from '@/components/interface/ChatTab';
import { TerminalTab } from '@/components/interface/TerminalTab';
import { ServerTab } from '@/screens/Interface/ServerTab';
import { TerminalTab } from '@/screens/Interface/TerminalTab';
interface InterfaceScreenProps {
activeTab?: string | null;
onTabChange?: (tab: string | null) => void;
isImageGenerationMode?: boolean;
}
export const InterfaceScreen = ({
activeTab,
onTabChange,
isImageGenerationMode = false,
}: InterfaceScreenProps) => {
const [serverOnly, setServerOnly] = useState<boolean>(false);
const [serverUrl, setServerUrl] = useState<string>('');
@ -22,10 +24,10 @@ export const InterfaceScreen = ({
setIsServerReady(true);
if (!serverOnly && onTabChange) {
onTabChange('chat');
onTabChange(isImageGenerationMode ? 'image' : 'chat');
}
},
[serverOnly, onTabChange]
[serverOnly, onTabChange, isImageGenerationMode]
);
useEffect(() => {
@ -66,7 +68,23 @@ export const InterfaceScreen = ({
display: activeTab === 'chat' ? 'block' : 'none',
}}
>
<ChatTab serverUrl={serverUrl} isServerReady={isServerReady} />
<ServerTab
serverUrl={serverUrl}
isServerReady={isServerReady}
mode={isImageGenerationMode ? 'image-generation' : 'chat'}
/>
</div>
<div
style={{
height: '100%',
display: activeTab === 'image' ? 'block' : 'none',
}}
>
<ServerTab
serverUrl={serverUrl}
isServerReady={isServerReady}
mode="image-generation"
/>
</div>
<div
style={{

View file

@ -31,22 +31,6 @@ export const AdvancedTab = ({
onFailsafeChange,
}: AdvancedTabProps) => (
<Stack gap="lg">
<div>
<Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}>
Additional arguments
</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>
<TextInput
placeholder="Additional command line arguments"
value={additionalArguments}
onChange={(event) =>
onAdditionalArgumentsChange(event.currentTarget.value)
}
/>
</div>
<div>
<Stack gap="md">
<Group gap="lg" align="flex-start" wrap="nowrap">
@ -115,5 +99,20 @@ export const AdvancedTab = ({
</Group>
</Stack>
</div>
<div>
<Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}>
Additional arguments
</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>
<TextInput
placeholder="Additional command line arguments"
value={additionalArguments}
onChange={(event) =>
onAdditionalArgumentsChange(event.currentTarget.value)
}
/>
</div>
</Stack>
);

View file

@ -0,0 +1,213 @@
import {
Text,
Group,
Select,
Badge,
Card,
useMantineColorScheme,
ActionIcon,
Tooltip,
} from '@mantine/core';
import { useState, useEffect } from 'react';
import { AlertTriangle } from 'lucide-react';
import { InfoTooltip } from '@/components/InfoTooltip';
import { isNoCudaBinary, isRocmBinary } from '@/utils/binaryUtils';
interface BackendSelectorProps {
backend: string;
onBackendChange: (backend: string) => void;
noavx2?: boolean;
failsafe?: boolean;
}
export const BackendSelector = ({
backend,
onBackendChange,
noavx2 = false,
failsafe = false,
}: BackendSelectorProps) => {
const { colorScheme } = useMantineColorScheme();
const isDark = colorScheme === 'dark';
const [availableBackends, setAvailableBackends] = useState<
Array<{ value: string; label: string; devices?: string[] }>
>([]);
const [isLoadingBackends, setIsLoadingBackends] = useState(true);
const [cpuCapabilities, setCpuCapabilities] = useState<{
avx: boolean;
avx2: boolean;
} | null>(null);
useEffect(() => {
const detectAvailableBackends = async () => {
setIsLoadingBackends(true);
try {
const [currentBinaryInfo, cpuCapabilitiesResult, gpuCapabilities] =
await Promise.all([
window.electronAPI.kobold.getCurrentBinaryInfo(),
window.electronAPI.kobold.detectCPU(),
window.electronAPI.kobold.detectGPUCapabilities(),
]);
setCpuCapabilities({
avx: cpuCapabilitiesResult.avx,
avx2: cpuCapabilitiesResult.avx2,
});
const backends: Array<{
value: string;
label: string;
devices?: string[];
}> = [];
if (currentBinaryInfo?.filename) {
const filename = currentBinaryInfo.filename;
if (!isNoCudaBinary(filename) && gpuCapabilities.cuda.supported) {
backends.push({
value: 'cuda',
label: 'CUDA',
devices: gpuCapabilities.cuda.devices,
});
}
if (isRocmBinary(filename) && gpuCapabilities.rocm.supported) {
backends.push({
value: 'rocm',
label: 'ROCm',
devices: gpuCapabilities.rocm.devices,
});
}
if (gpuCapabilities.vulkan.supported) {
backends.push({
value: 'vulkan',
label: 'Vulkan',
devices: gpuCapabilities.vulkan.devices,
});
}
if (gpuCapabilities.clblast.supported) {
backends.push({
value: 'clblast',
label: 'CLBlast',
devices: gpuCapabilities.clblast.devices,
});
}
backends.push({
value: 'cpu',
label: 'CPU',
devices: cpuCapabilitiesResult.cpuInfo,
});
}
setAvailableBackends(backends);
if (
backends.length > 0 &&
(!backend || !backends.some((b) => b.value === backend))
) {
onBackendChange(backends[0].value);
}
} catch (error) {
console.warn('Failed to detect available backends:', error);
setAvailableBackends([]);
} finally {
setIsLoadingBackends(false);
}
};
void detectAvailableBackends();
}, [backend, onBackendChange]);
const getWarnings = () => {
if (backend !== 'cpu' || !cpuCapabilities) return [];
const warnings = [];
if (!cpuCapabilities.avx2 && !noavx2) {
warnings.push({
type: 'warning',
message:
'Your CPU does not support AVX2. Enable the "Disable AVX2" option to avoid crashes.',
});
}
if (!cpuCapabilities.avx && !cpuCapabilities.avx2 && !failsafe) {
warnings.push({
type: 'warning',
message:
'Your CPU does not support AVX or AVX2. Enable the "Failsafe" option to avoid crashes.',
});
}
return warnings;
};
return (
<div>
<Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}>
Backend
</Text>
<InfoTooltip label="Select a backend to use. CUDA runs on Nvidia GPUs, and is much faster. ROCm is the AMD equivalent. Vulkan and CLBlast works on all GPUs but are somewhat slower." />
{getWarnings().map((warning, index) => (
<Tooltip
key={index}
label={warning.message}
multiline
w={300}
withArrow
>
<ActionIcon size="sm" color="orange" variant="light">
<AlertTriangle size={14} />
</ActionIcon>
</Tooltip>
))}
</Group>
<Select
placeholder={
isLoadingBackends
? 'Detecting available backends...'
: 'Select backend'
}
value={backend}
onChange={(value) => value && onBackendChange(value)}
data={availableBackends.map((b) => ({
value: b.value,
label: b.label,
}))}
disabled={isLoadingBackends || availableBackends.length === 0}
/>
{backend &&
availableBackends.find((b) => b.value === backend)?.devices && (
<Group gap="xs" mt="xs">
<Text size="xs" c="dimmed">
Devices:
</Text>
{availableBackends
.find((b) => b.value === backend)
?.devices?.map((device, index) => (
<Badge key={index} variant="light" size="sm">
{device}
</Badge>
))}
</Group>
)}
{backend === 'cpu' && (
<Card withBorder p="sm" mt="xs" bg={isDark ? 'blue.9' : 'blue.0'}>
<Text size="sm" c={isDark ? 'blue.2' : 'blue.8'}>
<Text component="span" fw={600}>
Performance Note:
</Text>{' '}
LLMs run significantly faster on GPU-accelerated systems. Consider
using NVIDIA&apos;s CUDA or AMD&apos;s ROCm backends for optimal
performance.
</Text>
</Card>
)}
</div>
);
};

View file

@ -1,14 +1,5 @@
import {
Stack,
Text,
Group,
Button,
ActionIcon,
Menu,
Select,
Badge,
} from '@mantine/core';
import { RotateCcw, Save, Settings2, File } from 'lucide-react';
import { Stack, Text, Group, Button, Menu, Select, Badge } from '@mantine/core';
import { Save, Settings2, File } from 'lucide-react';
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import type { ConfigFile } from '@/types';
@ -16,7 +7,6 @@ interface ConfigurationManagerProps {
configFiles: ConfigFile[];
selectedFile: string | null;
onFileSelection: (fileName: string) => void;
onRefresh: () => void;
onSaveAsNew: () => void;
onUpdateCurrent: () => void;
}
@ -58,43 +48,31 @@ export const ConfigurationManager = ({
configFiles,
selectedFile,
onFileSelection,
onRefresh,
onSaveAsNew,
onUpdateCurrent,
}: ConfigurationManagerProps) => (
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={500}>Configuration File</Text>
<Group gap="xs">
<Menu>
<Menu.Target>
<Button variant="light" leftSection={<Save size={16} />}>
Save
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<Save size={16} />} onClick={onSaveAsNew}>
Save as new configuration
</Menu.Item>
<Menu.Item
leftSection={<Settings2 size={16} />}
disabled={!selectedFile}
onClick={onUpdateCurrent}
>
Update current configuration
</Menu.Item>
</Menu.Dropdown>
</Menu>
<ActionIcon
variant="light"
onClick={onRefresh}
size="lg"
aria-label="Refresh configuration files"
title="Refresh configuration files"
>
<RotateCcw size={16} />
</ActionIcon>
</Group>
<Menu>
<Menu.Target>
<Button variant="light" leftSection={<Save size={16} />}>
Save
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<Save size={16} />} onClick={onSaveAsNew}>
Save as new configuration
</Menu.Item>
<Menu.Item
leftSection={<Settings2 size={16} />}
disabled={!selectedFile}
onClick={onUpdateCurrent}
>
Update current configuration
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
{configFiles.length === 0 ? (

View file

@ -6,16 +6,11 @@ import {
Button,
Checkbox,
Slider,
Select,
Badge,
Card,
useMantineColorScheme,
} from '@mantine/core';
import { useState, useEffect } from 'react';
import { File, Search } from 'lucide-react';
import { InfoTooltip } from '@/components/InfoTooltip';
import { BackendSelector } from '@/screens/Launch/BackendSelector';
import { getInputValidationState } from '@/utils/validation';
import { isNoCudaBinary, isRocmBinary } from '@/utils/binaryUtils';
interface GeneralTabProps {
modelPath: string;
@ -23,6 +18,8 @@ interface GeneralTabProps {
autoGpuLayers: boolean;
contextSize: number;
backend: string;
noavx2: boolean;
failsafe: boolean;
onModelPathChange: (path: string) => void;
onSelectModelFile: () => void;
onGpuLayersChange: (layers: number) => void;
@ -37,6 +34,8 @@ export const GeneralTab = ({
autoGpuLayers,
contextSize,
backend,
noavx2,
failsafe,
onModelPathChange,
onSelectModelFile,
onGpuLayersChange,
@ -44,89 +43,6 @@ export const GeneralTab = ({
onContextSizeChange,
onBackendChange,
}: GeneralTabProps) => {
const { colorScheme } = useMantineColorScheme();
const isDark = colorScheme === 'dark';
const [availableBackends, setAvailableBackends] = useState<
Array<{ value: string; label: string; devices?: string[] }>
>([]);
const [isLoadingBackends, setIsLoadingBackends] = useState(true);
useEffect(() => {
const detectAvailableBackends = async () => {
setIsLoadingBackends(true);
try {
const [currentBinaryInfo, _cpuCapabilities, gpuCapabilities] =
await Promise.all([
window.electronAPI.kobold.getCurrentBinaryInfo(),
window.electronAPI.kobold.detectCPU(),
window.electronAPI.kobold.detectGPUCapabilities(),
]);
const backends: Array<{
value: string;
label: string;
devices?: string[];
}> = [];
if (currentBinaryInfo?.filename) {
const filename = currentBinaryInfo.filename;
backends.push({ value: 'cpu', label: 'CPU' });
if (!isNoCudaBinary(filename) && gpuCapabilities.cuda.supported) {
backends.push({
value: 'cuda',
label: 'CUDA',
devices: gpuCapabilities.cuda.devices,
});
}
if (isRocmBinary(filename) && gpuCapabilities.rocm.supported) {
backends.push({
value: 'rocm',
label: 'ROCm',
devices: gpuCapabilities.rocm.devices,
});
}
if (gpuCapabilities.vulkan.supported) {
backends.push({
value: 'vulkan',
label: 'Vulkan',
devices: gpuCapabilities.vulkan.devices,
});
}
if (gpuCapabilities.clblast.supported) {
backends.push({
value: 'clblast',
label: 'CLBlast',
devices: gpuCapabilities.clblast.devices,
});
}
}
setAvailableBackends(backends);
if (
backends.length > 0 &&
(!backend || !backends.some((b) => b.value === backend))
) {
onBackendChange(backends[0].value);
}
} catch (error) {
console.warn('Failed to detect available backends:', error);
setAvailableBackends([]);
} finally {
setIsLoadingBackends(false);
}
};
void detectAvailableBackends();
}, [backend, onBackendChange]);
const validationState = getInputValidationState(modelPath);
const getInputColor = () => {
@ -154,7 +70,7 @@ export const GeneralTab = ({
<Stack gap="lg">
<div>
<Text size="sm" fw={500} mb="xs">
Text Model File *
Text Model File
</Text>
<Group gap="xs" align="flex-start">
<div style={{ flex: 1 }}>
@ -162,7 +78,6 @@ export const GeneralTab = ({
placeholder="Select a .gguf model file or enter a direct URL to file"
value={modelPath}
onChange={(event) => onModelPathChange(event.currentTarget.value)}
required
color={getInputColor()}
error={
validationState === 'invalid' ? getHelperText() : undefined
@ -190,55 +105,12 @@ export const GeneralTab = ({
</Group>
</div>
<div>
<Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}>
Backend
</Text>
<InfoTooltip label="Select a backend to use. CUDA runs on Nvidia GPUs, and is much faster. ROCm is the AMD equivalent. Vulkan and CLBlast works on all GPUs but are somewhat slower." />
</Group>
<Select
placeholder={
isLoadingBackends
? 'Detecting available backends...'
: 'Select backend'
}
value={backend}
onChange={(value) => value && onBackendChange(value)}
data={availableBackends.map((b) => ({
value: b.value,
label: b.label,
}))}
disabled={isLoadingBackends || availableBackends.length === 0}
/>
{backend &&
availableBackends.find((b) => b.value === backend)?.devices && (
<Group gap="xs" mt="xs">
<Text size="xs" c="dimmed">
Devices:
</Text>
{availableBackends
.find((b) => b.value === backend)
?.devices?.map((device, index) => (
<Badge key={index} variant="light" size="sm">
{device}
</Badge>
))}
</Group>
)}
{backend === 'cpu' && (
<Card withBorder p="sm" mt="xs" bg={isDark ? 'blue.9' : 'blue.0'}>
<Text size="sm" c={isDark ? 'blue.2' : 'blue.8'}>
<Text component="span" fw={600}>
Performance Note:
</Text>{' '}
LLMs run significantly faster on GPU-accelerated systems. Consider
using NVIDIA&apos;s CUDA or AMD&apos;s ROCm backends for optimal
performance.
</Text>
</Card>
)}
</div>
<BackendSelector
backend={backend}
onBackendChange={onBackendChange}
noavx2={noavx2}
failsafe={failsafe}
/>
<div>
<Group justify="space-between" align="center" mb="xs">

View file

@ -0,0 +1,233 @@
import {
Stack,
Text,
Group,
TextInput,
Button,
Select,
Alert,
} from '@mantine/core';
import { File, Search, AlertTriangle } from 'lucide-react';
import { InfoTooltip } from '@/components/InfoTooltip';
import { getInputValidationState } from '@/utils/validation';
import { IMAGE_MODEL_PRESETS } from '@/utils/imageModelPresets';
interface ImageGenerationTabProps {
sdmodel: string;
sdt5xxl: string;
sdclipl: string;
sdclipg: string;
sdphotomaker: string;
sdvae: string;
textModelPath?: string;
onSdmodelChange: (path: string) => void;
onSelectSdmodelFile: () => void;
onSdt5xxlChange: (path: string) => void;
onSelectSdt5xxlFile: () => void;
onSdcliplChange: (path: string) => void;
onSelectSdcliplFile: () => void;
onSdclipgChange: (path: string) => void;
onSelectSdclipgFile: () => void;
onSdphotomakerChange: (path: string) => void;
onSelectSdphotomakerFile: () => void;
onSdvaeChange: (path: string) => void;
onSelectSdvaeFile: () => void;
onApplyPreset: (presetName: string) => void;
}
const ModelField = ({
label,
value,
placeholder,
tooltip,
onChange,
onSelectFile,
showSearchHF = false,
}: {
label: string;
value: string;
placeholder: string;
tooltip?: string;
onChange: (value: string) => void;
onSelectFile: () => void;
showSearchHF?: boolean;
}) => {
const validationState = getInputValidationState(value);
const getInputColor = () => {
switch (validationState) {
case 'valid':
return 'green';
case 'invalid':
return 'red';
default:
return undefined;
}
};
const getHelperText = () => {
if (!value.trim()) return undefined;
if (validationState === 'invalid') {
return 'Enter a valid URL or file path';
}
return undefined;
};
return (
<div>
<Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}>
{label}
</Text>
{tooltip && <InfoTooltip label={tooltip} />}
</Group>
<Group gap="xs" align="flex-start">
<div style={{ flex: 1 }}>
<TextInput
placeholder={placeholder}
value={value}
onChange={(event) => onChange(event.currentTarget.value)}
color={getInputColor()}
error={validationState === 'invalid' ? getHelperText() : undefined}
/>
</div>
<Button
onClick={onSelectFile}
variant="light"
leftSection={<File size={16} />}
>
Browse
</Button>
{showSearchHF && (
<Button
onClick={() => {
window.electronAPI.app.openExternal(
'https://huggingface.co/models?pipeline_tag=text-to-image&library=gguf&sort=trending'
);
}}
variant="outline"
leftSection={<Search size={16} />}
>
Search HF
</Button>
)}
</Group>
</div>
);
};
export const ImageGenerationTab = ({
sdmodel,
sdt5xxl,
sdclipl,
sdclipg,
sdphotomaker,
sdvae,
textModelPath,
onSdmodelChange,
onSelectSdmodelFile,
onSdt5xxlChange,
onSelectSdt5xxlFile,
onSdcliplChange,
onSelectSdcliplFile,
onSdclipgChange,
onSelectSdclipgFile,
onSdphotomakerChange,
onSelectSdphotomakerFile,
onSdvaeChange,
onSelectSdvaeFile,
onApplyPreset,
}: ImageGenerationTabProps) => {
const hasTextModel = textModelPath?.trim() !== '';
const hasImageModel = sdmodel.trim() !== '';
return (
<Stack gap="lg">
<div>
<Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}>
Model Preset
</Text>
<InfoTooltip label="Quick presets for popular image generation models with pre-configured encoders." />
</Group>
<Select
placeholder="Choose a preset..."
data={IMAGE_MODEL_PRESETS.map((preset) => ({
value: preset.name,
label: preset.name,
}))}
value={null}
onChange={(value) => {
if (value) {
onApplyPreset(value);
}
}}
clearable
/>
</div>
{hasTextModel && hasImageModel && (
<Alert
color="orange"
title="Model Priority Notice"
icon={<AlertTriangle size={16} />}
>
Both text and image generation models are selected. The image
generation model will take priority and be used for launch.
</Alert>
)}
<ModelField
label="Image Gen. Model File"
value={sdmodel}
placeholder="Select a model file or enter a direct URL"
onChange={onSdmodelChange}
onSelectFile={onSelectSdmodelFile}
showSearchHF
/>
<ModelField
label="T5-XXL File"
value={sdt5xxl}
placeholder="Select a T5-XXL file or enter a direct URL"
onChange={onSdt5xxlChange}
onSelectFile={onSelectSdt5xxlFile}
/>
<ModelField
label="Clip-L File"
value={sdclipl}
placeholder="Select a Clip-L file or enter a direct URL"
onChange={onSdcliplChange}
onSelectFile={onSelectSdcliplFile}
/>
<ModelField
label="Clip-G File"
value={sdclipg}
placeholder="Select a Clip-G file or enter a direct URL"
onChange={onSdclipgChange}
onSelectFile={onSelectSdclipgFile}
/>
<ModelField
label="PhotoMaker"
value={sdphotomaker}
placeholder="Select a PhotoMaker file or enter a direct URL"
tooltip="PhotoMaker is a model that allows face cloning. Select a .safetensors PhotoMaker file to be loaded (SDXL only)."
onChange={onSdphotomakerChange}
onSelectFile={onSelectSdphotomakerFile}
/>
<ModelField
label="Image VAE"
value={sdvae}
placeholder="Select a VAE file or enter a direct URL"
onChange={onSdvaeChange}
onSelectFile={onSelectSdvaeFile}
/>
</Stack>
);
};

View file

@ -10,18 +10,23 @@ import {
} from '@mantine/core';
import { useState, useEffect, useCallback } from 'react';
import { useLaunchConfig } from '@/hooks/useLaunchConfig';
import { ConfigurationManager } from '@/components/launch/ConfigurationManager';
import { GeneralTab } from '@/components/launch/GeneralTab';
import { AdvancedTab } from '@/components/launch/AdvancedTab';
import { NetworkTab } from '@/components/launch/NetworkTab';
import { SaveConfigModal } from '@/components/launch/SaveConfigModal';
import { ConfigurationManager } from '@/screens/Launch/ConfigurationManager';
import { GeneralTab } from '@/screens/Launch/GeneralTab';
import { AdvancedTab } from '@/screens/Launch/AdvancedTab';
import { NetworkTab } from '@/screens/Launch/NetworkTab';
import { ImageGenerationTab } from '@/screens/Launch/ImageGenerationTab';
import { SaveConfigModal } from '@/screens/Launch/SaveConfigModal';
import type { ConfigFile } from '@/types';
interface LaunchScreenProps {
onLaunch: () => void;
onLaunchModeChange?: (isImageMode: boolean) => void;
}
export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
export const LaunchScreen = ({
onLaunch,
onLaunchModeChange,
}: LaunchScreenProps) => {
const [configFiles, setConfigFiles] = useState<ConfigFile[]>([]);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [, setInstallDir] = useState<string>('');
@ -49,6 +54,12 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
noavx2,
failsafe,
backend,
sdmodel,
sdt5xxl,
sdclipl,
sdclipg,
sdphotomaker,
sdvae,
parseAndApplyConfigFile,
loadSavedSettings,
loadConfigFromFile,
@ -71,6 +82,19 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
handleNoavx2Change,
handleFailsafeChange,
handleBackendChange,
handleSdmodelChange,
handleSelectSdmodelFile,
handleSdt5xxlChange,
handleSelectSdt5xxlFile,
handleSdcliplChange,
handleSelectSdcliplFile,
handleSdclipgChange,
handleSelectSdclipgFile,
handleSdphotomakerChange,
handleSelectSdphotomakerFile,
handleSdvaeChange,
handleSelectSdvaeFile,
handleApplyPreset,
} = useLaunchConfig();
const loadConfigFiles = useCallback(async () => {
@ -199,6 +223,36 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
setHasUnsavedChanges(true);
};
const handleSdmodelChangeWithTracking = (path: string) => {
handleSdmodelChange(path);
setHasUnsavedChanges(true);
};
const handleSdt5xxlChangeWithTracking = (path: string) => {
handleSdt5xxlChange(path);
setHasUnsavedChanges(true);
};
const handleSdcliplChangeWithTracking = (path: string) => {
handleSdcliplChange(path);
setHasUnsavedChanges(true);
};
const handleSdclipgChangeWithTracking = (path: string) => {
handleSdclipgChange(path);
setHasUnsavedChanges(true);
};
const handleSdphotomakerChangeWithTracking = (path: string) => {
handleSdphotomakerChange(path);
setHasUnsavedChanges(true);
};
const handleSdvaeChangeWithTracking = (path: string) => {
handleSdvaeChange(path);
setHasUnsavedChanges(true);
};
useEffect(() => {
void loadConfigFiles();
@ -213,8 +267,12 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
return cleanup;
}, [loadConfigFiles]);
// eslint-disable-next-line sonarjs/cognitive-complexity
const handleLaunch = async () => {
if (isLaunching || !modelPath) {
const isImageMode = sdmodel.trim() !== '';
const isTextMode = modelPath.trim() !== '';
if (isLaunching || (!isImageMode && !isTextMode)) {
return;
}
@ -227,7 +285,33 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
const args: string[] = [];
args.push('--model', modelPath);
if (isImageMode && isTextMode) {
args.push('--sdmodel', sdmodel);
} else if (isImageMode) {
args.push('--sdmodel', sdmodel);
if (sdt5xxl.trim()) {
args.push('--sdt5xxl', sdt5xxl);
}
if (sdclipl.trim()) {
args.push('--sdclipl', sdclipl);
}
if (sdclipg.trim()) {
args.push('--sdclipg', sdclipg);
}
if (sdphotomaker.trim()) {
args.push('--sdphotomaker', sdphotomaker);
}
if (sdvae.trim()) {
args.push('--sdvae', sdvae);
}
} else {
args.push('--model', modelPath);
}
if (autoGpuLayers) {
args.push('--gpulayers', '-1');
@ -298,6 +382,10 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
);
if (result.success) {
if (onLaunchModeChange) {
onLaunchModeChange(isImageMode);
}
setTimeout(() => {
onLaunch();
}, 100);
@ -332,7 +420,7 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
</div>
<Button
radius="md"
disabled={!modelPath || isLaunching}
disabled={(!modelPath && !sdmodel) || isLaunching}
onClick={handleLaunch}
loading={isLaunching}
size="lg"
@ -371,6 +459,8 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
autoGpuLayers={autoGpuLayers}
contextSize={contextSize}
backend={backend}
noavx2={noavx2}
failsafe={failsafe}
onModelPathChange={handleModelPathChangeWithTracking}
onSelectModelFile={handleSelectModelFile}
onGpuLayersChange={handleGpuLayersChangeWithTracking}
@ -421,12 +511,28 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
</Tabs.Panel>
<Tabs.Panel value="image" pt="md">
<Stack gap="lg" align="center" py="xl">
<Text c="dimmed" ta="center">
Image generation configuration will be available in a future
update.
</Text>
</Stack>
<ImageGenerationTab
sdmodel={sdmodel}
sdt5xxl={sdt5xxl}
sdclipl={sdclipl}
sdclipg={sdclipg}
sdphotomaker={sdphotomaker}
sdvae={sdvae}
textModelPath={modelPath}
onSdmodelChange={handleSdmodelChangeWithTracking}
onSelectSdmodelFile={handleSelectSdmodelFile}
onSdt5xxlChange={handleSdt5xxlChangeWithTracking}
onSelectSdt5xxlFile={handleSelectSdt5xxlFile}
onSdcliplChange={handleSdcliplChangeWithTracking}
onSelectSdcliplFile={handleSelectSdcliplFile}
onSdclipgChange={handleSdclipgChangeWithTracking}
onSelectSdclipgFile={handleSelectSdclipgFile}
onSdphotomakerChange={handleSdphotomakerChangeWithTracking}
onSelectSdphotomakerFile={handleSelectSdphotomakerFile}
onSdvaeChange={handleSdvaeChangeWithTracking}
onSelectSdvaeFile={handleSelectSdvaeFile}
onApplyPreset={handleApplyPreset}
/>
</Tabs.Panel>
</div>
</Tabs>

View file

@ -93,6 +93,35 @@ export interface KoboldAPI {
getConfigFiles: () => Promise<
Array<{ name: string; path: string; size: number }>
>;
saveConfigFile: (
configName: string,
configData: {
gpulayers?: number;
contextsize?: number;
model_param?: string;
port?: number;
host?: string;
multiuser?: number;
multiplayer?: boolean;
remotetunnel?: boolean;
nocertify?: boolean;
websearch?: boolean;
noshift?: boolean;
flashattention?: boolean;
noavx2?: boolean;
failsafe?: boolean;
usecuda?: boolean;
usevulkan?: boolean;
useclblast?: boolean;
sdmodel?: string;
sdt5xxl?: string;
sdclipl?: string;
sdclipg?: string;
sdphotomaker?: string;
sdvae?: string;
[key: string]: unknown;
}
) => Promise<boolean>;
getSelectedConfig: () => Promise<string | null>;
setSelectedConfig: (configName: string) => Promise<boolean>;
parseConfigFile: (filePath: string) => Promise<{

View file

@ -0,0 +1,54 @@
export interface ImageModelPreset {
name: string;
description?: string;
sdmodel: string;
sdt5xxl: string;
sdclipl: string;
sdclipg: string;
sdphotomaker: string;
sdvae: string;
}
export const IMAGE_MODEL_PRESETS: ImageModelPreset[] = [
{
name: 'FLUX.1',
description: 'FLUX.1 development model with default encoders',
sdmodel:
'https://huggingface.co/bullerwins/FLUX.1-Kontext-dev-GGUF/resolve/main/flux1-kontext-dev-Q4_K_S.gguf?download=true',
sdt5xxl:
'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/t5xxl_fp8_e4m3fn.safetensors?download=true',
sdclipl:
'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/clip_l.safetensors?download=true',
sdclipg: '',
sdphotomaker: '',
sdvae:
'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/ae.safetensors?download=true',
},
{
name: 'Chroma',
description: 'Chroma with optimized VAE and shared encoders',
sdmodel:
'https://huggingface.co/silveroxides/Chroma-GGUF/resolve/main/chroma-unlocked-v29/chroma-unlocked-v29-Q4_K_S.gguf?download=true',
sdt5xxl:
'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/t5xxl_fp8_e4m3fn.safetensors?download=true',
sdclipl:
'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/clip_l.safetensors?download=true',
sdclipg: '',
sdphotomaker: '',
sdvae:
'https://huggingface.co/lodestones/Chroma/resolve/main/ae.safetensors?download=true',
},
{
name: 'Custom',
description: 'Start with empty fields for custom configuration',
sdmodel: '',
sdt5xxl: '',
sdclipl: '',
sdclipg: '',
sdphotomaker: '',
sdvae: '',
},
];
export const getPresetByName = (name: string): ImageModelPreset | undefined =>
IMAGE_MODEL_PRESETS.find((preset) => preset.name === name);