diff --git a/package.json b/package.json index 0635450..eed2aaa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gerbil", "productName": "Gerbil", - "version": "1.0.5", + "version": "1.0.6", "description": "Run Large Language Models locally", "main": "out/main/index.js", "homepage": "./", diff --git a/src/assets/kcpp_sdui.embd b/src/assets/kcpp_sdui.embd new file mode 100644 index 0000000..8799aa4 --- /dev/null +++ b/src/assets/kcpp_sdui.embd @@ -0,0 +1,524 @@ + + + + + + + Stable UI for KoboldCpp + + + + +
+ + + + diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index 8e170f3..f2c6773 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -25,11 +25,11 @@ import type { FrontendPreference, InterfaceTab, Screen } from '@/types'; interface TitleBarProps { currentScreen: Screen; - currentTab?: InterfaceTab; - onTabChange?: (tab: InterfaceTab) => void; - onEject?: () => void; - onOpenSettings?: () => void; - frontendPreference?: FrontendPreference; + currentTab: InterfaceTab; + onTabChange: (tab: InterfaceTab) => void; + onEject: () => void; + onOpenSettings: () => void; + frontendPreference: FrontendPreference; } export const TitleBar = ({ @@ -150,9 +150,9 @@ export const TitleBar = ({ value={currentTab} onChange={(value) => { if (value === 'eject') { - onEject?.(); + onEject(); } else { - onTabChange?.(value as InterfaceTab); + onTabChange(value as InterfaceTab); } }} onDropdownOpen={() => setIsSelectOpen(true)} diff --git a/src/components/screens/Interface/TerminalTab.tsx b/src/components/screens/Interface/TerminalTab.tsx index bd8c974..f1a8b7a 100644 --- a/src/components/screens/Interface/TerminalTab.tsx +++ b/src/components/screens/Interface/TerminalTab.tsx @@ -1,4 +1,10 @@ -import { useState, useEffect, useRef } from 'react'; +import { + useState, + useEffect, + useRef, + forwardRef, + useImperativeHandle, +} from 'react'; import { Box, ScrollArea, @@ -17,163 +23,174 @@ interface TerminalTabProps { frontendPreference?: FrontendPreference; } -export const TerminalTab = ({ - onServerReady, - frontendPreference = 'koboldcpp', -}: TerminalTabProps) => { - const { host, port, isImageGenerationMode } = useLaunchConfigStore(); - const computedColorScheme = useComputedColorScheme('light', { - getInitialValueInEffect: false, - }); - const [terminalContent, setTerminalContent] = useState(''); - const [isUserScrolling, setIsUserScrolling] = useState(false); - const [shouldAutoScroll, setShouldAutoScroll] = useState(true); - const scrollAreaRef = useRef(null); - const viewportRef = useRef(null); - const lastScrollTop = useRef(0); +export interface TerminalTabRef { + scrollToBottom: () => void; +} - const isDark = computedColorScheme === 'dark'; - - const handleScroll = ({ y }: { y: number }) => { - if (!viewportRef.current) return; - - const { scrollHeight, clientHeight } = viewportRef.current; - const isAtBottomNow = y + clientHeight >= scrollHeight - 10; - - if (y < lastScrollTop.current) { - setIsUserScrolling(true); - setShouldAutoScroll(false); - } else if (isAtBottomNow) { - setIsUserScrolling(false); - setShouldAutoScroll(true); - } - - lastScrollTop.current = y; - }; - - useEffect(() => { - if (shouldAutoScroll && !isUserScrolling && viewportRef.current) { - const viewport = viewportRef.current; - viewport.scrollTop = viewport.scrollHeight; - } - }, [terminalContent, shouldAutoScroll, isUserScrolling]); - - useEffect(() => { - const cleanup = window.electronAPI.kobold.onKoboldOutput((data: string) => { - setTerminalContent((prev) => { - const newData = data.toString(); - - if (onServerReady) { - const serverHost = host || 'localhost'; - const serverPort = port || 5001; - - let signalToCheck: string = SERVER_READY_SIGNALS.KOBOLDCPP; - - if (frontendPreference === 'sillytavern') { - signalToCheck = SERVER_READY_SIGNALS.SILLYTAVERN; - } else if ( - frontendPreference === 'openwebui' && - !isImageGenerationMode - ) { - signalToCheck = SERVER_READY_SIGNALS.OPENWEBUI; - } - - if (newData.includes(signalToCheck)) { - setTimeout( - () => onServerReady(`http://${serverHost}:${serverPort}`), - 1500 - ); - } - } - - return handleTerminalOutput(prev, newData); - }); +export const TerminalTab = forwardRef( + ({ onServerReady, frontendPreference = 'koboldcpp' }, ref) => { + const { host, port, isImageGenerationMode } = useLaunchConfigStore(); + const computedColorScheme = useComputedColorScheme('light', { + getInitialValueInEffect: false, }); + const [terminalContent, setTerminalContent] = useState(''); + const [isUserScrolling, setIsUserScrolling] = useState(false); + const [shouldAutoScroll, setShouldAutoScroll] = useState(true); + const scrollAreaRef = useRef(null); + const viewportRef = useRef(null); + const lastScrollTop = useRef(0); - return cleanup; - }, [onServerReady, host, port, frontendPreference, isImageGenerationMode]); + const isDark = computedColorScheme === 'dark'; - const scrollToBottom = () => { - if (viewportRef.current) { - const viewport = viewportRef.current; - viewport.scrollTop = viewport.scrollHeight; - setShouldAutoScroll(true); - setIsUserScrolling(false); - } - }; + const handleScroll = ({ y }: { y: number }) => { + if (!viewportRef.current) return; - return ( - - = scrollHeight - 10; + + if (y < lastScrollTop.current) { + setIsUserScrolling(true); + setShouldAutoScroll(false); + } else if (isAtBottomNow) { + setIsUserScrolling(false); + setShouldAutoScroll(true); + } + + lastScrollTop.current = y; + }; + + useEffect(() => { + if (shouldAutoScroll && !isUserScrolling && viewportRef.current) { + const viewport = viewportRef.current; + viewport.scrollTop = viewport.scrollHeight; + } + }, [terminalContent, shouldAutoScroll, isUserScrolling]); + + useEffect(() => { + const cleanup = window.electronAPI.kobold.onKoboldOutput( + (data: string) => { + setTerminalContent((prev) => { + const newData = data.toString(); + + if (onServerReady) { + const serverHost = host || 'localhost'; + const serverPort = port || 5001; + + let signalToCheck: string = SERVER_READY_SIGNALS.KOBOLDCPP; + + if (frontendPreference === 'sillytavern') { + signalToCheck = SERVER_READY_SIGNALS.SILLYTAVERN; + } else if ( + frontendPreference === 'openwebui' && + !isImageGenerationMode + ) { + signalToCheck = SERVER_READY_SIGNALS.OPENWEBUI; + } + + if (newData.includes(signalToCheck)) { + setTimeout( + () => onServerReady(`http://${serverHost}:${serverPort}`), + 1500 + ); + } + } + + return handleTerminalOutput(prev, newData); + }); + } + ); + + return cleanup; + }, [onServerReady, host, port, frontendPreference, isImageGenerationMode]); + + const scrollToBottom = () => { + if (viewportRef.current) { + const viewport = viewportRef.current; + viewport.scrollTop = viewport.scrollHeight; + setShouldAutoScroll(true); + setIsUserScrolling(false); + } + }; + + useImperativeHandle(ref, () => ({ + scrollToBottom, + })); + + return ( + - - {terminalContent.length === 0 ? ( - - Starting... - - ) : ( -
- )} - - - - {isUserScrolling && !shouldAutoScroll && ( - - - - )} - - ); -}; + + {terminalContent.length === 0 ? ( + + Starting... + + ) : ( +
+ )} + + + + {isUserScrolling && !shouldAutoScroll && ( + + + + )} + + ); + } +); + +TerminalTab.displayName = 'TerminalTab'; diff --git a/src/components/screens/Interface/index.tsx b/src/components/screens/Interface/index.tsx index 0a1fba1..ae65540 100644 --- a/src/components/screens/Interface/index.tsx +++ b/src/components/screens/Interface/index.tsx @@ -1,6 +1,9 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import { ServerTab } from '@/components/screens/Interface/ServerTab'; -import { TerminalTab } from '@/components/screens/Interface/TerminalTab'; +import { + TerminalTab, + type TerminalTabRef, +} from '@/components/screens/Interface/TerminalTab'; import type { InterfaceTab, FrontendPreference } from '@/types'; interface InterfaceScreenProps { @@ -16,6 +19,7 @@ export const InterfaceScreen = ({ }: InterfaceScreenProps) => { const [serverUrl, setServerUrl] = useState(''); const [isServerReady, setIsServerReady] = useState(false); + const terminalTabRef = useRef(null); const handleServerReady = useCallback( (url: string) => { @@ -28,6 +32,12 @@ export const InterfaceScreen = ({ [onTabChange] ); + useEffect(() => { + if (activeTab === 'terminal' && terminalTabRef.current) { + terminalTabRef.current.scrollToBottom(); + } + }, [activeTab]); + return (
diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 49817b9..02f1d8d 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -8,6 +8,7 @@ import type { OpenWebUIManager } from '@/main/managers/OpenWebUIManager'; import type { WindowManager } from '@/main/managers/WindowManager'; import { HardwareManager } from '@/main/managers/HardwareManager'; import { BinaryManager } from '@/main/managers/BinaryManager'; +import type { FrontendPreference } from '@/types'; export class IPCHandlers { private koboldManager: KoboldCppManager; @@ -41,10 +42,14 @@ export class IPCHandlers { private async launchKoboldCppWithCustomFrontends(args: string[] = []) { try { - const result = await this.koboldManager.launchKoboldCpp(args); + const frontendPreference = (await this.configManager.get( + 'frontendPreference' + )) as FrontendPreference; - const frontendPreference = - await this.configManager.get('frontendPreference'); + const result = await this.koboldManager.launchKoboldCpp( + args, + frontendPreference + ); if (frontendPreference === 'sillytavern') { this.sillyTavernManager.startFrontend(args); diff --git a/src/main/managers/KoboldCppManager.ts b/src/main/managers/KoboldCppManager.ts index 1548705..d44ce4d 100644 --- a/src/main/managers/KoboldCppManager.ts +++ b/src/main/managers/KoboldCppManager.ts @@ -11,6 +11,7 @@ import { chmod, readFile, writeFile, + copyFile, } from 'fs/promises'; import { dialog } from 'electron'; import axios from 'axios'; @@ -27,11 +28,14 @@ import { } from '@/constants/patches'; import { pathExists, readJsonFile, writeJsonFile } from '@/utils/fs'; import { stripAssetExtensions } from '@/utils/version'; +import { parseKoboldConfig } from '@/utils/kobold'; +import { getAssetPath } from '@/utils/path'; import type { GitHubAsset, InstalledVersion, KoboldConfig, } from '@/types/electron'; +import type { FrontendPreference } from '@/types'; export class KoboldCppManager { private koboldProcess: ChildProcess | null = null; @@ -318,6 +322,29 @@ export class KoboldCppManager { } } + private async patchKcppSduiEmbd(unpackedDir: string): Promise { + try { + const possiblePaths = [ + join(unpackedDir, '_internal', 'kcpp_sdui.embd'), + join(unpackedDir, 'kcpp_sdui.embd'), + ]; + + const sourceAssetPath = getAssetPath('kcpp_sdui.embd'); + + for (const targetPath of possiblePaths) { + if (await pathExists(targetPath)) { + await copyFile(sourceAssetPath, targetPath); + break; + } + } + } catch (error) { + this.logManager.logError( + 'Failed to patch kcpp_sdui.embd:', + error as Error + ); + } + } + private async getLauncherPath(unpackedDir: string): Promise { const extensions = process.platform === 'win32' ? ['.exe', ''] : ['', '.exe']; @@ -618,7 +645,8 @@ export class KoboldCppManager { } async launchKoboldCpp( - args: string[] = [] + args: string[] = [], + frontendPreference: FrontendPreference = 'koboldcpp' ): Promise<{ success: boolean; pid?: number; error?: string }> { try { if (this.koboldProcess) { @@ -646,7 +674,18 @@ export class KoboldCppManager { .split(/[/\\]/) .slice(0, -1) .join('/'); - await this.patchKliteEmbd(binaryDir); + + const { isImageMode } = parseKoboldConfig(args); + + if (frontendPreference === 'koboldcpp') { + if (isImageMode) { + await this.patchKcppSduiEmbd(binaryDir); + } else { + await this.patchKliteEmbd(binaryDir); + } + } else if (frontendPreference === 'openwebui' && isImageMode) { + await this.patchKcppSduiEmbd(binaryDir); + } const finalArgs = [...args]; diff --git a/src/main/managers/OpenWebUIManager.ts b/src/main/managers/OpenWebUIManager.ts index fc4f777..05daf8e 100644 --- a/src/main/managers/OpenWebUIManager.ts +++ b/src/main/managers/OpenWebUIManager.ts @@ -162,9 +162,6 @@ export class OpenWebUIManager { } = parseKoboldConfig(args); if (isImageMode) { - this.windowManager.sendKoboldOutput( - 'Open WebUI does not support image generation mode. Please use KoboldAI Lite or SillyTavern for image generation.' - ); return; } diff --git a/src/main/managers/WindowManager.ts b/src/main/managers/WindowManager.ts index 18237d4..28c4584 100644 --- a/src/main/managers/WindowManager.ts +++ b/src/main/managers/WindowManager.ts @@ -11,6 +11,7 @@ import { join } from 'path'; import { stripVTControlCharacters } from 'util'; import { PRODUCT_NAME } from '../../constants'; import type { IPCChannel, IPCChannelPayloads } from '@/types/ipc'; +import { getAssetPath } from '@/utils/path'; export class WindowManager { private mainWindow: BrowserWindow | null = null; @@ -20,11 +21,7 @@ export class WindowManager { } private getIconPath(): string { - if (process.env.NODE_ENV === 'development') { - const projectRoot = join(__dirname, '../..'); - return join(projectRoot, 'src/assets/icon.png'); - } - return join(process.resourcesPath, 'assets/icon.png'); + return getAssetPath('icon.png'); } createMainWindow(): BrowserWindow { diff --git a/src/main/utils/assets.ts b/src/main/utils/assets.ts new file mode 100644 index 0000000..bbf2683 --- /dev/null +++ b/src/main/utils/assets.ts @@ -0,0 +1,9 @@ +import { join } from 'path'; +import { app } from 'electron'; + +export const getAssetPath = (filename: string): string => { + if (process.env.NODE_ENV === 'development' || !app.isPackaged) { + return join(__dirname, '../..', 'src/assets', filename); + } + return join(process.resourcesPath, 'assets', filename); +}; diff --git a/src/utils/path.ts b/src/utils/path.ts index d996264..ed5de66 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -1,7 +1,15 @@ import { join } from 'path'; import { homedir } from 'os'; +import { app } from 'electron'; import { PRODUCT_NAME, CONFIG_FILE_NAME } from '@/constants'; +export function getAssetPath(filename: string): string { + if (process.env.NODE_ENV === 'development' || !app.isPackaged) { + return join(__dirname, '../..', 'src/assets', filename); + } + return join(process.resourcesPath, 'assets', filename); +} + export function getConfigDir(): string { return join(getConfigDirPath(), CONFIG_FILE_NAME); }