always scroll to bottom when switching to the terminal tab, overwrite existing sdui.embd with a cleaner version

This commit is contained in:
lone-cloud 2025-09-07 23:29:44 -07:00
parent 985cc53fa6
commit db572b408e
11 changed files with 780 additions and 173 deletions

View file

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

524
src/assets/kcpp_sdui.embd Normal file

File diff suppressed because one or more lines are too long

View file

@ -25,11 +25,11 @@ import type { FrontendPreference, InterfaceTab, Screen } from '@/types';
interface TitleBarProps { interface TitleBarProps {
currentScreen: Screen; currentScreen: Screen;
currentTab?: InterfaceTab; currentTab: InterfaceTab;
onTabChange?: (tab: InterfaceTab) => void; onTabChange: (tab: InterfaceTab) => void;
onEject?: () => void; onEject: () => void;
onOpenSettings?: () => void; onOpenSettings: () => void;
frontendPreference?: FrontendPreference; frontendPreference: FrontendPreference;
} }
export const TitleBar = ({ export const TitleBar = ({
@ -150,9 +150,9 @@ export const TitleBar = ({
value={currentTab} value={currentTab}
onChange={(value) => { onChange={(value) => {
if (value === 'eject') { if (value === 'eject') {
onEject?.(); onEject();
} else { } else {
onTabChange?.(value as InterfaceTab); onTabChange(value as InterfaceTab);
} }
}} }}
onDropdownOpen={() => setIsSelectOpen(true)} onDropdownOpen={() => setIsSelectOpen(true)}

View file

@ -1,4 +1,10 @@
import { useState, useEffect, useRef } from 'react'; import {
useState,
useEffect,
useRef,
forwardRef,
useImperativeHandle,
} from 'react';
import { import {
Box, Box,
ScrollArea, ScrollArea,
@ -17,163 +23,174 @@ interface TerminalTabProps {
frontendPreference?: FrontendPreference; frontendPreference?: FrontendPreference;
} }
export const TerminalTab = ({ export interface TerminalTabRef {
onServerReady, scrollToBottom: () => void;
frontendPreference = 'koboldcpp', }
}: TerminalTabProps) => {
const { host, port, isImageGenerationMode } = useLaunchConfigStore();
const computedColorScheme = useComputedColorScheme('light', {
getInitialValueInEffect: false,
});
const [terminalContent, setTerminalContent] = useState<string>('');
const [isUserScrolling, setIsUserScrolling] = useState<boolean>(false);
const [shouldAutoScroll, setShouldAutoScroll] = useState<boolean>(true);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const lastScrollTop = useRef<number>(0);
const isDark = computedColorScheme === 'dark'; export const TerminalTab = forwardRef<TerminalTabRef, TerminalTabProps>(
({ onServerReady, frontendPreference = 'koboldcpp' }, ref) => {
const handleScroll = ({ y }: { y: number }) => { const { host, port, isImageGenerationMode } = useLaunchConfigStore();
if (!viewportRef.current) return; const computedColorScheme = useComputedColorScheme('light', {
getInitialValueInEffect: false,
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);
});
}); });
const [terminalContent, setTerminalContent] = useState<string>('');
const [isUserScrolling, setIsUserScrolling] = useState<boolean>(false);
const [shouldAutoScroll, setShouldAutoScroll] = useState<boolean>(true);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const lastScrollTop = useRef<number>(0);
return cleanup; const isDark = computedColorScheme === 'dark';
}, [onServerReady, host, port, frontendPreference, isImageGenerationMode]);
const scrollToBottom = () => { const handleScroll = ({ y }: { y: number }) => {
if (viewportRef.current) { if (!viewportRef.current) return;
const viewport = viewportRef.current;
viewport.scrollTop = viewport.scrollHeight;
setShouldAutoScroll(true);
setIsUserScrolling(false);
}
};
return ( const { scrollHeight, clientHeight } = viewportRef.current;
<Box const isAtBottomNow = y + clientHeight >= scrollHeight - 10;
style={{
height: `calc(100vh - ${TITLEBAR_HEIGHT})`, if (y < lastScrollTop.current) {
display: 'flex', setIsUserScrolling(true);
flexDirection: 'column', setShouldAutoScroll(false);
backgroundColor: isDark } else if (isAtBottomNow) {
? 'var(--mantine-color-dark-filled)' setIsUserScrolling(false);
: 'var(--mantine-color-gray-0)', setShouldAutoScroll(true);
borderRadius: 'inherit', }
position: 'relative',
}} lastScrollTop.current = y;
> };
<ScrollArea
ref={scrollAreaRef} useEffect(() => {
viewportRef={viewportRef} if (shouldAutoScroll && !isUserScrolling && viewportRef.current) {
onScrollPositionChange={handleScroll} 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 (
<Box
style={{ style={{
flex: 1, height: `calc(100vh - ${TITLEBAR_HEIGHT})`,
fontFamily: 'Monaco, Menlo, Ubuntu Mono, monospace', display: 'flex',
fontSize: '0.875em', flexDirection: 'column',
lineHeight: 1.4, backgroundColor: isDark
? 'var(--mantine-color-dark-filled)'
: 'var(--mantine-color-gray-0)',
borderRadius: 'inherit',
position: 'relative',
}} }}
scrollbarSize={8}
offsetScrollbars={false}
> >
<Box p="md"> <ScrollArea
{terminalContent.length === 0 ? ( ref={scrollAreaRef}
<Text c="dimmed" style={{ fontFamily: 'inherit' }}> viewportRef={viewportRef}
Starting... onScrollPositionChange={handleScroll}
</Text>
) : (
<div
style={{
margin: 0,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: '0.875em',
lineHeight: 1.4,
color: isDark
? 'var(--mantine-color-gray-0)'
: 'var(--mantine-color-dark-filled)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
dangerouslySetInnerHTML={{
__html: processTerminalContent(terminalContent),
}}
/>
)}
</Box>
</ScrollArea>
{isUserScrolling && !shouldAutoScroll && (
<ActionIcon
variant="filled"
color="blue"
size="lg"
radius="xl"
onClick={scrollToBottom}
style={{ style={{
position: 'absolute', flex: 1,
bottom: '1.25rem', fontFamily: 'Monaco, Menlo, Ubuntu Mono, monospace',
right: '1.25rem', fontSize: '0.875em',
zIndex: 10, lineHeight: 1.4,
boxShadow: '0 0.125rem 0.5rem rgba(0, 0, 0, 0.3)',
}} }}
aria-label="Scroll to bottom" scrollbarSize={8}
offsetScrollbars={false}
> >
<ChevronDown size={20} /> <Box p="md">
</ActionIcon> {terminalContent.length === 0 ? (
)} <Text c="dimmed" style={{ fontFamily: 'inherit' }}>
</Box> Starting...
); </Text>
}; ) : (
<div
style={{
margin: 0,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: '0.875em',
lineHeight: 1.4,
color: isDark
? 'var(--mantine-color-gray-0)'
: 'var(--mantine-color-dark-filled)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
dangerouslySetInnerHTML={{
__html: processTerminalContent(terminalContent),
}}
/>
)}
</Box>
</ScrollArea>
{isUserScrolling && !shouldAutoScroll && (
<ActionIcon
variant="filled"
color="blue"
size="lg"
radius="xl"
onClick={scrollToBottom}
style={{
position: 'absolute',
bottom: '1.25rem',
right: '1.25rem',
zIndex: 10,
boxShadow: '0 0.125rem 0.5rem rgba(0, 0, 0, 0.3)',
}}
aria-label="Scroll to bottom"
>
<ChevronDown size={20} />
</ActionIcon>
)}
</Box>
);
}
);
TerminalTab.displayName = 'TerminalTab';

View file

@ -1,6 +1,9 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
import { ServerTab } from '@/components/screens/Interface/ServerTab'; 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'; import type { InterfaceTab, FrontendPreference } from '@/types';
interface InterfaceScreenProps { interface InterfaceScreenProps {
@ -16,6 +19,7 @@ export const InterfaceScreen = ({
}: InterfaceScreenProps) => { }: InterfaceScreenProps) => {
const [serverUrl, setServerUrl] = useState<string>(''); const [serverUrl, setServerUrl] = useState<string>('');
const [isServerReady, setIsServerReady] = useState<boolean>(false); const [isServerReady, setIsServerReady] = useState<boolean>(false);
const terminalTabRef = useRef<TerminalTabRef>(null);
const handleServerReady = useCallback( const handleServerReady = useCallback(
(url: string) => { (url: string) => {
@ -28,6 +32,12 @@ export const InterfaceScreen = ({
[onTabChange] [onTabChange]
); );
useEffect(() => {
if (activeTab === 'terminal' && terminalTabRef.current) {
terminalTabRef.current.scrollToBottom();
}
}, [activeTab]);
return ( return (
<div <div
style={{ style={{
@ -56,6 +66,7 @@ export const InterfaceScreen = ({
}} }}
> >
<TerminalTab <TerminalTab
ref={terminalTabRef}
onServerReady={handleServerReady} onServerReady={handleServerReady}
frontendPreference={frontendPreference} frontendPreference={frontendPreference}
/> />

View file

@ -8,6 +8,7 @@ import type { OpenWebUIManager } from '@/main/managers/OpenWebUIManager';
import type { WindowManager } from '@/main/managers/WindowManager'; import type { WindowManager } from '@/main/managers/WindowManager';
import { HardwareManager } from '@/main/managers/HardwareManager'; import { HardwareManager } from '@/main/managers/HardwareManager';
import { BinaryManager } from '@/main/managers/BinaryManager'; import { BinaryManager } from '@/main/managers/BinaryManager';
import type { FrontendPreference } from '@/types';
export class IPCHandlers { export class IPCHandlers {
private koboldManager: KoboldCppManager; private koboldManager: KoboldCppManager;
@ -41,10 +42,14 @@ export class IPCHandlers {
private async launchKoboldCppWithCustomFrontends(args: string[] = []) { private async launchKoboldCppWithCustomFrontends(args: string[] = []) {
try { try {
const result = await this.koboldManager.launchKoboldCpp(args); const frontendPreference = (await this.configManager.get(
'frontendPreference'
)) as FrontendPreference;
const frontendPreference = const result = await this.koboldManager.launchKoboldCpp(
await this.configManager.get('frontendPreference'); args,
frontendPreference
);
if (frontendPreference === 'sillytavern') { if (frontendPreference === 'sillytavern') {
this.sillyTavernManager.startFrontend(args); this.sillyTavernManager.startFrontend(args);

View file

@ -11,6 +11,7 @@ import {
chmod, chmod,
readFile, readFile,
writeFile, writeFile,
copyFile,
} from 'fs/promises'; } from 'fs/promises';
import { dialog } from 'electron'; import { dialog } from 'electron';
import axios from 'axios'; import axios from 'axios';
@ -27,11 +28,14 @@ import {
} from '@/constants/patches'; } from '@/constants/patches';
import { pathExists, readJsonFile, writeJsonFile } from '@/utils/fs'; import { pathExists, readJsonFile, writeJsonFile } from '@/utils/fs';
import { stripAssetExtensions } from '@/utils/version'; import { stripAssetExtensions } from '@/utils/version';
import { parseKoboldConfig } from '@/utils/kobold';
import { getAssetPath } from '@/utils/path';
import type { import type {
GitHubAsset, GitHubAsset,
InstalledVersion, InstalledVersion,
KoboldConfig, KoboldConfig,
} from '@/types/electron'; } from '@/types/electron';
import type { FrontendPreference } from '@/types';
export class KoboldCppManager { export class KoboldCppManager {
private koboldProcess: ChildProcess | null = null; private koboldProcess: ChildProcess | null = null;
@ -318,6 +322,29 @@ export class KoboldCppManager {
} }
} }
private async patchKcppSduiEmbd(unpackedDir: string): Promise<void> {
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<string | null> { private async getLauncherPath(unpackedDir: string): Promise<string | null> {
const extensions = const extensions =
process.platform === 'win32' ? ['.exe', ''] : ['', '.exe']; process.platform === 'win32' ? ['.exe', ''] : ['', '.exe'];
@ -618,7 +645,8 @@ export class KoboldCppManager {
} }
async launchKoboldCpp( async launchKoboldCpp(
args: string[] = [] args: string[] = [],
frontendPreference: FrontendPreference = 'koboldcpp'
): Promise<{ success: boolean; pid?: number; error?: string }> { ): Promise<{ success: boolean; pid?: number; error?: string }> {
try { try {
if (this.koboldProcess) { if (this.koboldProcess) {
@ -646,7 +674,18 @@ export class KoboldCppManager {
.split(/[/\\]/) .split(/[/\\]/)
.slice(0, -1) .slice(0, -1)
.join('/'); .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]; const finalArgs = [...args];

View file

@ -162,9 +162,6 @@ export class OpenWebUIManager {
} = parseKoboldConfig(args); } = parseKoboldConfig(args);
if (isImageMode) { if (isImageMode) {
this.windowManager.sendKoboldOutput(
'Open WebUI does not support image generation mode. Please use KoboldAI Lite or SillyTavern for image generation.'
);
return; return;
} }

View file

@ -11,6 +11,7 @@ import { join } from 'path';
import { stripVTControlCharacters } from 'util'; import { stripVTControlCharacters } from 'util';
import { PRODUCT_NAME } from '../../constants'; import { PRODUCT_NAME } from '../../constants';
import type { IPCChannel, IPCChannelPayloads } from '@/types/ipc'; import type { IPCChannel, IPCChannelPayloads } from '@/types/ipc';
import { getAssetPath } from '@/utils/path';
export class WindowManager { export class WindowManager {
private mainWindow: BrowserWindow | null = null; private mainWindow: BrowserWindow | null = null;
@ -20,11 +21,7 @@ export class WindowManager {
} }
private getIconPath(): string { private getIconPath(): string {
if (process.env.NODE_ENV === 'development') { return getAssetPath('icon.png');
const projectRoot = join(__dirname, '../..');
return join(projectRoot, 'src/assets/icon.png');
}
return join(process.resourcesPath, 'assets/icon.png');
} }
createMainWindow(): BrowserWindow { createMainWindow(): BrowserWindow {

9
src/main/utils/assets.ts Normal file
View file

@ -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);
};

View file

@ -1,7 +1,15 @@
import { join } from 'path'; import { join } from 'path';
import { homedir } from 'os'; import { homedir } from 'os';
import { app } from 'electron';
import { PRODUCT_NAME, CONFIG_FILE_NAME } from '@/constants'; 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 { export function getConfigDir(): string {
return join(getConfigDirPath(), CONFIG_FILE_NAME); return join(getConfigDirPath(), CONFIG_FILE_NAME);
} }