mirror of
https://github.com/lone-cloud/gerbil
synced 2026-06-03 19:54:44 -07:00
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:
parent
861a1afb87
commit
a559f8c86d
45 changed files with 2173 additions and 685 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -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",
|
||||
|
|
|
|||
226
src/App.tsx
226
src/App.tsx
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:',
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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', () =>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
452
src/main/managers/SillyTavernManager.ts
Normal file
452
src/main/managers/SillyTavernManager.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
18
src/types/electron.d.ts
vendored
18
src/types/electron.d.ts
vendored
|
|
@ -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
21
src/types/index.d.ts
vendored
|
|
@ -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
12
src/types/ipc.d.ts
vendored
Normal 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];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)}`
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
26
src/utils/linkifyTerminal.ts
Normal file
26
src/utils/linkifyTerminal.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
return linkifyText(escaped);
|
||||
};
|
||||
|
||||
export const processTerminalContent = (content: string): string => {
|
||||
if (!content) return '';
|
||||
|
||||
return escapeHtmlExceptLinks(content);
|
||||
};
|
||||
2
src/utils/server/index.ts
Normal file
2
src/utils/server/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './hardware';
|
||||
export * from './paths';
|
||||
12
src/utils/server/paths.ts
Normal file
12
src/utils/server/paths.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue