adding seamless integration for sillytavern as an optional frontend, refactors to reduce code duplication, add http link detection to terminal output

This commit is contained in:
Egor 2025-08-27 22:15:52 -07:00
parent 861a1afb87
commit a559f8c86d
45 changed files with 2173 additions and 685 deletions

1
.gitignore vendored
View file

@ -16,3 +16,4 @@ ae.safetensors
clip_l.safetensors
t5xxl_fp8_e4m3fn.safetensors
flux1-kontext-dev-Q3_K_S.gguf
gemma-3-4b-it.Q8_0.gguf

View file

@ -90,28 +90,34 @@ The `--cli` argument allows you to use the Friendly Kobold binary as a proxy to
### Considerations
You might want to run CLI Mode if you're looking to use a different frontend, such as SillyTavern or OpenWebUI, than the ones bundled (eg. KoboldAI Lite, Stable UI) with KoboldCpp AND you're looking to minimize any resource utilization of this app. Note that at the time of this writing, Friendly Kobold only takes about ~200MB of RAM and ~100MB of VRAM for its Chromium-based UI. When running in CLI Mode, Friendly Kobold will still take about 1/3 of those RAM and VRAM numbers.
You might want to run CLI Mode if you're looking to use a different frontend, such as OpenWebUI, than the ones bundled (eg. KoboldAI Lite, Stable UI) with KoboldCpp AND you're looking to minimize any resource utilization of this app. Note that at the time of this writing, Friendly Kobold only takes about ~200MB of RAM and ~100MB of VRAM for its Chromium-based UI. When running in CLI Mode, Friendly Kobold will still take about 1/3 of those RAM and VRAM numbers.
### Usage
**Linux/macOS:**
```bash
# Basic usage - launch KoboldCpp with no arguments
./friendly-kobold --cli
# Basic usage - launch KoboldCpp launcher with no arguments
friendly-kobold --cli
# Pass arguments to KoboldCpp
./friendly-kobold --cli --help
./friendly-kobold --cli --port 5001 --model /path/to/model.gguf
friendly-kobold --cli --help
friendly-kobold --cli --port 5001 --model /path/to/model.gguf
# Any KoboldCpp arguments are supported
./friendly-kobold --cli --model /path/to/model.gguf --port 5001 --host 0.0.0.0 --multiuser 2
friendly-kobold --cli --model /path/to/model.gguf --port 5001 --host 0.0.0.0 --multiuser 2
# CLI inception (friendly kobold CLI calling KoboldCpp CLI mode)
# This is the ideal way to run a custom frontend
friendly-kobold --cli --cli --model /path/to/model.gguf --gpulayers 57 --contextsize 8192 --port 5001 --multiuser 1 --flashattention --usemmap --usevulkan
```
**Windows:**
CLI mode will only work correctly on Windows if you install Friendly Kobold using the Setup.exe from the github releases. Otherwise there is currently a technical limitation with the Windows portable .exe which will cause it to not display the terminal output correctly nor will it be killable through the standard terminal (Ctrl+C) commands.
You can use the CLI mode on Windows in exactly the same way as in the Linux/macOS examples above, except you'll be calling the "Friendly Kobold.exe". Note that it will not be on your system PATH by default, so you'll need to manually specify the full path to it when callig it from the Windows terminal.
## For Local Dev
### Prerequisites
@ -139,10 +145,6 @@ CLI mode will only work correctly on Windows if you install Friendly Kobold usin
yarn dev
```
### Future considerations
It would make a lot of sense to transition this project to Tauri from Electron. The app size should drop from ~110MB 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.
## License
AGPL v3 License - see LICENSE file for details

View file

@ -56,6 +56,10 @@ const config = [
rules: {
...reactHooks.configs.recommended.rules,
...react.configs.recommended.rules,
...importPlugin.configs.recommended.rules,
...importPlugin.configs.typescript.rules,
...tseslint.configs.recommended.rules,
...tseslint.configs.stylistic.rules,
'@typescript-eslint/no-unused-vars': [
'error',
@ -102,6 +106,11 @@ const config = [
'import/no-default-export': 'error',
'import/prefer-default-export': 'off',
'import/no-unresolved': 'off',
'import/no-relative-parent-imports': 'error',
'@typescript-eslint/array-type': ['error', { default: 'array' }],
'@typescript-eslint/no-require-imports': 'error',
'arrow-body-style': ['error', 'as-needed'],
'prefer-arrow-callback': ['error', { allowNamedFunctions: false }],

View file

@ -1,7 +1,7 @@
{
"name": "friendly-kobold",
"productName": "Friendly Kobold",
"version": "0.8.1",
"version": "0.9.0",
"description": "A desktop app for running Large Language Models locally",
"main": "out/main/index.js",
"homepage": "./",
@ -54,14 +54,13 @@
"devDependencies": {
"@eslint/js": "^9.34.0",
"@types/node": "^24.3.0",
"@types/react": "^19.1.11",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.8",
"@types/strip-ansi": "^5.2.1",
"@typescript-eslint/eslint-plugin": "^8.41.0",
"@typescript-eslint/parser": "^8.41.0",
"@vitejs/plugin-react": "^5.0.1",
"@vitejs/plugin-react": "^5.0.2",
"cross-env": "^10.0.0",
"electron": "^37.3.1",
"electron": "^37.4.0",
"electron-builder": "^26.0.12",
"electron-vite": "^4.0.0",
"eslint": "^9.34.0",
@ -86,9 +85,9 @@
"execa": "^9.6.0",
"got": "^14.4.7",
"lucide-react": "^0.542.0",
"npm": "^11.5.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"strip-ansi": "^7.1.0",
"systeminformation": "^5.27.8",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0",

View file

@ -13,9 +13,7 @@ import { useUpdateChecker } from '@/hooks/useUpdateChecker';
import { useKoboldVersions } from '@/hooks/useKoboldVersions';
import { UI } from '@/constants';
import type { DownloadItem } from '@/types/electron';
import type { InterfaceTab } from '@/types';
type Screen = 'welcome' | 'download' | 'launch' | 'interface';
import type { InterfaceTab, FrontendPreference, Screen } from '@/types';
export const App = () => {
const [currentScreen, setCurrentScreen] = useState<Screen | null>(null);
@ -25,6 +23,8 @@ export const App = () => {
const [activeInterfaceTab, setActiveInterfaceTab] =
useState<InterfaceTab>('terminal');
const [isImageGenerationMode, setIsImageGenerationMode] = useState(false);
const [frontendPreference, setFrontendPreference] =
useState<FrontendPreference>('koboldcpp');
const {
updateInfo: binaryUpdateInfo,
@ -46,15 +46,19 @@ export const App = () => {
useEffect(() => {
const checkInstallation = async () => {
try {
const [versions, currentBinaryPath, hasSeenWelcome] = await Promise.all(
[
const [versions, currentBinaryPath, hasSeenWelcome, preference] =
await Promise.all([
window.electronAPI.kobold.getInstalledVersions(),
window.electronAPI.config.get(
'currentKoboldBinary'
) as Promise<string>,
window.electronAPI.config.get('hasSeenWelcome') as Promise<boolean>,
]
);
window.electronAPI.config.get(
'frontendPreference'
) as Promise<FrontendPreference>,
]);
setFrontendPreference(preference || 'koboldcpp');
if (!hasSeenWelcome) {
setCurrentScreenWithTransition('welcome');
@ -214,106 +218,118 @@ export const App = () => {
};
return (
<>
<AppShell
header={{ height: currentScreen === 'welcome' ? 0 : UI.HEADER_HEIGHT }}
padding={currentScreen === 'interface' ? 0 : 'md'}
<AppShell
header={{ height: currentScreen === 'welcome' ? 0 : UI.HEADER_HEIGHT }}
padding={currentScreen === 'interface' ? 0 : 'md'}
>
{currentScreen !== 'welcome' && (
<AppHeader
currentScreen={currentScreen}
activeInterfaceTab={activeInterfaceTab}
setActiveInterfaceTab={setActiveInterfaceTab}
isImageGenerationMode={isImageGenerationMode}
frontendPreference={frontendPreference}
onEject={handleEject}
onSettingsOpen={() => setSettingsOpened(true)}
/>
)}
<AppShell.Main
style={{
position: 'relative',
overflow: 'hidden',
minHeight:
currentScreen === 'welcome'
? '100vh'
: `calc(100vh - ${UI.HEADER_HEIGHT}px)`,
}}
>
{currentScreen !== 'welcome' && (
<AppHeader
currentScreen={currentScreen}
activeInterfaceTab={activeInterfaceTab}
setActiveInterfaceTab={setActiveInterfaceTab}
isImageGenerationMode={isImageGenerationMode}
onEject={handleEject}
onSettingsOpen={() => setSettingsOpened(true)}
{currentScreen === null ? (
<Center h="100%" style={{ minHeight: '25rem' }}>
<Stack align="center" gap="lg">
<Loader size="xl" type="dots" />
<Text c="dimmed" size="lg">
Loading...
</Text>
</Stack>
</Center>
) : (
<>
<ScreenTransition
isActive={currentScreen === 'welcome'}
shouldAnimate={hasInitialized}
>
<WelcomeScreen onGetStarted={handleWelcomeComplete} />
</ScreenTransition>
<ScreenTransition
isActive={currentScreen === 'download'}
shouldAnimate={hasInitialized}
>
<DownloadScreen onDownloadComplete={handleDownloadComplete} />
</ScreenTransition>
<ScreenTransition
isActive={currentScreen === 'launch'}
shouldAnimate={hasInitialized}
>
<LaunchScreen
onLaunch={handleLaunch}
onLaunchModeChange={setIsImageGenerationMode}
/>
</ScreenTransition>
<ScreenTransition
isActive={currentScreen === 'interface'}
shouldAnimate={hasInitialized}
>
<InterfaceScreen
activeTab={activeInterfaceTab}
onTabChange={setActiveInterfaceTab}
isImageGenerationMode={isImageGenerationMode}
/>
</ScreenTransition>
</>
)}
{showUpdateModal && binaryUpdateInfo && (
<UpdateAvailableModal
opened={showUpdateModal}
onClose={dismissUpdate}
currentVersion={binaryUpdateInfo.currentVersion}
availableUpdate={binaryUpdateInfo.availableUpdate}
onUpdate={handleBinaryUpdate}
isDownloading={
downloading === binaryUpdateInfo.availableUpdate.name
}
downloadProgress={
downloadProgress[binaryUpdateInfo.availableUpdate.name] || 0
}
/>
)}
<AppShell.Main
style={{
position: 'relative',
overflow: 'hidden',
minHeight:
currentScreen === 'welcome'
? '100vh'
: `calc(100vh - ${UI.HEADER_HEIGHT}px)`,
}}
>
{currentScreen === null ? (
<Center h="100%" style={{ minHeight: '25rem' }}>
<Stack align="center" gap="lg">
<Loader size="xl" type="dots" />
<Text c="dimmed" size="lg">
Loading...
</Text>
</Stack>
</Center>
) : (
<>
<ScreenTransition
isActive={currentScreen === 'welcome'}
shouldAnimate={hasInitialized}
>
<WelcomeScreen onGetStarted={handleWelcomeComplete} />
</ScreenTransition>
<ScreenTransition
isActive={currentScreen === 'download'}
shouldAnimate={hasInitialized}
>
<DownloadScreen onDownloadComplete={handleDownloadComplete} />
</ScreenTransition>
<ScreenTransition
isActive={currentScreen === 'launch'}
shouldAnimate={hasInitialized}
>
<LaunchScreen
onLaunch={handleLaunch}
onLaunchModeChange={setIsImageGenerationMode}
/>
</ScreenTransition>
<ScreenTransition
isActive={currentScreen === 'interface'}
shouldAnimate={hasInitialized}
>
<InterfaceScreen
activeTab={activeInterfaceTab}
onTabChange={setActiveInterfaceTab}
isImageGenerationMode={isImageGenerationMode}
/>
</ScreenTransition>
</>
)}
{showUpdateModal && binaryUpdateInfo && (
<UpdateAvailableModal
opened={showUpdateModal}
onClose={dismissUpdate}
currentVersion={binaryUpdateInfo.currentVersion}
availableUpdate={binaryUpdateInfo.availableUpdate}
onUpdate={handleBinaryUpdate}
isDownloading={
downloading === binaryUpdateInfo.availableUpdate.name
}
downloadProgress={
downloadProgress[binaryUpdateInfo.availableUpdate.name] || 0
}
/>
)}
</AppShell.Main>
<SettingsModal
opened={settingsOpened}
onClose={() => setSettingsOpened(false)}
currentScreen={currentScreen || undefined}
/>
<EjectConfirmModal
opened={showEjectModal}
onClose={() => setShowEjectModal(false)}
onConfirm={handleEjectConfirm}
/>
</AppShell>
</>
</AppShell.Main>
<SettingsModal
opened={settingsOpened}
onClose={async () => {
setSettingsOpened(false);
try {
const preference = (await window.electronAPI.config.get(
'frontendPreference'
)) as FrontendPreference;
setFrontendPreference(preference || 'koboldcpp');
} catch (error) {
window.electronAPI.logs.logError(
'Failed to load frontend preference:',
error as Error
);
}
}}
currentScreen={currentScreen || undefined}
/>
<EjectConfirmModal
opened={showEjectModal}
onClose={() => setShowEjectModal(false)}
onConfirm={handleEjectConfirm}
/>
</AppShell>
);
};

View file

@ -12,16 +12,16 @@ import {
} from '@mantine/core';
import { Settings, ArrowLeft } from 'lucide-react';
import { soundAssets, playSound, initializeAudio } from '@/utils';
import type { InterfaceTab } from '@/types';
import iconUrl from '/icon.png';
type Screen = 'welcome' | 'download' | 'launch' | 'interface';
import { FRONTENDS } from '@/constants';
import type { InterfaceTab, FrontendPreference, Screen } from '@/types';
interface AppHeaderProps {
currentScreen: Screen | null;
activeInterfaceTab: InterfaceTab;
setActiveInterfaceTab: (tab: InterfaceTab) => void;
isImageGenerationMode: boolean;
frontendPreference: FrontendPreference;
onEject: () => void;
onSettingsOpen: () => void;
}
@ -31,6 +31,7 @@ export const AppHeader = ({
activeInterfaceTab,
setActiveInterfaceTab,
isImageGenerationMode,
frontendPreference,
onEject,
onSettingsOpen,
}: AppHeaderProps) => {
@ -117,7 +118,12 @@ export const AppHeader = ({
data={[
{
value: 'chat',
label: isImageGenerationMode ? 'Stable UI' : 'KoboldAI Lite',
label:
frontendPreference === 'sillytavern'
? FRONTENDS.SILLYTAVERN
: isImageGenerationMode
? FRONTENDS.STABLE_UI
: FRONTENDS.KOBOLDAI_LITE,
},
{ value: 'terminal', label: 'Terminal' },
]}

View file

@ -1,18 +1,20 @@
import { useRef } from 'react';
import { Box, Text, Stack } from '@mantine/core';
import { UI } from '@/constants';
import type { ServerTabMode } from '@/types';
import { UI, SILLYTAVERN, FRONTENDS } from '@/constants';
import type { ServerTabMode, FrontendPreference } from '@/types';
interface ServerTabProps {
serverUrl?: string;
isServerReady?: boolean;
mode: ServerTabMode;
frontendPreference?: FrontendPreference;
}
export const ServerTab = ({
serverUrl,
isServerReady,
mode,
frontendPreference = 'koboldcpp',
}: ServerTabProps) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
@ -29,7 +31,7 @@ export const ServerTab = ({
>
<Stack align="center" gap="md">
<Text c="dimmed" size="lg">
Waiting for KoboldCpp server to start...
Waiting for the KoboldCpp server to start...
</Text>
<Text c="dimmed" size="sm">
The {mode === 'chat' ? 'chat' : 'image generation'} interface will
@ -40,12 +42,19 @@ export const ServerTab = ({
);
}
const iframeUrl =
mode === 'image-generation' ? `${serverUrl}/sdui` : serverUrl;
const title =
mode === 'image-generation'
? 'Stable UI Interface'
: 'KoboldAI Lite Interface';
let iframeUrl: string;
let title: string;
if (frontendPreference === 'sillytavern') {
iframeUrl = SILLYTAVERN.PROXY_URL;
title = FRONTENDS.SILLYTAVERN;
} else {
iframeUrl = mode === 'image-generation' ? `${serverUrl}/sdui` : serverUrl;
title =
mode === 'image-generation'
? FRONTENDS.STABLE_UI
: FRONTENDS.KOBOLDAI_LITE;
}
return (
<Box

View file

@ -9,13 +9,15 @@ import {
import { ChevronDown } from 'lucide-react';
import styles from '@/styles/layout.module.css';
import { UI } from '@/constants';
import { handleTerminalOutput } from '@/utils';
import { handleTerminalOutput, processTerminalContent } from '@/utils';
import { useLaunchConfigStore } from '@/stores/launchConfigStore';
interface TerminalTabProps {
onServerReady?: (serverUrl: string) => void;
onServerReady: (url: string) => void;
}
export const TerminalTab = ({ onServerReady }: TerminalTabProps) => {
const { host, port } = useLaunchConfigStore();
const computedColorScheme = useComputedColorScheme('light', {
getInitialValueInEffect: false,
});
@ -57,16 +59,19 @@ export const TerminalTab = ({ onServerReady }: TerminalTabProps) => {
setTerminalContent((prev) => {
const newData = data.toString();
if (
onServerReady &&
newData.includes('Please connect to custom endpoint at ')
) {
const match = newData.match(
/Please connect to custom endpoint at (http:\/\/[^\s]+)/
);
if (match) {
const serverUrl = match[1];
setTimeout(() => onServerReady(serverUrl), 1500);
if (onServerReady) {
if (
newData.includes('Please connect to custom endpoint at') ||
newData.includes(
'Now running KoboldCpp in Interactive Terminal Chat mode'
)
) {
const serverHost = host || 'localhost';
const serverPort = port || 5001;
setTimeout(
() => onServerReady(`http://${serverHost}:${serverPort}`),
1500
);
}
}
@ -75,7 +80,7 @@ export const TerminalTab = ({ onServerReady }: TerminalTabProps) => {
});
return cleanup;
}, [onServerReady]);
}, [onServerReady, host, port]);
const scrollToBottom = () => {
if (viewportRef.current) {
@ -126,9 +131,10 @@ export const TerminalTab = ({ onServerReady }: TerminalTabProps) => {
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{terminalContent}
</div>
dangerouslySetInnerHTML={{
__html: processTerminalContent(terminalContent),
}}
/>
)}
</Box>
</ScrollArea>

View file

@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { ServerTab } from '@/components/screens/Interface/ServerTab';
import { TerminalTab } from '@/components/screens/Interface/TerminalTab';
import type { InterfaceTab } from '@/types';
import type { InterfaceTab, FrontendPreference } from '@/types';
interface InterfaceScreenProps {
activeTab?: InterfaceTab | null;
@ -16,6 +16,26 @@ export const InterfaceScreen = ({
}: InterfaceScreenProps) => {
const [serverUrl, setServerUrl] = useState<string>('');
const [isServerReady, setIsServerReady] = useState<boolean>(false);
const [frontendPreference, setFrontendPreference] =
useState<FrontendPreference>('koboldcpp');
useEffect(() => {
const loadFrontendPreference = async () => {
try {
const frontendPreference = (await window.electronAPI.config.get(
'frontendPreference'
)) as FrontendPreference;
setFrontendPreference(frontendPreference || 'koboldcpp');
} catch (error) {
window.electronAPI.logs.logError(
'Failed to load frontend preference:',
error as Error
);
}
};
loadFrontendPreference();
}, []);
const handleServerReady = useCallback(
(url: string) => {
@ -47,6 +67,7 @@ export const InterfaceScreen = ({
serverUrl={serverUrl}
isServerReady={isServerReady}
mode={isImageGenerationMode ? 'image-generation' : 'chat'}
frontendPreference={frontendPreference}
/>
</div>
<div

View file

@ -1,10 +1,7 @@
import { Text, Group, Badge } from '@mantine/core';
import type { BackendOption } from '@/types';
interface BackendSelectItemProps {
label: string;
devices?: string[];
disabled?: boolean;
}
type BackendSelectItemProps = Omit<BackendOption, 'value'>;
export const BackendSelectItem = ({
label,

View file

@ -4,6 +4,7 @@ import { InfoTooltip } from '@/components/InfoTooltip';
import { BackendSelectItem } from '@/components/screens/Launch/GeneralTab/BackendSelectItem';
import { GpuDeviceSelector } from '@/components/screens/Launch/GeneralTab/GpuDeviceSelector';
import { useLaunchConfig } from '@/hooks/useLaunchConfig';
import type { BackendOption } from '@/types';
export const BackendSelector = () => {
const {
@ -15,88 +16,16 @@ export const BackendSelector = () => {
handleAutoGpuLayersChange,
} = useLaunchConfig();
const [availableBackends, setAvailableBackends] = useState<
Array<{
value: string;
label: string;
devices?: string[];
disabled?: boolean;
}>
>([]);
const [availableBackends, setAvailableBackends] = useState<BackendOption[]>(
[]
);
const hasInitialized = useRef(false);
useEffect(() => {
const loadBackends = async () => {
try {
const [cpuCapabilitiesResult, binarySupport, gpuCapabilities] =
await Promise.all([
window.electronAPI.kobold.detectCPU(),
window.electronAPI.kobold.detectBackendSupport(),
window.electronAPI.kobold.detectGPUCapabilities(),
]);
if (!binarySupport) {
setAvailableBackends([]);
hasInitialized.current = true;
return;
}
const backends: Array<{
value: string;
label: string;
devices?: string[];
disabled?: boolean;
}> = [];
if (binarySupport.cuda) {
backends.push({
value: 'cuda',
label: 'CUDA',
devices: gpuCapabilities.cuda.devices,
disabled: !gpuCapabilities.cuda.supported,
});
}
if (binarySupport.rocm) {
backends.push({
value: 'rocm',
label: 'ROCm',
devices: gpuCapabilities.rocm.devices,
disabled: !gpuCapabilities.rocm.supported,
});
}
if (binarySupport.vulkan) {
backends.push({
value: 'vulkan',
label: 'Vulkan',
devices: gpuCapabilities.vulkan.devices,
disabled: !gpuCapabilities.vulkan.supported,
});
}
if (binarySupport.clblast) {
backends.push({
value: 'clblast',
label: 'CLBlast',
devices: gpuCapabilities.clblast.devices,
disabled: !gpuCapabilities.clblast.supported,
});
}
backends.push({
value: 'cpu',
label: 'CPU',
devices: cpuCapabilitiesResult.devices,
disabled: false,
});
backends.sort((a, b) => {
if (a.disabled === b.disabled) return 0;
return a.disabled ? 1 : -1;
});
const backends =
await window.electronAPI.kobold.getAvailableBackends(true);
setAvailableBackends(backends);
hasInitialized.current = true;
} catch (error) {

View file

@ -1,13 +1,10 @@
import { Text, Group, Select, TextInput } from '@mantine/core';
import { InfoTooltip } from '@/components/InfoTooltip';
import { useLaunchConfig } from '@/hooks/useLaunchConfig';
import type { BackendOption } from '@/types';
interface GpuDeviceSelectorProps {
availableBackends: Array<{
value: string;
label: string;
devices?: string[];
}>;
availableBackends: BackendOption[];
}
export const GpuDeviceSelector = ({

View file

@ -1,5 +1,12 @@
import { Stack, Text, Group, SegmentedControl, rem } from '@mantine/core';
import { useMantineColorScheme, useComputedColorScheme } from '@mantine/core';
import {
Stack,
Text,
Group,
SegmentedControl,
rem,
useMantineColorScheme,
useComputedColorScheme,
} from '@mantine/core';
import { Sun, Moon, Monitor } from 'lucide-react';
export const AppearanceTab = () => {

View file

@ -1,13 +1,26 @@
import { useState, useEffect } from 'react';
import { Stack, Text, Group, TextInput, Button, rem } from '@mantine/core';
import { Folder, FolderOpen } from 'lucide-react';
import {
Stack,
Text,
Group,
TextInput,
Button,
rem,
Select,
} from '@mantine/core';
import { Folder, FolderOpen, Monitor } from 'lucide-react';
import styles from '@/styles/layout.module.css';
import type { FrontendPreference } from '@/types';
import { FRONTENDS } from '@/constants';
export const GeneralTab = () => {
const [installDir, setInstallDir] = useState<string>('');
const [FrontendPreference, setFrontendPreference] =
useState<FrontendPreference>('koboldcpp');
useEffect(() => {
loadCurrentInstallDir();
loadFrontendPreference();
}, []);
const loadCurrentInstallDir = async () => {
@ -22,6 +35,20 @@ export const GeneralTab = () => {
}
};
const loadFrontendPreference = async () => {
try {
const frontendPreference = (await window.electronAPI.config.get(
'frontendPreference'
)) as FrontendPreference;
setFrontendPreference(frontendPreference || 'koboldcpp');
} catch (error) {
window.electronAPI.logs.logError(
'Failed to load frontend preference:',
error as Error
);
}
};
const handleSelectInstallDir = async () => {
try {
const selectedDir =
@ -38,6 +65,20 @@ export const GeneralTab = () => {
}
};
const handleFrontendPreferenceChange = async (value: string | null) => {
if (!value || (value !== 'koboldcpp' && value !== 'sillytavern')) return;
try {
await window.electronAPI.config.set('frontendPreference', value);
setFrontendPreference(value);
} catch (error) {
window.electronAPI.logs.logError(
'Failed to save frontend preference:',
error as Error
);
}
};
return (
<Stack gap="lg" h="100%">
<div>
@ -66,6 +107,27 @@ export const GeneralTab = () => {
</Button>
</Group>
</div>
<div>
<Text fw={500} mb="sm">
Frontend Interface
</Text>
<Text size="sm" c="dimmed" mb="md">
Choose which frontend interface to use for interacting with models
</Text>
<Select
value={FrontendPreference}
onChange={handleFrontendPreferenceChange}
data={[
{ value: 'koboldcpp', label: 'KoboldCpp (Built-in)' },
{
value: 'sillytavern',
label: FRONTENDS.SILLYTAVERN,
},
]}
leftSection={<Monitor style={{ width: rem(16), height: rem(16) }} />}
/>
</div>
</Stack>
);
};

View file

@ -4,11 +4,12 @@ import { Settings, Palette, SlidersHorizontal, GitBranch } from 'lucide-react';
import { GeneralTab } from '@/components/settings/GeneralTab';
import { VersionsTab } from '@/components/settings/VersionsTab';
import { AppearanceTab } from '@/components/settings/AppearanceTab';
import type { Screen } from '@/types';
interface SettingsModalProps {
opened: boolean;
onClose: () => void;
currentScreen?: 'welcome' | 'download' | 'launch' | 'interface';
currentScreen?: Screen;
}
export const SettingsModal = ({

View file

@ -2,3 +2,14 @@ export const DEFAULT_CONTEXT_SIZE = 4096;
export const DEFAULT_MODEL_URL =
'https://huggingface.co/MaziyarPanahi/gemma-3-4b-it-GGUF/resolve/main/gemma-3-4b-it.Q8_0.gguf?download=true';
export const SILLYTAVERN = {
PORT: 3000,
PROXY_PORT: 3001,
get URL() {
return `http://localhost:${this.PORT}`;
},
get PROXY_URL() {
return `http://localhost:${this.PROXY_PORT}`;
},
} as const;

View file

@ -1,4 +1,5 @@
export const APP_NAME = 'friendly-kobold';
export const PRODUCT_NAME = 'Friendly Kobold';
export const CONFIG_FILE_NAME = 'config.json';
@ -32,14 +33,14 @@ export const ASSET_SUFFIXES = {
OLDPC: 'oldpc',
} as const;
export const KOBOLDAI_URLS = {
STANDARD_DOWNLOAD: 'https://koboldai.org/cpp',
ROCM_DOWNLOAD: 'https://koboldai.org/cpplinuxrocm',
DOMAIN: 'koboldai.org',
} as const;
export const ROCM = {
BINARY_NAME: 'koboldcpp-linux-x64-rocm',
DOWNLOAD_URL: KOBOLDAI_URLS.ROCM_DOWNLOAD,
DOWNLOAD_URL: 'https://koboldai.org/cpplinuxrocm',
SIZE_BYTES_APPROX: 1024 * 1024 * 1024,
} as const;
export const FRONTENDS = {
KOBOLDAI_LITE: 'KoboldAI Lite',
STABLE_UI: 'Stable UI',
SILLYTAVERN: 'SillyTavern',
} as const;

View file

@ -1,5 +1,4 @@
import { useState, useCallback } from 'react';
import { parseCLBlastDevice } from '@/utils';
import type { SdConvDirectMode } from '@/types';
interface UseLaunchLogicProps {
@ -105,7 +104,7 @@ const buildConfigArgs = (
args.push('--host', launchArgs.host);
}
const flagMappings: Array<[boolean, string, string?]> = [
const flagMappings: [boolean, string, string?][] = [
[launchArgs.multiuser, '--multiuser', '1'],
[launchArgs.multiplayer, '--multiplayer'],
[launchArgs.remotetunnel, '--remotetunnel'],
@ -214,6 +213,20 @@ const buildBackendArgs = (launchArgs: LaunchArgs): string[] => {
return args;
};
function parseCLBlastDevice(deviceString: string): {
deviceIndex: number;
platformIndex: number;
} | null {
const match = deviceString.match(/\[(\d+),(\d+)\]$/);
if (match) {
return {
deviceIndex: parseInt(match[1], 10),
platformIndex: parseInt(match[2], 10),
};
}
return null;
}
export const useLaunchLogic = ({
modelPath,
sdmodel,
@ -233,6 +246,8 @@ export const useLaunchLogic = ({
setIsLaunching(true);
onLaunch();
try {
const args: string[] = [
...buildModelArgs(isImageMode, modelPath, sdmodel, launchArgs),
@ -253,10 +268,6 @@ export const useLaunchLogic = ({
if (onLaunchModeChange) {
onLaunchModeChange(isImageMode);
}
setTimeout(() => {
onLaunch();
}, 100);
} else {
window.electronAPI.logs.logError(
'Launch failed:',

View file

@ -1,4 +1,5 @@
import { useMemo, useEffect, useState, useCallback } from 'react';
import type { BackendOption } from '@/types';
export interface Warning {
type: 'warning' | 'info';
@ -135,7 +136,7 @@ const checkCpuWarnings = (
cpuCapabilities: { avx: boolean; avx2: boolean },
noavx2: boolean,
failsafe: boolean,
availableBackends: Array<{ value: string; label: string; devices?: string[] }>
availableBackends: BackendOption[]
): Warning[] => {
const warnings: Warning[] = [];
@ -181,11 +182,7 @@ const checkBackendWarnings = async (params?: {
} | null;
noavx2: boolean;
failsafe: boolean;
availableBackends: Array<{
value: string;
label: string;
devices?: string[];
}>;
availableBackends: BackendOption[];
}): Promise<Warning[]> => {
const warnings: Warning[] = [];

View file

@ -2,37 +2,28 @@
import { spawn } from 'child_process';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { PRODUCT_NAME, CONFIG_FILE_NAME } from '@/constants';
import { homedir } from 'os';
const CONFIG_FILE_NAME = 'config.json';
export class LightweightCliHandler {
private getConfigPath(): string {
private getConfigDir(appName: string): string {
const platform = process.platform;
const home = homedir();
switch (platform) {
case 'win32':
return join(
home,
'AppData',
'Roaming',
'Friendly Kobold',
CONFIG_FILE_NAME
);
return join(home, 'AppData', 'Roaming', appName);
case 'darwin':
return join(
home,
'Library',
'Application Support',
'Friendly Kobold',
CONFIG_FILE_NAME
);
return join(home, 'Library', 'Application Support', appName);
default:
return join(home, '.config', 'Friendly Kobold', CONFIG_FILE_NAME);
return join(home, '.config', appName);
}
}
private getConfigPath(): string {
return join(this.getConfigDir(PRODUCT_NAME), CONFIG_FILE_NAME);
}
private getCurrentKoboldBinary(): string | null {
try {
const configPath = this.getConfigPath();

View file

@ -1,23 +1,25 @@
import { app } from 'electron';
import { join } from 'path';
import { existsSync, mkdirSync } from 'fs';
import { homedir } from 'os';
import { WindowManager } from '@/main/managers/WindowManager';
import { ConfigManager } from '@/main/managers/ConfigManager';
import { LogManager } from '@/main/managers/LogManager';
import { KoboldCppManager } from '@/main/managers/KoboldCppManager';
import { SillyTavernManager } from '@/main/managers/SillyTavernManager';
import { GitHubService } from '@/main/services/GitHubService';
import { HardwareService } from '@/main/services/HardwareService';
import { BinaryService } from '@/main/services/BinaryService';
import { IPCHandlers } from '@/main/utils/IPCHandlers';
import { APP_NAME, CONFIG_FILE_NAME } from '@/constants';
import { IPCHandlers } from '@/main/ipc';
import { PRODUCT_NAME, CONFIG_FILE_NAME } from '@/constants';
import { homedir } from 'os';
export class FriendlyKoboldApp {
private windowManager: WindowManager;
private configManager: ConfigManager;
private logManager: LogManager;
private koboldManager: KoboldCppManager;
private sillyTavernManager: SillyTavernManager;
private githubService: GitHubService;
private hardwareService: HardwareService;
private binaryService: BinaryService;
@ -49,13 +51,19 @@ export class FriendlyKoboldApp {
this.hardwareService
);
this.sillyTavernManager = new SillyTavernManager(
this.logManager,
this.windowManager
);
this.ipcHandlers = new IPCHandlers(
this.koboldManager,
this.configManager,
this.githubService,
this.hardwareService,
this.binaryService,
this.logManager
this.logManager,
this.sillyTavernManager
);
}
@ -63,23 +71,24 @@ export class FriendlyKoboldApp {
return join(app.getPath('userData'), CONFIG_FILE_NAME);
}
private getDefaultInstallPath() {
private getDefaultInstallDir(appName: string): string {
const platform = process.platform;
const home = homedir();
switch (platform) {
case 'win32':
return join(home, APP_NAME);
return join(home, appName);
case 'darwin':
return join(home, 'Applications', APP_NAME);
return join(home, 'Applications', appName);
default:
return join(home, '.local', 'share', APP_NAME);
return join(home, '.local', 'share', appName);
}
}
private ensureInstallDirectory() {
const installDir =
this.configManager.getInstallDir() || this.getDefaultInstallPath();
this.configManager.getInstallDir() ||
this.getDefaultInstallDir(PRODUCT_NAME);
if (!this.configManager.getInstallDir()) {
this.configManager.setInstallDir(installDir);
@ -113,19 +122,20 @@ export class FriendlyKoboldApp {
event.preventDefault();
try {
const cleanupPromise = this.koboldManager.cleanup();
const cleanupPromises = [
this.koboldManager.cleanup(),
this.sillyTavernManager.cleanup(),
];
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, 10000);
});
await Promise.race([cleanupPromise, timeoutPromise]);
await Promise.race([Promise.all(cleanupPromises), timeoutPromise]);
} catch (error) {
this.logManager.logError(
'Error during KoboldCpp cleanup:',
error as Error
);
this.logManager.logError('Error during cleanup:', error as Error);
}
this.windowManager.cleanup();

View file

@ -1,16 +1,18 @@
import { ipcMain } from 'electron';
import { shell, app } from 'electron';
import { ipcMain, shell, app } from 'electron';
import { KoboldCppManager } from '@/main/managers/KoboldCppManager';
import { ConfigManager } from '@/main/managers/ConfigManager';
import { LogManager } from '@/main/managers/LogManager';
import { SillyTavernManager } from '@/main/managers/SillyTavernManager';
import { GitHubService } from '@/main/services/GitHubService';
import { HardwareService } from '@/main/services/HardwareService';
import { BinaryService } from '@/main/services/BinaryService';
import type { FrontendPreference } from '@/types';
export class IPCHandlers {
private koboldManager: KoboldCppManager;
private configManager: ConfigManager;
private logManager: LogManager;
private sillyTavernManager: SillyTavernManager;
private githubService: GitHubService;
private hardwareService: HardwareService;
private binaryService: BinaryService;
@ -21,16 +23,51 @@ export class IPCHandlers {
githubService: GitHubService,
hardwareService: HardwareService,
binaryService: BinaryService,
logManager: LogManager
logManager: LogManager,
sillyTavernManager: SillyTavernManager
) {
this.koboldManager = koboldManager;
this.configManager = configManager;
this.logManager = logManager;
this.sillyTavernManager = sillyTavernManager;
this.githubService = githubService;
this.hardwareService = hardwareService;
this.binaryService = binaryService;
}
private async launchKoboldCppWithCustomFrontends(
args: string[] = []
): Promise<{
success: boolean;
pid?: number;
error?: string;
}> {
try {
const frontendPreference = (await this.configManager.get(
'frontendPreference'
)) as FrontendPreference | undefined;
if (frontendPreference === 'sillytavern') {
try {
await this.sillyTavernManager.startFrontend(args);
} catch (error) {
this.logManager.logError(
'Failed to setup SillyTavern text frontend:',
error as Error
);
}
}
return await this.koboldManager.launchKoboldCpp(args);
} catch (error) {
this.logManager.logError('Error in enhanced launch:', error as Error);
return {
success: false,
error: (error as Error).message,
};
}
}
setupHandlers() {
ipcMain.handle('kobold:getLatestRelease', () =>
this.githubService.getLatestRelease()
@ -128,8 +165,10 @@ export class IPCHandlers {
this.binaryService.detectBackendSupport()
);
ipcMain.handle('kobold:getAvailableBackends', () =>
this.binaryService.getAvailableBackends()
ipcMain.handle(
'kobold:getAvailableBackends',
(_event, includeDisabled = false) =>
this.binaryService.getAvailableBackends(includeDisabled)
);
ipcMain.handle('kobold:getPlatform', () => ({
@ -170,7 +209,7 @@ export class IPCHandlers {
);
ipcMain.handle('kobold:launchKoboldCpp', (_event, args) =>
this.koboldManager.launchKoboldCpp(args)
this.launchKoboldCppWithCustomFrontends(args)
);
ipcMain.handle('kobold:stopKoboldCpp', () =>

View file

@ -1,5 +1,6 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { LogManager } from '@/main/managers/LogManager';
import type { FrontendPreference } from '@/types';
type ConfigValue = string | number | boolean | unknown[] | undefined;
@ -7,6 +8,7 @@ interface AppConfig {
installDir?: string;
currentKoboldBinary?: string;
selectedConfig?: string;
frontendPreference?: FrontendPreference;
[key: string]: ConfigValue;
}

View file

@ -1,5 +1,5 @@
/* eslint-disable no-comments/disallowComments */
import { spawn, ChildProcess } from 'child_process';
import { spawn, ChildProcess, exec as execCmd } from 'child_process';
import { join } from 'path';
import {
existsSync,
@ -20,50 +20,16 @@ import { GitHubService } from '@/main/services/GitHubService';
import { ConfigManager } from '@/main/managers/ConfigManager';
import { LogManager } from '@/main/managers/LogManager';
import { WindowManager } from '@/main/managers/WindowManager';
import { ROCM } from '@/constants';
import { ROCM, PRODUCT_NAME } from '@/constants';
import { stripAssetExtensions } from '@/utils/versionUtils';
import { compareVersions } from '@/utils';
import type { DownloadItem } from '@/types/electron';
interface GitHubAsset {
name: string;
browser_download_url: string;
size: number;
created_at: string;
isUpdate?: boolean;
wasCurrentBinary?: boolean;
}
interface GitHubRelease {
tag_name: string;
name: string;
published_at: string;
body: string;
assets: GitHubAsset[];
}
interface UpdateInfo {
currentVersion: string;
latestVersion: string;
releaseInfo: GitHubRelease;
hasUpdate: boolean;
}
interface ReleaseWithStatus {
release: GitHubRelease;
availableAssets: Array<{
asset: GitHubAsset;
isDownloaded: boolean;
installedVersion?: string;
}>;
}
export interface InstalledVersion {
version: string;
path: string;
filename: string;
size?: number;
}
import type {
DownloadItem,
GitHubAsset,
UpdateInfo,
ReleaseWithStatus,
InstalledVersion,
} from '@/types/electron';
export class KoboldCppManager {
private installDir: string;
@ -155,10 +121,7 @@ export class KoboldCppManager {
this.configManager.setCurrentKoboldBinary(launcherPath);
}
const mainWindow = this.windowManager.getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('versions-updated');
}
this.windowManager.sendToRenderer('versions-updated');
return launcherPath;
} else {
@ -212,8 +175,7 @@ export class KoboldCppManager {
}
const items = readdirSync(this.installDir);
const launchers: Array<{ path: string; filename: string; size: number }> =
[];
const launchers: { path: string; filename: string; size: number }[] = [];
for (const item of items) {
const itemPath = join(this.installDir, item);
@ -269,9 +231,9 @@ export class KoboldCppManager {
}
async getConfigFiles(): Promise<
Array<{ name: string; path: string; size: number }>
{ name: string; path: string; size: number }[]
> {
const configFiles: Array<{ name: string; path: string; size: number }> = [];
const configFiles: { name: string; path: string; size: number }[] = [];
try {
if (existsSync(this.installDir)) {
@ -452,10 +414,7 @@ export class KoboldCppManager {
if (existsSync(binaryPath)) {
this.configManager.setCurrentKoboldBinary(binaryPath);
const mainWindow = this.windowManager.getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('versions-updated');
}
this.windowManager.sendToRenderer('versions-updated');
return true;
}
@ -511,7 +470,7 @@ export class KoboldCppManager {
async selectInstallDirectory(): Promise<string | null> {
const result = await dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
title: 'Select the Friendly Kobold Installation Directory',
title: `Select the ${PRODUCT_NAME} Installation Directory`,
defaultPath: this.installDir,
buttonLabel: 'Select Directory',
});
@ -520,10 +479,10 @@ export class KoboldCppManager {
this.installDir = result.filePaths[0];
this.configManager.setInstallDir(result.filePaths[0]);
const mainWindow = this.windowManager.getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('install-dir-changed', result.filePaths[0]);
}
this.windowManager.sendToRenderer(
'install-dir-changed',
result.filePaths[0]
);
return result.filePaths[0];
}
@ -715,10 +674,7 @@ export class KoboldCppManager {
this.configManager.setCurrentKoboldBinary(launcherPath);
}
const mainWindow = this.windowManager.getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('versions-updated');
}
this.windowManager.sendToRenderer('versions-updated');
return {
success: true,
@ -846,59 +802,45 @@ export class KoboldCppManager {
this.koboldProcess = child;
const mainWindow = this.windowManager.getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
const commandLine = `$ ${currentVersion.path} ${finalArgs.join(' ')}\n${'─'.repeat(60)}\n`;
const commandLine = `$ ${currentVersion.path} ${finalArgs.join(' ')}`;
setTimeout(() => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('kobold-output', commandLine);
}
}, 200);
setTimeout(() => {
this.windowManager.sendKoboldOutput(commandLine);
}, 200);
child.stdout?.on('data', (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
const output = data.toString();
mainWindow.webContents.send('kobold-output', output);
}
});
child.stdout?.on('data', (data) => {
const output = data.toString();
this.windowManager.sendKoboldOutput(output, true);
});
child.stderr?.on('data', (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
const output = data.toString();
mainWindow.webContents.send('kobold-output', output);
}
});
child.stderr?.on('data', (data) => {
const output = data.toString();
this.windowManager.sendKoboldOutput(output, true);
});
child.on('exit', (code, signal) => {
if (mainWindow && !mainWindow.isDestroyed()) {
const displayMessage = signal
? `\n[INFO] Process terminated with signal ${signal}\n`
: code === 0
? `\n[INFO] Process exited successfully\n`
: code && (code > 1 || code < 0)
? `\n[ERROR] Process exited with code ${code}\n`
: `\n[INFO] Process exited with code ${code}\n`;
mainWindow.webContents.send('kobold-output', displayMessage);
}
this.koboldProcess = null;
});
child.on('exit', (code, signal) => {
const displayMessage = signal
? `\n[INFO] Process terminated with signal ${signal}`
: code === 0
? `\n[INFO] Process exited successfully`
: code && (code > 1 || code < 0)
? `\n[ERROR] Process exited with code ${code}`
: `\n[INFO] Process exited with code ${code}`;
this.windowManager.sendKoboldOutput(displayMessage);
this.koboldProcess = null;
});
child.on('error', (error) => {
this.logManager.logError(
`KoboldCpp process error: ${error.message}`,
error
);
child.on('error', (error) => {
this.logManager.logError(
`KoboldCpp process error: ${error.message}`,
error
);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(
'kobold-output',
`\n[ERROR] Process error: ${error.message}\n`
);
}
this.koboldProcess = null;
});
}
this.windowManager.sendKoboldOutput(
`\n[ERROR] Process error: ${error.message}\n`
);
this.koboldProcess = null;
});
return { success: true, pid: child.pid };
} catch (error) {
@ -967,7 +909,6 @@ export class KoboldCppManager {
setTimeout(() => {
if (this.koboldProcess && !this.koboldProcess.killed) {
const { exec: execCmd } = require('child_process');
execCmd(
`taskkill /pid ${pid} /t /f`,
(error: Error | null) => {

View file

@ -47,7 +47,8 @@ export class LogManager {
}
public logDebug(message: string) {
this.logger.debug(message);
// eslint-disable-next-line no-console
console.log(message);
}
public setupGlobalErrorHandlers() {

View file

@ -0,0 +1,452 @@
import { spawn } from 'child_process';
import { createServer, request, type Server } from 'http';
import { app } from 'electron';
import { homedir } from 'os';
import { join } from 'path';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import type { ChildProcess } from 'child_process';
import { LogManager } from './LogManager';
import { WindowManager } from './WindowManager';
import { SILLYTAVERN } from '@/constants';
import { getPlatformPathFromWindowsPath } from '@/utils/server';
export interface SillyTavernConfig {
name: string;
port: number;
proxyPort?: number;
}
export class SillyTavernManager {
private sillyTavernProcess: ChildProcess | null = null;
private proxyServer: Server | null = null;
private logManager: LogManager;
private windowManager: WindowManager;
constructor(logManager: LogManager, windowManager: WindowManager) {
this.logManager = logManager;
this.windowManager = windowManager;
process.on('SIGINT', () => {
this.cleanup().catch(() => {
void 0;
});
});
process.on('SIGTERM', () => {
this.cleanup().catch(() => {
void 0;
});
});
}
private getSillyTavernSettingsPath(): string {
const platform = process.platform;
const home = homedir();
switch (platform) {
case 'win32':
return join(
home,
'AppData',
'Roaming',
'SillyTavern',
'data',
'default-user',
'settings.json'
);
case 'darwin':
return join(
home,
'Library',
'Application Support',
'SillyTavern',
'data',
'default-user',
'settings.json'
);
case 'linux':
default:
return join(
home,
'.local',
'share',
'SillyTavern',
'data',
'default-user',
'settings.json'
);
}
}
private async ensureSillyTavernSettings(): Promise<void> {
const settingsPath = this.getSillyTavernSettingsPath();
if (existsSync(settingsPath)) {
this.windowManager.sendKoboldOutput('SillyTavern settings found');
return;
}
this.windowManager.sendKoboldOutput(
'SillyTavern settings not found, starting SillyTavern briefly to generate config...'
);
const bundledNpxPath = getPlatformPathFromWindowsPath(
app.getAppPath(),
'npx.cmd'
);
this.windowManager.sendKoboldOutput(`Using bundled npx: ${bundledNpxPath}`);
return new Promise((resolve, reject) => {
const initProcess = spawn(
bundledNpxPath,
['sillytavern', '--browserLaunchEnabled', 'false'],
{
stdio: ['pipe', 'pipe', 'pipe'],
detached: false,
}
);
let hasResolved = false;
const cleanupAndResolve = () => {
if (!hasResolved) {
hasResolved = true;
this.windowManager.sendKoboldOutput(
'SillyTavern settings should now be generated'
);
resolve();
}
};
const timeout = setTimeout(() => {
if (!initProcess.killed) {
initProcess.kill('SIGTERM');
}
cleanupAndResolve();
}, 20000);
initProcess.on('exit', () => {
clearTimeout(timeout);
cleanupAndResolve();
});
initProcess.on('error', (error) => {
clearTimeout(timeout);
if (!hasResolved) {
hasResolved = true;
this.logManager.logError(
'Failed to initialize SillyTavern settings:',
error
);
reject(error);
}
});
if (initProcess.stdout) {
initProcess.stdout.on('data', (data: Buffer) => {
const output = data.toString();
if (output.includes('SillyTavern is listening')) {
setTimeout(() => {
if (!initProcess.killed && !hasResolved) {
initProcess.kill('SIGTERM');
cleanupAndResolve();
}
}, 1000);
}
});
}
setTimeout(() => {
if (!initProcess.killed && !hasResolved) {
this.windowManager.sendKoboldOutput(
'SillyTavern initialization taking longer than expected, proceeding...'
);
initProcess.kill('SIGTERM');
cleanupAndResolve();
}
}, 8000);
});
}
private parseKoboldConfig(args: string[]): { host: string; port: number } {
let host = 'localhost';
let port = 5001;
for (let i = 0; i < args.length - 1; i++) {
if (args[i] === '--hostname' || args[i] === '--host') {
host = args[i + 1];
} else if (args[i] === '--port') {
const parsedPort = parseInt(args[i + 1], 10);
if (!isNaN(parsedPort)) {
port = parsedPort;
}
}
}
return { host, port };
}
private async setupSillyTavernConfig(
koboldHost: string,
koboldPort: number
): Promise<void> {
try {
const configPath = this.getSillyTavernSettingsPath();
this.windowManager.sendKoboldOutput(
`Configuring SillyTavern settings at: ${configPath}`
);
let settings: Record<string, unknown> = {};
if (existsSync(configPath)) {
try {
const content = readFileSync(configPath, 'utf-8');
settings = JSON.parse(content) as Record<string, unknown>;
this.windowManager.sendKoboldOutput(
`Loaded existing SillyTavern settings`
);
} catch {
this.windowManager.sendKoboldOutput(
`Could not read existing settings, creating new ones`
);
}
}
const koboldUrl = `http://${koboldHost}:${koboldPort}`;
const koboldApiUrl = `${koboldUrl}/api`;
if (!settings.power_user) settings.power_user = {};
const powerUser = settings.power_user as Record<string, unknown>;
if (!settings.textgenerationwebui_settings)
settings.textgenerationwebui_settings = {};
const textgenSettings = settings.textgenerationwebui_settings as Record<
string,
unknown
>;
if (!textgenSettings.server_urls) textgenSettings.server_urls = {};
const serverUrls = textgenSettings.server_urls as Record<string, unknown>;
serverUrls.koboldcpp = koboldUrl;
settings.main_api = 'textgenerationwebui';
textgenSettings.type = 'koboldcpp';
powerUser.auto_connect = true;
writeFileSync(configPath, JSON.stringify(settings, null, 2), 'utf-8');
this.windowManager.sendKoboldOutput(
`SillyTavern configuration updated successfully!`
);
this.windowManager.sendKoboldOutput(
`KoboldCpp will auto-connect to: ${koboldApiUrl}`
);
} catch (error) {
this.logManager.logError(
'Failed to setup SillyTavern config:',
error as Error
);
this.windowManager.sendKoboldOutput(
`Failed to configure SillyTavern: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private createProxyServer(targetPort: number, proxyPort: number): void {
this.proxyServer = createServer((req, res) => {
const options = {
hostname: 'localhost',
port: targetPort,
path: req.url,
method: req.method,
headers: req.headers,
};
const proxyReq = request(options, (proxyRes) => {
const headers = { ...proxyRes.headers };
delete headers['x-frame-options'];
res.writeHead(proxyRes.statusCode || 200, headers);
proxyRes.pipe(res);
});
proxyReq.on('error', (err) => {
this.logManager.logError('Proxy request error:', err);
res.writeHead(500);
res.end('Proxy error');
});
req.pipe(proxyReq);
});
this.proxyServer.listen(proxyPort, () => {
this.windowManager.sendKoboldOutput(
`Proxy server started on port ${proxyPort}, forwarding to SillyTavern on port ${targetPort}`
);
});
this.proxyServer.on('error', (err) => {
this.logManager.logError('Proxy server error:', err);
});
}
getDefaultSillyTavernConfig(): SillyTavernConfig {
return {
name: 'sillytavern',
port: SILLYTAVERN.PORT,
proxyPort: SILLYTAVERN.PROXY_PORT,
};
}
async startFrontend(args: string[]): Promise<void> {
try {
const config = this.getDefaultSillyTavernConfig();
const { host: koboldHost, port: koboldPort } =
this.parseKoboldConfig(args);
await this.stopFrontend();
this.windowManager.sendKoboldOutput(
`Preparing SillyTavern to connect to KoboldCpp at ${koboldHost}:${koboldPort}...`
);
await this.ensureSillyTavernSettings();
await this.setupSillyTavernConfig(koboldHost, koboldPort);
this.windowManager.sendKoboldOutput(
`Starting ${config.name} frontend on port ${config.port}...`
);
const sillyTavernArgs = [
'sillytavern',
'--port',
config.port.toString(),
'--listen',
'--corsProxy',
'--securityOverride',
'--disableCsrf',
'--browserLaunchEnabled',
'false',
];
const bundledNpxPath = getPlatformPathFromWindowsPath(
app.getAppPath(),
'npx.cmd'
);
this.windowManager.sendKoboldOutput(
`Using bundled npx: ${bundledNpxPath}`
);
this.sillyTavernProcess = spawn(bundledNpxPath, sillyTavernArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
detached: false,
});
if (this.sillyTavernProcess.stdout) {
this.sillyTavernProcess.stdout.on('data', (data: Buffer) => {
const output = data.toString();
this.windowManager.sendKoboldOutput(output, true);
});
}
if (this.sillyTavernProcess.stderr) {
this.sillyTavernProcess.stderr.on('data', (data: Buffer) => {
const output = data.toString();
this.windowManager.sendKoboldOutput(output, true);
});
}
this.sillyTavernProcess.on(
'exit',
(code: number | null, signal: string | null) => {
const message = signal
? `SillyTavern terminated with signal ${signal}`
: `SillyTavern exited with code ${code}`;
this.windowManager.sendKoboldOutput(message);
this.sillyTavernProcess = null;
}
);
this.sillyTavernProcess.on('error', (error) => {
this.logManager.logError('SillyTavern process error:', error);
this.windowManager.sendKoboldOutput(
`SillyTavern error: ${error.message}`
);
this.sillyTavernProcess = null;
});
if (config.proxyPort) {
this.createProxyServer(config.port, config.proxyPort);
this.windowManager.sendKoboldOutput(
`SillyTavern proxy starting at http://localhost:${config.proxyPort} (forwarding to port ${config.port})`
);
} else {
this.windowManager.sendKoboldOutput(
`SillyTavern starting at http://localhost:${config.port}`
);
}
} catch (error) {
this.logManager.logError(
`Failed to start SillyTavern: ${error instanceof Error ? error.message : String(error)}`,
error as Error
);
throw error;
}
}
async stopFrontend(): Promise<void> {
const promises: Promise<void>[] = [];
if (this.sillyTavernProcess) {
promises.push(
new Promise((resolve) => {
const timeout = setTimeout(() => {
this.logManager.logError(
'SillyTavern did not close within timeout',
new Error('Process close timeout')
);
this.sillyTavernProcess = null;
resolve();
}, 5000);
this.sillyTavernProcess?.kill('SIGTERM');
this.sillyTavernProcess?.once('exit', () => {
clearTimeout(timeout);
this.sillyTavernProcess = null;
resolve();
});
})
);
}
if (this.proxyServer) {
promises.push(
new Promise((resolve) => {
this.proxyServer?.close(() => {
this.proxyServer = null;
resolve();
});
})
);
}
await Promise.all(promises);
}
async cleanup(): Promise<void> {
if (this.sillyTavernProcess) {
try {
await this.stopFrontend();
} catch (error) {
this.logManager.logError(
'Error during SillyTavernManager cleanup:',
error as Error
);
}
}
}
}

View file

@ -1,7 +1,10 @@
import { BrowserWindow, app, Menu, shell, nativeImage } from 'electron';
import * as os from 'os';
import { join } from 'path';
import { GITHUB_API } from '../../constants';
import { readFileSync } from 'fs';
import { stripVTControlCharacters } from 'util';
import { GITHUB_API, PRODUCT_NAME } from '../../constants';
import type { IPCChannel, IPCChannelPayloads } from '@/types/ipc';
export class WindowManager {
private mainWindow: BrowserWindow | null = null;
@ -25,7 +28,7 @@ export class WindowManager {
width: 1000,
height: 600,
icon: iconImage,
title: 'Friendly Kobold',
title: PRODUCT_NAME,
show: false,
backgroundColor: '#ffffff',
webPreferences: {
@ -119,6 +122,23 @@ export class WindowManager {
return this.mainWindow;
}
sendToRenderer<T extends IPCChannel>(
channel: T,
...args: IPCChannelPayloads[T]
): void {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send(channel, ...args);
}
}
sendKoboldOutput(message: string, raw?: boolean): void {
const cleanMessage = stripVTControlCharacters(message);
this.sendToRenderer(
'kobold-output',
raw ? cleanMessage : `${cleanMessage}\n`
);
}
public cleanup() {
if (this.mainWindow) {
this.mainWindow.removeAllListeners();
@ -280,7 +300,7 @@ export class WindowManager {
private async showAboutDialog() {
const packagePath = join(app.getAppPath(), 'package.json');
const packageInfo = require(packagePath);
const packageInfo = JSON.parse(readFileSync(packagePath, 'utf8'));
const electronVersion = process.versions.electron;
const chromeVersion = process.versions.chrome;
const nodeVersion = process.versions.node;

View file

@ -3,6 +3,7 @@ import { join, dirname } from 'path';
import { LogManager } from '@/main/managers/LogManager';
import type { KoboldCppManager } from '@/main/managers/KoboldCppManager';
import type { HardwareService } from '@/main/services/HardwareService';
import type { BackendOption } from '@/types';
export interface BackendSupport {
rocm: boolean;
@ -15,10 +16,7 @@ export interface BackendSupport {
export class BinaryService {
private backendSupportCache = new Map<string, BackendSupport>();
private availableBackendsCache = new Map<
string,
Array<{ value: string; label: string; devices?: string[] }>
>();
private availableBackendsCache = new Map<string, BackendOption[]>();
private logManager: LogManager;
private koboldManager: KoboldCppManager;
private hardwareService: HardwareService;
@ -107,20 +105,25 @@ export class BinaryService {
}
}
async getAvailableBackends(): Promise<
Array<{ value: string; label: string; devices?: string[] }>
> {
// eslint-disable-next-line sonarjs/cognitive-complexity
async getAvailableBackends(
includeDisabled = false
): Promise<BackendOption[]> {
try {
const [currentBinaryInfo, hardwareCapabilities] = await Promise.all([
this.koboldManager.getCurrentBinaryInfo(),
this.hardwareService.detectGPUCapabilities(),
]);
const [currentBinaryInfo, hardwareCapabilities, cpuCapabilities] =
await Promise.all([
this.koboldManager.getCurrentBinaryInfo(),
this.hardwareService.detectGPUCapabilities(),
includeDisabled
? this.hardwareService.detectCPU()
: Promise.resolve(null),
]);
if (!currentBinaryInfo?.path) {
return [{ value: 'cpu', label: 'CPU' }];
}
const cacheKey = `${currentBinaryInfo.path}:${JSON.stringify(hardwareCapabilities)}`;
const cacheKey = `${currentBinaryInfo.path}:${includeDisabled}`;
if (this.availableBackendsCache.has(cacheKey)) {
return this.availableBackendsCache.get(cacheKey)!;
@ -132,49 +135,70 @@ export class BinaryService {
return [];
}
const backends: Array<{
value: string;
label: string;
devices?: string[];
}> = [];
const backends: BackendOption[] = [];
if (backendSupport.cuda && hardwareCapabilities.cuda.supported) {
backends.push({
value: 'cuda',
label: 'CUDA',
devices: hardwareCapabilities.cuda.devices,
});
if (backendSupport.cuda) {
const isSupported = hardwareCapabilities.cuda.supported;
if (isSupported || includeDisabled) {
backends.push({
value: 'cuda',
label: 'CUDA',
devices: hardwareCapabilities.cuda.devices,
disabled: includeDisabled ? !isSupported : undefined,
});
}
}
if (backendSupport.rocm && hardwareCapabilities.rocm.supported) {
backends.push({
value: 'rocm',
label: 'ROCm',
devices: hardwareCapabilities.rocm.devices,
});
if (backendSupport.rocm) {
const isSupported = hardwareCapabilities.rocm.supported;
if (isSupported || includeDisabled) {
backends.push({
value: 'rocm',
label: 'ROCm',
devices: hardwareCapabilities.rocm.devices,
disabled: includeDisabled ? !isSupported : undefined,
});
}
}
if (backendSupport.vulkan && hardwareCapabilities.vulkan.supported) {
backends.push({
value: 'vulkan',
label: 'Vulkan',
devices: hardwareCapabilities.vulkan.devices,
});
if (backendSupport.vulkan) {
const isSupported = hardwareCapabilities.vulkan.supported;
if (isSupported || includeDisabled) {
backends.push({
value: 'vulkan',
label: 'Vulkan',
devices: hardwareCapabilities.vulkan.devices,
disabled: includeDisabled ? !isSupported : undefined,
});
}
}
if (backendSupport.clblast && hardwareCapabilities.clblast.supported) {
backends.push({
value: 'clblast',
label: 'CLBlast',
devices: hardwareCapabilities.clblast.devices,
});
if (backendSupport.clblast) {
const isSupported = hardwareCapabilities.clblast.supported;
if (isSupported || includeDisabled) {
backends.push({
value: 'clblast',
label: 'CLBlast',
devices: hardwareCapabilities.clblast.devices,
disabled: includeDisabled ? !isSupported : undefined,
});
}
}
backends.push({
value: 'cpu',
label: 'CPU',
devices: cpuCapabilities?.devices,
disabled: false,
});
if (includeDisabled) {
backends.sort((a, b) => {
if (a.disabled === b.disabled) return 0;
return a.disabled ? 1 : -1;
});
}
this.availableBackendsCache.set(cacheKey, backends);
return backends;
} catch (error) {

View file

@ -1,6 +1,6 @@
/* eslint-disable no-comments/disallowComments */
import si from 'systeminformation';
import { shortenDeviceName } from '@/utils';
import { shortenDeviceName } from '@/utils/server';
import { LogManager } from '@/main/managers/LogManager';
import type {
CPUCapabilities,

View file

@ -1,132 +0,0 @@
/* eslint-disable no-console */
import { spawn } from 'child_process';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { CONFIG_FILE_NAME } from '@/constants';
export class CliHandler {
private getConfigPath(): string {
const platform = process.platform;
const home = homedir();
switch (platform) {
case 'win32':
return join(
home,
'AppData',
'Roaming',
'Friendly Kobold',
CONFIG_FILE_NAME
);
case 'darwin':
return join(
home,
'Library',
'Application Support',
'Friendly Kobold',
CONFIG_FILE_NAME
);
default:
return join(home, '.config', 'Friendly Kobold', CONFIG_FILE_NAME);
}
}
private getCurrentKoboldBinary(): string | null {
try {
const configPath = this.getConfigPath();
if (!existsSync(configPath)) {
return null;
}
const config = JSON.parse(readFileSync(configPath, 'utf8'));
return config.currentKoboldBinary || null;
} catch {
return null;
}
}
async handleCliMode(args: string[]): Promise<void> {
const currentBinary = this.getCurrentKoboldBinary();
if (!currentBinary) {
console.error(
'Error: No KoboldCpp binary found. Please run the GUI first to download KoboldCpp.'
);
process.exit(1);
}
if (!existsSync(currentBinary)) {
console.error(`Error: KoboldCpp binary not found at: ${currentBinary}`);
console.error('Please run the GUI to download or reconfigure KoboldCpp.');
process.exit(1);
}
return new Promise<void>((resolve, reject) => {
const isWindows = process.platform === 'win32';
const child = spawn(currentBinary, args, {
stdio: isWindows ? 'pipe' : 'inherit',
detached: false,
});
if (isWindows) {
child.stdout?.setEncoding('utf8');
child.stderr?.setEncoding('utf8');
child.stdout?.on('data', (data) => {
process.stdout.write(data.toString());
});
child.stderr?.on('data', (data) => {
process.stderr.write(data.toString());
});
if (child.stdin && process.stdin.readable) {
process.stdin.pipe(child.stdin);
}
}
child.on('exit', (code, signal) => {
if (signal) {
console.log(`\nProcess terminated with signal: ${signal}`);
process.exit(128 + (signal === 'SIGTERM' ? 15 : 2));
} else if (code !== null) {
process.exit(code);
} else {
resolve();
}
});
child.on('error', (error) => {
console.error(`Failed to start KoboldCpp: ${error.message}`);
reject(error);
});
const handleSignal = () => {
console.log('\nReceived termination signal, terminating KoboldCpp...');
if (!child.killed) {
child.kill('SIGTERM');
}
};
process.on('SIGINT', handleSignal);
process.on('SIGTERM', handleSignal);
});
}
static parseArguments(argv: string[]): {
isCliMode: boolean;
args: string[];
} {
const cliIndex = argv.indexOf('--cli');
if (cliIndex === -1) {
return { isCliMode: false, args: [] };
}
const koboldArgs = argv.slice(cliIndex + 1);
return { isCliMode: true, args: koboldArgs };
}
}

View file

@ -19,7 +19,8 @@ const koboldAPI: KoboldAPI = {
detectGPUMemory: () => ipcRenderer.invoke('kobold:detectGPUMemory'),
detectROCm: () => ipcRenderer.invoke('kobold:detectROCm'),
detectBackendSupport: () => ipcRenderer.invoke('kobold:detectBackendSupport'),
getAvailableBackends: () => ipcRenderer.invoke('kobold:getAvailableBackends'),
getAvailableBackends: (includeDisabled?: boolean) =>
ipcRenderer.invoke('kobold:getAvailableBackends', includeDisabled),
getCurrentInstallDir: () => ipcRenderer.invoke('kobold:getCurrentInstallDir'),
selectInstallDirectory: () =>
ipcRenderer.invoke('kobold:selectInstallDirectory'),

View file

@ -1,5 +1,10 @@
@import '@mantine/core/styles.css';
/* TODO: something (mantine?) is setting this sometimes to "none" on the terminal tab */
body {
user-select: auto !important;
}
/* Custom scrollbars */
::-webkit-scrollbar {
width: 0.5rem;

View file

@ -2,12 +2,12 @@ import type {
CPUCapabilities,
GPUCapabilities,
BasicGPUInfo,
HardwareInfo,
PlatformInfo,
GPUMemoryInfo,
} from '@/types/hardware';
import type { BackendOption } from '@/types';
interface GitHubAsset {
export interface GitHubAsset {
name: string;
browser_download_url: string;
size: number;
@ -32,13 +32,13 @@ export interface UpdateInfo {
hasUpdate: boolean;
}
interface ReleaseWithStatus {
export interface ReleaseWithStatus {
release: GitHubRelease;
availableAssets: Array<{
availableAssets: {
asset: GitHubAsset;
isDownloaded: boolean;
installedVersion?: string;
}>;
}[];
}
export interface InstalledVersion {
@ -79,9 +79,7 @@ export interface KoboldAPI {
failsafe: boolean;
cuda: boolean;
} | null>;
getAvailableBackends: () => Promise<
Array<{ value: string; label: string; devices?: string[] }>
>;
getAvailableBackends: (includeDisabled?: boolean) => Promise<BackendOption[]>;
getCurrentInstallDir: () => Promise<string>;
selectInstallDirectory: () => Promise<string | null>;
downloadRelease: (
@ -97,9 +95,7 @@ export interface KoboldAPI {
launchKoboldCpp: (
args?: string[]
) => Promise<{ success: boolean; pid?: number; error?: string }>;
getConfigFiles: () => Promise<
Array<{ name: string; path: string; size: number }>
>;
getConfigFiles: () => Promise<{ name: string; path: string; size: number }[]>;
saveConfigFile: (
configName: string,
configData: {

21
src/types/index.d.ts vendored
View file

@ -8,8 +8,12 @@ export type InterfaceTab = 'terminal' | 'chat';
export type SdConvDirectMode = 'off' | 'vaeonly' | 'full';
export type FrontendPreference = 'koboldcpp' | 'sillytavern';
export type ServerTabMode = 'chat' | 'image-generation';
export type Screen = 'welcome' | 'download' | 'launch' | 'interface';
export interface GitHubAsset {
name: string;
browser_download_url: string;
@ -39,11 +43,12 @@ export interface InstalledVersion {
size?: number;
}
export type {
CPUCapabilities,
GPUCapabilities,
BasicGPUInfo,
HardwareInfo,
PlatformInfo,
SystemCapabilities,
} from '@/types/hardware';
export interface SelectOption {
value: string;
label: string;
}
export interface BackendOption extends SelectOption {
devices?: string[];
disabled?: boolean;
}

12
src/types/ipc.d.ts vendored Normal file
View file

@ -0,0 +1,12 @@
export type IPCChannel =
| 'download-progress'
| 'install-dir-changed'
| 'versions-updated'
| 'kobold-output';
export interface IPCChannelPayloads {
'download-progress': [progress: number];
'install-dir-changed': [newPath: string];
'versions-updated': [];
'kobold-output': [message: string];
}

View file

@ -1,13 +0,0 @@
export function parseCLBlastDevice(deviceString: string): {
deviceIndex: number;
platformIndex: number;
} | null {
const match = deviceString.match(/\[(\d+),(\d+)\]$/);
if (match) {
return {
deviceIndex: parseInt(match[1], 10),
platformIndex: parseInt(match[2], 10),
};
}
return null;
}

View file

@ -1,9 +1,9 @@
import { KOBOLDAI_URLS } from '@/constants';
import { ROCM } from '@/constants';
export const formatDownloadSize = (size: number, url?: string): string => {
if (!size) return '';
const isApproximateSize = url?.includes(KOBOLDAI_URLS.DOMAIN);
const isApproximateSize = url?.includes(ROCM.DOWNLOAD_URL);
return isApproximateSize
? `~${formatFileSizeInMB(size)}`

View file

@ -1,8 +1,7 @@
export * from './assets';
export * from './clblast';
export * from './downloadUtils';
export * from './hardware';
export * from './imageModelPresets';
export * from './linkifyTerminal';
export * from './platform';
export * from './sounds';
export * from './terminal';

View file

@ -0,0 +1,26 @@
const URL_REGEX = /(https?:\/\/[^\s<>"{}|\\^`[\]]+)/gi;
export const linkifyText = (text: string): string =>
text.replace(URL_REGEX, (url) => {
const cleanUrl = url.replace(/[.,;:!?]+$/, '');
const trailingPunctuation = url.slice(cleanUrl.length);
return `<a href="${cleanUrl}" target="_blank" rel="noopener noreferrer" style="color: #339af0; text-decoration: underline; cursor: pointer;">${cleanUrl}</a>${trailingPunctuation}`;
});
export const escapeHtmlExceptLinks = (text: string): string => {
const escaped = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
return linkifyText(escaped);
};
export const processTerminalContent = (content: string): string => {
if (!content) return '';
return escapeHtmlExceptLinks(content);
};

View file

@ -0,0 +1,2 @@
export * from './hardware';
export * from './paths';

12
src/utils/server/paths.ts Normal file
View file

@ -0,0 +1,12 @@
import { join } from 'path';
export function getPlatformPathFromWindowsPath(
basePath: string,
windowsBinaryPath: string
): string {
const binaryName =
process.platform === 'win32'
? windowsBinaryPath
: windowsBinaryPath.replace(/\.[^.]*$/, '');
return join(basePath, 'node_modules', '.bin', binaryName);
}

View file

@ -1,26 +1,24 @@
import stripAnsi from 'strip-ansi';
export const handleTerminalOutput = (
prevContent: string,
newData: string
): string => {
try {
const cleanData = stripAnsi(newData);
if (newData.includes('\r') && !newData.includes('\r\n')) {
const lines = (prevContent + newData).split('\n');
if (cleanData.includes('\r') && !cleanData.includes('\r\n')) {
const lines = (prevContent + cleanData).split('\n');
return lines
.map((line) => {
if (line.includes('\r')) {
const parts = line.split('\r');
return parts[parts.length - 1];
}
return line;
})
.join('\n');
}
return prevContent + cleanData;
return prevContent + newData;
} catch (error) {
window.electronAPI.logs.logError('Terminal Basic Error', error as Error);
return prevContent + newData;

View file

@ -1,4 +1,4 @@
export const isValidUrl = (string: string): boolean => {
const isValidUrl = (string: string): boolean => {
try {
const url = new URL(string);
return url.protocol === 'http:' || url.protocol === 'https:';
@ -7,7 +7,7 @@ export const isValidUrl = (string: string): boolean => {
}
};
export const isValidFilePath = (path: string): boolean => {
const isValidFilePath = (path: string): boolean => {
if (!path.trim()) return false;
const validExtensions = ['.gguf'];

1111
yarn.lock

File diff suppressed because it is too large Load diff