seamless openwebui integration, --version CLI support

This commit is contained in:
Egor 2025-09-02 22:09:20 -07:00
parent 75d5bcc020
commit 2128916701
22 changed files with 685 additions and 308 deletions

1
.gitignore vendored
View file

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

View file

@ -2,19 +2,20 @@
A desktop app to easily run Large Language Models locally.
<img src="src/assets/icon.png" alt="Gerbil Icon" width="32" height="32">
<img src="src/assets/icon.png" alt="Gerbil Icon" width="32" height="32" />
<!-- markdownlint-enable MD033 -->
## Core Features
- **Run LLMs locally** powered by [KoboldCpp](https://github.com/LostRuins/koboldcpp)
- **Run LLMs locally** powered by [KoboldCpp](https://github.com/LostRuins/koboldcpp) which itself is a highly modified fork of [llama.cpp](https://github.com/ggml-org/llama.cpp)
- **Cross-platform desktop app** - Native support for Windows, macOS, and Linux (including Wayland)
- **Automatic updates** - Download and keep your KoboldCpp binary up-to-date effortlessly
- **Smart process management** - Prevents runaway background processes and system resource waste
- **Optimized performance** - Automatically unpacks binaries for faster operation and reduced memory usage
- **Image generation support** - Built-in presets for Flux and Chroma image generation workflows
- **SillyTavern integration** - Seamlessly launch SillyTavern for advanced character interactions (requires [Node.js](https://nodejs.org/))
- **OpenWebUI integration** - Launch OpenWebUI for a modern web-based chat interface (requires [uv](https://docs.astral.sh/uv/getting-started/installation/))
- **Privacy-focused** - Everything runs locally on your machine, no data sent to external servers
## Installation
@ -49,9 +50,6 @@ The AUR package automatically handles installation, desktop integration, and sys
## Screenshots
<!-- markdownlint-disable MD033 -->
<div align="center">
### Download & Setup
<img src="screenshots/download.png" alt="Download Interface" width="600">
@ -72,12 +70,13 @@ The AUR package automatically handles installation, desktop integration, and sys
<img src="screenshots/gen-img.png" alt="Image Generation" width="600">
### Seemless SillyTavern integration
### Seamless SillyTavern integration
<img src="screenshots/sillytavern.png" alt="SillyTavern integration" width="600">
</div>
<!-- markdownlint-enable MD033 -->
### Seamless OpenWebUI integration
<img src="screenshots/openwebui.png" alt="SillyTavern integration" width="600">
### Future features
@ -147,3 +146,5 @@ You can use the CLI mode on Windows in exactly the same way as in the Linux/macO
## License
AGPL v3 License - see LICENSE file for details
# test

View file

@ -1,7 +1,7 @@
{
"name": "gerbil",
"productName": "Gerbil",
"version": "0.9.6",
"version": "0.9.7",
"description": "Run Large Language Models locally",
"main": "out/main/index.js",
"homepage": "./",
@ -56,11 +56,11 @@
"@types/node": "^24.3.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@typescript-eslint/eslint-plugin": "^8.41.0",
"@typescript-eslint/parser": "^8.41.0",
"@typescript-eslint/eslint-plugin": "^8.42.0",
"@typescript-eslint/parser": "^8.42.0",
"@vitejs/plugin-react": "^5.0.2",
"cross-env": "^10.0.0",
"electron": "^37.4.0",
"electron": "^38.0.0",
"electron-builder": "^26.0.12",
"electron-vite": "^4.0.0",
"eslint": "^9.34.0",
@ -116,15 +116,6 @@
]
}
],
"extraResources": [
{
"from": "src/main/templates",
"to": "templates",
"filter": [
"**/*"
]
}
],
"mac": {
"compression": "maximum",
"category": "public.app-category.productivity",

BIN
screenshots/openwebui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

View file

@ -165,12 +165,20 @@ export const TitleBar = ({
data={[
{
value: 'chat',
label:
frontendPreference === 'sillytavern'
? FRONTENDS.SILLYTAVERN
: isImageGenerationMode
? FRONTENDS.STABLE_UI
: FRONTENDS.KOBOLDAI_LITE,
label: (() => {
if (frontendPreference === 'sillytavern') {
return FRONTENDS.SILLYTAVERN;
}
if (
frontendPreference === 'openwebui' &&
!isImageGenerationMode
) {
return FRONTENDS.OPENWEBUI;
}
return isImageGenerationMode
? FRONTENDS.STABLE_UI
: FRONTENDS.KOBOLDAI_LITE;
})(),
},
{ value: 'terminal', label: 'Terminal' },
{ value: 'eject', label: 'Eject' },

View file

@ -1,5 +1,10 @@
import { Box, Text, Stack } from '@mantine/core';
import { SILLYTAVERN, FRONTENDS, TITLEBAR_HEIGHT } from '@/constants';
import {
SILLYTAVERN,
OPENWEBUI,
FRONTENDS,
TITLEBAR_HEIGHT,
} from '@/constants';
import { useLaunchConfigStore } from '@/stores/launchConfig';
import type { FrontendPreference } from '@/types';
@ -46,6 +51,9 @@ export const ServerTab = ({
if (frontendPreference === 'sillytavern') {
iframeUrl = SILLYTAVERN.PROXY_URL;
title = FRONTENDS.SILLYTAVERN;
} else if (frontendPreference === 'openwebui' && !isImageGenerationMode) {
iframeUrl = OPENWEBUI.URL;
title = FRONTENDS.OPENWEBUI;
} else {
iframeUrl = isImageGenerationMode ? `${serverUrl}/sdui` : serverUrl;
title = isImageGenerationMode

View file

@ -22,7 +22,7 @@ export const TerminalTab = ({
onServerReady,
frontendPreference = 'koboldcpp',
}: TerminalTabProps) => {
const { host, port } = useLaunchConfigStore();
const { host, port, isImageGenerationMode } = useLaunchConfigStore();
const computedColorScheme = useComputedColorScheme('light', {
getInitialValueInEffect: false,
});
@ -68,20 +68,22 @@ export const TerminalTab = ({
const serverHost = host || 'localhost';
const serverPort = port || 5001;
let signalToCheck: string = SERVER_READY_SIGNALS.KOBOLDCPP;
if (frontendPreference === 'sillytavern') {
if (newData.includes(SERVER_READY_SIGNALS.SILLYTAVERN)) {
setTimeout(
() => onServerReady(`http://${serverHost}:${serverPort}`),
1500
);
}
} else {
if (newData.includes(SERVER_READY_SIGNALS.KOBOLDCPP)) {
setTimeout(
() => onServerReady(`http://${serverHost}:${serverPort}`),
1500
);
}
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
);
}
}
@ -90,7 +92,7 @@ export const TerminalTab = ({
});
return cleanup;
}, [onServerReady, host, port, frontendPreference]);
}, [onServerReady, host, port, frontendPreference, isImageGenerationMode]);
const scrollToBottom = () => {
if (viewportRef.current) {

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { tryExecute, safeExecute } from '@/utils/logger';
import {
Stack,
@ -8,6 +8,7 @@ import {
Button,
rem,
Select,
Box,
Anchor,
} from '@mantine/core';
import { Folder, FolderOpen, Monitor } from 'lucide-react';
@ -15,43 +16,125 @@ import styles from '@/styles/layout.module.css';
import type { FrontendPreference } from '@/types';
import { FRONTENDS } from '@/constants';
interface FrontendRequirement {
id: string;
name: string;
url: string;
}
interface FrontendConfig {
value: string;
label: string;
requirements?: FrontendRequirement[];
requirementCheck?: () => Promise<boolean>;
}
export const GeneralTab = () => {
const [installDir, setInstallDir] = useState<string>('');
const [FrontendPreference, setFrontendPreference] =
useState<FrontendPreference>('koboldcpp');
const [isNpxAvailable, setIsNpxAvailable] = useState<boolean>(true);
const [frontendRequirements, setFrontendRequirements] = useState<
Map<string, boolean>
>(new Map());
const frontendConfigs: FrontendConfig[] = useMemo(
() => [
{ value: 'koboldcpp', label: 'KoboldCpp (Built-in)' },
{
value: 'sillytavern',
label: FRONTENDS.SILLYTAVERN,
requirements: [
{
id: 'nodejs',
name: 'Node.js',
url: 'https://nodejs.org/',
},
],
requirementCheck: () => window.electronAPI.sillytavern.isNpxAvailable(),
},
{
value: 'openwebui',
label: FRONTENDS.OPENWEBUI,
requirements: [
{
id: 'uv',
name: 'uv',
url: 'https://docs.astral.sh/uv/getting-started/installation/',
},
],
requirementCheck: () => window.electronAPI.openwebui.isUvAvailable(),
},
],
[]
);
const checkAllFrontendRequirements = useCallback(async () => {
const requirementResults = new Map<string, boolean>();
for (const config of frontendConfigs) {
if (config.requirementCheck) {
const isAvailable = await safeExecute(
config.requirementCheck,
`Failed to check requirements for ${config.label}:`
);
requirementResults.set(config.value, isAvailable ?? false);
} else {
requirementResults.set(config.value, true);
}
}
setFrontendRequirements(requirementResults);
const currentFrontendConfig = frontendConfigs.find(
(config) => config.value === FrontendPreference
);
if (currentFrontendConfig && !requirementResults.get(FrontendPreference)) {
await tryExecute(
() => window.electronAPI.config.set('frontendPreference', 'koboldcpp'),
'Failed to reset frontend preference:'
);
setFrontendPreference('koboldcpp');
}
}, [frontendConfigs, FrontendPreference]);
useEffect(() => {
const initialize = async () => {
await Promise.all([loadCurrentInstallDir(), loadFrontendPreference()]);
await Promise.all([
loadCurrentInstallDir(),
loadFrontendPreference(),
checkAllFrontendRequirements(),
]);
};
initialize();
}, []);
}, [checkAllFrontendRequirements]);
const getSelectedFrontendConfig = () =>
frontendConfigs.find((config) => config.value === FrontendPreference);
const getUnmetRequirements = () => {
const selectedConfig = getSelectedFrontendConfig();
if (!selectedConfig || !selectedConfig.requirements) return [];
const isAvailable = frontendRequirements.get(selectedConfig.value) ?? true;
return isAvailable ? [] : selectedConfig.requirements;
};
const getUnmetRequirementsForFrontend = (frontendValue: string) => {
const config = frontendConfigs.find((c) => c.value === frontendValue);
if (!config || !config.requirements) return [];
const isAvailable = frontendRequirements.get(frontendValue) ?? true;
return isAvailable ? [] : config.requirements;
};
const isFrontendAvailable = (frontendValue: string) =>
frontendRequirements.get(frontendValue) ?? true;
useEffect(() => {
const checkNpxAvailability = async () => {
const available = await safeExecute(
() => window.electronAPI.sillytavern.isNpxAvailable(),
'Failed to check npx availability:'
);
const isAvailable = available ?? false;
setIsNpxAvailable(isAvailable);
if (!isAvailable && FrontendPreference === 'sillytavern') {
await tryExecute(
() =>
window.electronAPI.config.set('frontendPreference', 'koboldcpp'),
'Failed to reset frontend preference:'
);
setFrontendPreference('koboldcpp');
}
};
if (FrontendPreference) {
checkNpxAvailability();
checkAllFrontendRequirements();
}
}, [FrontendPreference]);
}, [FrontendPreference, checkAllFrontendRequirements]);
const loadCurrentInstallDir = async () => {
const currentDir = await safeExecute(
@ -86,14 +169,15 @@ export const GeneralTab = () => {
};
const handleFrontendPreferenceChange = async (value: string | null) => {
if (!value || (value !== 'koboldcpp' && value !== 'sillytavern')) return;
if (!value || !['koboldcpp', 'sillytavern', 'openwebui'].includes(value))
return;
const success = await tryExecute(
() => window.electronAPI.config.set('frontendPreference', value),
'Failed to save frontend preference:'
);
if (success) {
setFrontendPreference(value);
setFrontendPreference(value as FrontendPreference);
}
};
@ -130,43 +214,76 @@ export const GeneralTab = () => {
<Text fw={500} mb="sm">
Frontend Interface
</Text>
{isNpxAvailable && (
{getUnmetRequirements().length === 0 && (
<Text size="sm" c="dimmed" mb="md">
Choose which frontend interface to use for interacting with AI
models
</Text>
)}
{!isNpxAvailable && (
{getUnmetRequirements().length > 0 && (
<Text size="sm" c="red" mb="md">
Custom frontends require{' '}
<Anchor
href="#"
onClick={(e) => {
e.preventDefault();
window.electronAPI.app.openExternal('https://nodejs.org/');
}}
c="red"
td="underline"
>
Node.js
</Anchor>{' '}
{getSelectedFrontendConfig()?.label} requires{' '}
{getUnmetRequirements().map((req, index) => (
<span key={req.id}>
<Anchor
href="#"
onClick={(e) => {
e.preventDefault();
window.electronAPI.app.openExternal(req.url);
}}
c="red"
td="underline"
>
{req.name}
</Anchor>
{index < getUnmetRequirements().length - 1 ? ', ' : ''}
</span>
))}{' '}
to be installed on your system
</Text>
)}
<Select
value={FrontendPreference}
onChange={handleFrontendPreferenceChange}
disabled={!isNpxAvailable}
data={[
{ value: 'koboldcpp', label: 'KoboldCpp (Built-in)' },
{
value: 'sillytavern',
label: FRONTENDS.SILLYTAVERN,
},
]}
data={frontendConfigs.map((config) => ({
value: config.value,
label: config.label,
disabled: !isFrontendAvailable(config.value),
}))}
leftSection={<Monitor style={{ width: rem(16), height: rem(16) }} />}
/>
<Box mt="sm">
{frontendConfigs
.filter((config) => !isFrontendAvailable(config.value))
.map((config) => {
const unmetReqs = getUnmetRequirementsForFrontend(config.value);
return (
<Text key={config.value} size="sm" c="orange" mb="xs">
{config.label} is disabled - requires{' '}
{unmetReqs.map((req, index) => (
<span key={req.id}>
<Anchor
href="#"
onClick={(e) => {
e.preventDefault();
window.electronAPI.app.openExternal(req.url);
}}
c="orange"
td="underline"
>
{req.name}
</Anchor>
{index < unmetReqs.length - 1 ? ', ' : ''}
</span>
))}
</Text>
);
})}
</Box>
</div>
</Stack>
);

View file

@ -13,3 +13,10 @@ export const SILLYTAVERN = {
return `http://localhost:${this.PROXY_PORT}`;
},
} as const;
export const OPENWEBUI = {
PORT: 8080,
get URL() {
return `http://localhost:${this.PORT}`;
},
} as const;

View file

@ -7,6 +7,7 @@ export const TITLEBAR_HEIGHT = '2.5rem';
export const SERVER_READY_SIGNALS = {
KOBOLDCPP: 'Please connect to custom endpoint at',
SILLYTAVERN: 'SillyTavern is listening on',
OPENWEBUI: 'Waiting for application startup.',
} as const;
export * from './defaults';
@ -45,6 +46,7 @@ export const FRONTENDS = {
KOBOLDAI_LITE: 'KoboldAI Lite',
STABLE_UI: 'Stable UI',
SILLYTAVERN: 'SillyTavern',
OPENWEBUI: 'Open WebUI',
} as const;
export const ZOOM = {

View file

@ -1,16 +1,14 @@
import { app } from 'electron';
import { join } from 'path';
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 { OpenWebUIManager } from '@/main/managers/OpenWebUIManager';
import { HardwareManager } from '@/main/managers/HardwareManager';
import { BinaryManager } from '@/main/managers/BinaryManager';
import { IPCHandlers } from '@/main/ipc';
import { PRODUCT_NAME } from '@/constants';
import { homedir } from 'os';
import { ensureDir } from '@/utils/fs';
import { getConfigDir } from '@/utils/path';
@ -20,6 +18,7 @@ export class GerbilApp {
private configManager: ConfigManager;
private logManager: LogManager;
private sillyTavernManager: SillyTavernManager;
private openWebUIManager: OpenWebUIManager;
private hardwareManager: HardwareManager;
private binaryManager: BinaryManager;
private ipcHandlers: IPCHandlers;
@ -49,6 +48,12 @@ export class GerbilApp {
this.windowManager
);
this.openWebUIManager = new OpenWebUIManager(
this.configManager,
this.logManager,
this.windowManager
);
this.ipcHandlers = new IPCHandlers(
this.koboldManager,
this.configManager,
@ -56,32 +61,13 @@ export class GerbilApp {
this.binaryManager,
this.logManager,
this.sillyTavernManager,
this.openWebUIManager,
this.windowManager
);
}
private getDefaultInstallDir(): string {
const platform = process.platform;
const home = homedir();
switch (platform) {
case 'win32':
return join(home, PRODUCT_NAME);
case 'darwin':
return join(home, 'Applications', PRODUCT_NAME);
default:
return join(home, '.local', 'share', PRODUCT_NAME);
}
}
private async ensureInstallDirectory(): Promise<void> {
const installDir =
this.configManager.getInstallDir() || this.getDefaultInstallDir();
if (!this.configManager.getInstallDir()) {
await this.configManager.setInstallDir(installDir);
}
const installDir = this.configManager.getInstallDir();
await ensureDir(installDir);
}
@ -90,10 +76,6 @@ export class GerbilApp {
await this.configManager.initialize();
await this.ensureInstallDirectory();
if (process.platform === 'linux') {
app.setAppUserModelId('com.gerbil.app');
}
this.windowManager.createMainWindow();
this.ipcHandlers.setupHandlers();
@ -112,6 +94,7 @@ export class GerbilApp {
const cleanupPromises = [
this.koboldManager.cleanup(),
this.sillyTavernManager.cleanup(),
this.openWebUIManager.cleanup(),
];
const timeoutPromise = new Promise<void>((resolve) => {

View file

@ -1,29 +1,49 @@
const isCliMode = process.argv.includes('--cli');
import { readJsonFile } from '@/utils/fs';
import { join } from 'path';
if (isCliMode) {
import('./cli')
.then(async (cliModule) => {
const args = process.argv.slice(process.argv.indexOf('--cli') + 1);
const handler = new cliModule.LightweightCliHandler();
try {
await handler.handleCliMode(args);
} catch (error) {
// eslint-disable-next-line no-console
console.error('CLI mode error:', error);
process.exit(1);
}
})
.catch((error) => {
if (process.argv[1] === '--version') {
(async () => {
try {
const packageJsonPath = join(__dirname, '../../package.json');
const packageJson = await readJsonFile<{ version: string }>(
packageJsonPath
);
// eslint-disable-next-line no-console
console.error('Failed to load CLI module:', error);
process.exit(1);
});
console.log(packageJson?.version || 'unknown');
} catch {
// eslint-disable-next-line no-console
console.log('unknown');
}
process.exit(0);
})();
} else {
import('./gui').then((guiModule) => {
const app = new guiModule.GerbilApp();
app.initialize().catch((error: unknown) => {
// eslint-disable-next-line no-console
console.error('Failed to initialize Gerbil:', error);
const isCliMode = process.argv.includes('--cli');
if (isCliMode) {
import('./cli')
.then(async (cliModule) => {
const args = process.argv.slice(process.argv.indexOf('--cli') + 1);
const handler = new cliModule.LightweightCliHandler();
try {
await handler.handleCliMode(args);
} catch (error) {
// eslint-disable-next-line no-console
console.error('CLI mode error:', error);
process.exit(1);
}
})
.catch((error) => {
// eslint-disable-next-line no-console
console.error('Failed to load CLI module:', error);
process.exit(1);
});
} else {
import('./gui').then((guiModule) => {
const app = new guiModule.GerbilApp();
app.initialize().catch((error: unknown) => {
// eslint-disable-next-line no-console
console.error('Failed to initialize Gerbil:', error);
});
});
});
}
}

View file

@ -4,6 +4,7 @@ import type { KoboldCppManager } from '@/main/managers/KoboldCppManager';
import type { ConfigManager } from '@/main/managers/ConfigManager';
import type { LogManager } from '@/main/managers/LogManager';
import type { SillyTavernManager } from '@/main/managers/SillyTavernManager';
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';
@ -13,6 +14,7 @@ export class IPCHandlers {
private configManager: ConfigManager;
private logManager: LogManager;
private sillyTavernManager: SillyTavernManager;
private openWebUIManager: OpenWebUIManager;
private hardwareManager: HardwareManager;
private binaryManager: BinaryManager;
private windowManager: WindowManager;
@ -24,12 +26,14 @@ export class IPCHandlers {
binaryManager: BinaryManager,
logManager: LogManager,
sillyTavernManager: SillyTavernManager,
openWebUIManager: OpenWebUIManager,
windowManager: WindowManager
) {
this.koboldManager = koboldManager;
this.configManager = configManager;
this.logManager = logManager;
this.sillyTavernManager = sillyTavernManager;
this.openWebUIManager = openWebUIManager;
this.hardwareManager = hardwareManager;
this.binaryManager = binaryManager;
this.windowManager = windowManager;
@ -44,6 +48,8 @@ export class IPCHandlers {
if (frontendPreference === 'sillytavern') {
this.sillyTavernManager.startFrontend(args);
} else if (frontendPreference === 'openwebui') {
this.openWebUIManager.startFrontend(args);
}
return result;
@ -133,6 +139,7 @@ export class IPCHandlers {
ipcMain.handle('kobold:stopKoboldCpp', () => {
this.koboldManager.stopKoboldCpp();
this.sillyTavernManager.stopFrontend();
this.openWebUIManager.stopFrontend();
});
ipcMain.handle('kobold:parseConfigFile', (_, filePath) =>
@ -240,5 +247,9 @@ export class IPCHandlers {
ipcMain.handle('sillytavern:isNpxAvailable', () =>
this.sillyTavernManager.isNpxAvailable()
);
ipcMain.handle('openwebui:isUvAvailable', () =>
this.openWebUIManager.isUvAvailable()
);
}
}

View file

@ -1,6 +1,9 @@
import { LogManager } from '@/main/managers/LogManager';
import { readJsonFile, writeJsonFile } from '@/utils/fs';
import type { FrontendPreference } from '@/types';
import { homedir } from 'os';
import { join } from 'path';
import { PRODUCT_NAME } from '@/constants';
type ConfigValue = string | number | boolean | unknown[] | undefined;
@ -53,8 +56,22 @@ export class ConfigManager {
await this.saveConfig();
}
getInstallDir(): string | undefined {
return this.config.installDir;
private getDefaultInstallDir(): string {
const platform = process.platform;
const home = homedir();
switch (platform) {
case 'win32':
return join(home, PRODUCT_NAME);
case 'darwin':
return join(home, 'Applications', PRODUCT_NAME);
default:
return join(home, '.local', 'share', PRODUCT_NAME);
}
}
getInstallDir(): string {
return this.config.installDir || this.getDefaultInstallDir();
}
async setInstallDir(dir: string): Promise<void> {

View file

@ -35,10 +35,6 @@ export class KoboldCppManager {
this.windowManager = windowManager;
}
private get installDir(): string {
return this.configManager.getInstallDir() || '';
}
private async removeDirectoryWithRetry(
dirPath: string,
maxRetries = 3,
@ -193,12 +189,18 @@ export class KoboldCppManager {
}
async downloadRelease(asset: GitHubAsset): Promise<string> {
const tempPackedFilePath = join(this.installDir, `${asset.name}.packed`);
const tempPackedFilePath = join(
this.configManager.getInstallDir(),
`${asset.name}.packed`
);
const baseFilename = stripAssetExtensions(asset.name);
const folderName = asset.version
? `${baseFilename}-${asset.version}`
: baseFilename;
const unpackedDirPath = join(this.installDir, folderName);
const unpackedDirPath = join(
this.configManager.getInstallDir(),
folderName
);
try {
await this.handleExistingDirectory(
@ -268,15 +270,16 @@ export class KoboldCppManager {
async getInstalledVersions(): Promise<InstalledVersion[]> {
try {
if (!(await pathExists(this.installDir))) {
const installDir = this.configManager.getInstallDir();
if (!(await pathExists(installDir))) {
return [];
}
const items = await readdir(this.installDir);
const items = await readdir(installDir);
const launchers: { path: string; filename: string; size: number }[] = [];
for (const item of items) {
const itemPath = join(this.installDir, item);
const itemPath = join(installDir, item);
const stats = await stat(itemPath);
if (stats.isDirectory()) {
@ -334,11 +337,12 @@ export class KoboldCppManager {
const configFiles: { name: string; path: string; size: number }[] = [];
try {
if (await pathExists(this.installDir)) {
const files = await readdir(this.installDir);
const installDir = this.configManager.getInstallDir();
if (await pathExists(installDir)) {
const files = await readdir(installDir);
for (const file of files) {
const filePath = join(this.installDir, file);
const filePath = join(installDir, file);
const stats = await stat(filePath);
if (
@ -384,12 +388,8 @@ export class KoboldCppManager {
configData: KoboldConfig
): Promise<boolean> {
try {
if (!this.installDir) {
this.logManager.logError('No install directory found');
return false;
}
const configPath = join(this.installDir, configFileName);
const installDir = this.configManager.getInstallDir();
const configPath = join(installDir, configFileName);
await writeJsonFile(configPath, configData);
return true;
} catch (error) {
@ -532,7 +532,7 @@ export class KoboldCppManager {
}
getCurrentInstallDir() {
return this.installDir;
return this.configManager.getInstallDir();
}
getWindowManager() {
@ -543,7 +543,7 @@ export class KoboldCppManager {
const result = await dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
title: `Select the ${PRODUCT_NAME} Installation Directory`,
defaultPath: this.installDir,
defaultPath: this.configManager.getInstallDir(),
buttonLabel: 'Select Directory',
});

View file

@ -0,0 +1,235 @@
import { spawn } from 'child_process';
import type { ChildProcess } from 'child_process';
import { join } from 'path';
import { LogManager } from './LogManager';
import { WindowManager } from './WindowManager';
import { ConfigManager } from './ConfigManager';
import { OPENWEBUI, SERVER_READY_SIGNALS } from '@/constants';
import { terminateProcess } from '@/utils/process';
import { parseKoboldConfig } from '@/utils/kobold';
export interface OpenWebUIConfig {
name: string;
port: number;
}
export class OpenWebUIManager {
private openWebUIProcess: ChildProcess | null = null;
private logManager: LogManager;
private windowManager: WindowManager;
private configManager: ConfigManager;
private static readonly OPENWEBUI_BASE_ARGS = [
'--python',
'3.11',
'open-webui@latest',
'serve',
];
constructor(
configManager: ConfigManager,
logManager: LogManager,
windowManager: WindowManager
) {
this.configManager = configManager;
this.logManager = logManager;
this.windowManager = windowManager;
process.on('SIGINT', () => {
this.cleanup().catch(() => {
void 0;
});
});
process.on('SIGTERM', () => {
this.cleanup().catch(() => {
void 0;
});
});
}
async isUvAvailable(): Promise<boolean> {
try {
const testProcess = spawn('uv', ['--version'], { stdio: 'pipe' });
return new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => {
testProcess.kill();
resolve(false);
}, 5000);
testProcess.on('exit', (code) => {
clearTimeout(timeout);
resolve(code === 0);
});
testProcess.on('error', () => {
clearTimeout(timeout);
resolve(false);
});
});
} catch {
return false;
}
}
private createUvProcess(
args: string[],
env?: Record<string, string>
): ChildProcess {
return spawn('uvx', args, {
stdio: ['pipe', 'pipe', 'pipe'],
detached: false,
env: { ...process.env, ...env },
});
}
private async waitForOpenWebUIToStart(): Promise<void> {
this.windowManager.sendKoboldOutput('Waiting for Open WebUI to start...');
return new Promise((resolve, reject) => {
const checkForOutput = (data: Buffer) => {
const output = data.toString();
if (output.includes(SERVER_READY_SIGNALS.OPENWEBUI)) {
this.windowManager.sendKoboldOutput('Open WebUI is now running!');
resolve();
if (this.openWebUIProcess?.stdout) {
this.openWebUIProcess.stdout.removeListener('data', checkForOutput);
}
}
};
if (this.openWebUIProcess?.stdout) {
this.openWebUIProcess.stdout.on('data', checkForOutput);
} else {
reject(new Error('Open WebUI process stdout not available'));
}
});
}
async startFrontend(args: string[]): Promise<void> {
try {
const config = {
name: 'openwebui',
port: OPENWEBUI.PORT,
};
const {
host: koboldHost,
port: koboldPort,
isImageMode,
} = 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;
}
await this.stopFrontend();
this.windowManager.sendKoboldOutput(
`Preparing Open WebUI to connect to KoboldCpp at ${koboldHost}:${koboldPort}...`
);
this.windowManager.sendKoboldOutput(
`Starting ${config.name} frontend on port ${config.port}...`
);
const koboldUrl = `http://${koboldHost}:${koboldPort}`;
const openWebUIArgs = [
...OpenWebUIManager.OPENWEBUI_BASE_ARGS,
'--port',
config.port.toString(),
];
this.windowManager.sendKoboldOutput('Starting Open WebUI with uv...');
const installDir = this.configManager.getInstallDir();
const openWebUIDataDir = join(installDir, 'openwebui-data');
this.openWebUIProcess = this.createUvProcess(openWebUIArgs, {
OPENAI_API_BASE_URL: `${koboldUrl}/v1`,
OPENAI_API_KEY: 'kobold',
DATA_DIR: openWebUIDataDir,
});
if (this.openWebUIProcess.stdout) {
this.openWebUIProcess.stdout.on('data', (data: Buffer) => {
this.windowManager.sendKoboldOutput(data.toString(), true);
});
}
if (this.openWebUIProcess.stderr) {
this.openWebUIProcess.stderr.on('data', (data: Buffer) => {
this.windowManager.sendKoboldOutput(data.toString(), true);
});
}
this.openWebUIProcess.on(
'exit',
(code: number | null, signal: string | null) => {
const message = signal
? `Open WebUI terminated with signal ${signal}`
: `Open WebUI exited with code ${code}`;
this.windowManager.sendKoboldOutput(message);
this.openWebUIProcess = null;
}
);
this.openWebUIProcess.on('error', (error) => {
this.logManager.logError('Open WebUI process error:', error);
this.windowManager.sendKoboldOutput(
`Open WebUI error: ${error.message}`
);
this.openWebUIProcess = null;
});
await this.waitForOpenWebUIToStart();
this.windowManager.sendKoboldOutput(
`Open WebUI is ready and auto-configured for KoboldCpp!`
);
this.windowManager.sendKoboldOutput(
`Access Open WebUI at: http://localhost:${config.port}`
);
this.windowManager.sendKoboldOutput(
`KoboldCpp connection: ${koboldUrl}/v1 (auto-configured)`
);
} catch (error) {
this.logManager.logError(
'Failed to start Open WebUI frontend:',
error as Error
);
this.windowManager.sendKoboldOutput(
`Failed to start Open WebUI: ${(error as Error).message}`
);
this.openWebUIProcess = null;
throw error;
}
}
async stopFrontend(): Promise<void> {
if (this.openWebUIProcess) {
this.windowManager.sendKoboldOutput('Stopping Open WebUI...');
try {
await terminateProcess(this.openWebUIProcess, {
logError: (message, error) =>
this.logManager.logError(message, error),
});
this.windowManager.sendKoboldOutput('Open WebUI stopped');
} catch (error) {
this.logManager.logError('Error stopping Open WebUI:', error as Error);
}
this.openWebUIProcess = null;
}
}
async cleanup(): Promise<void> {
await this.stopFrontend();
}
}

View file

@ -6,9 +6,10 @@ import type { ChildProcess } from 'child_process';
import { LogManager } from './LogManager';
import { WindowManager } from './WindowManager';
import { SILLYTAVERN } from '@/constants';
import { SILLYTAVERN, SERVER_READY_SIGNALS } from '@/constants';
import { terminateProcess } from '@/utils/process';
import { pathExists, readJsonFile, writeJsonFile } from '@/utils/fs';
import { parseKoboldConfig } from '@/utils/kobold';
export interface SillyTavernConfig {
name: string;
@ -76,39 +77,6 @@ export class SillyTavernManager {
}
const fallback = this.getFallbackDataRoot();
if (await pathExists(fallback)) {
this.detectedDataRoot = fallback;
return fallback;
}
const platform = process.platform;
const home = homedir();
const alternatePaths = [];
switch (platform) {
case 'win32':
alternatePaths.push(
join(home, 'AppData', 'Local', 'SillyTavern', 'data'),
join(home, '.sillytavern', 'data')
);
break;
case 'darwin':
alternatePaths.push(join(home, '.sillytavern', 'data'));
break;
case 'linux':
default:
alternatePaths.push(join(home, '.sillytavern', 'data'));
break;
}
for (const path of alternatePaths) {
if (await pathExists(path)) {
this.detectedDataRoot = path;
return path;
}
}
this.detectedDataRoot = fallback;
return fallback;
}
@ -220,7 +188,7 @@ export class SillyTavernManager {
initProcess.stdout.on('data', (data: Buffer) => {
const output = data.toString();
if (output.includes('SillyTavern is listening')) {
if (output.includes(SERVER_READY_SIGNALS.SILLYTAVERN)) {
setTimeout(async () => {
if (!initProcess.killed && !hasResolved) {
hasResolved = true;
@ -249,33 +217,6 @@ export class SillyTavernManager {
});
}
private parseKoboldConfig(args: string[]): {
host: string;
port: number;
isImageMode: boolean;
} {
let host = 'localhost';
let port = 5001;
let hasSdModel = false;
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;
}
} else if (args[i] === '--sdmodel') {
hasSdModel = true;
}
}
const isImageMode = hasSdModel;
return { host, port, isImageMode };
}
private async setupSillyTavernConfig(
koboldHost: string,
koboldPort: number,
@ -357,13 +298,8 @@ export class SillyTavernManager {
this.windowManager.sendKoboldOutput('Waiting for SillyTavern to start...');
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('SillyTavern failed to start within 30 seconds'));
}, 30000);
const checkForOutput = (data: Buffer) => {
if (data.toString().includes('SillyTavern is listening')) {
clearTimeout(timeout);
if (data.toString().includes(SERVER_READY_SIGNALS.SILLYTAVERN)) {
this.windowManager.sendKoboldOutput('SillyTavern is now running!');
resolve();
@ -379,7 +315,6 @@ export class SillyTavernManager {
if (this.sillyTavernProcess?.stdout) {
this.sillyTavernProcess.stdout.on('data', checkForOutput);
} else {
clearTimeout(timeout);
reject(new Error('SillyTavern process stdout not available'));
}
});
@ -433,7 +368,7 @@ export class SillyTavernManager {
host: koboldHost,
port: koboldPort,
isImageMode,
} = this.parseKoboldConfig(args);
} = parseKoboldConfig(args);
await this.stopFrontend();

View file

@ -5,6 +5,7 @@ import type {
ConfigAPI,
LogsAPI,
SillyTavernAPI,
OpenWebUIAPI,
} from '@/types/electron';
const koboldAPI: KoboldAPI = {
@ -100,10 +101,15 @@ const sillyTavernAPI: SillyTavernAPI = {
isNpxAvailable: () => ipcRenderer.invoke('sillytavern:isNpxAvailable'),
};
const openwebUIAPI: OpenWebUIAPI = {
isUvAvailable: () => ipcRenderer.invoke('openwebui:isUvAvailable'),
};
contextBridge.exposeInMainWorld('electronAPI', {
kobold: koboldAPI,
app: appAPI,
config: configAPI,
logs: logsAPI,
sillytavern: sillyTavernAPI,
openwebui: openwebUIAPI,
});

View file

@ -166,6 +166,10 @@ export interface SillyTavernAPI {
isNpxAvailable: () => Promise<boolean>;
}
export interface OpenWebUIAPI {
isUvAvailable: () => Promise<boolean>;
}
declare global {
interface Window {
electronAPI: {
@ -174,6 +178,7 @@ declare global {
config: ConfigAPI;
logs: LogsAPI;
sillytavern: SillyTavernAPI;
openwebui: OpenWebUIAPI;
};
}
}

View file

@ -8,7 +8,7 @@ export type InterfaceTab = 'terminal' | 'chat';
export type SdConvDirectMode = 'off' | 'vaeonly' | 'full';
export type FrontendPreference = 'koboldcpp' | 'sillytavern';
export type FrontendPreference = 'koboldcpp' | 'sillytavern' | 'openwebui';
export type Screen = 'welcome' | 'download' | 'launch' | 'interface';

28
src/utils/kobold.ts Normal file
View file

@ -0,0 +1,28 @@
export interface KoboldConfig {
host: string;
port: number;
isImageMode: boolean;
}
export function parseKoboldConfig(args: string[]): KoboldConfig {
let host = 'localhost';
let port = 5001;
let hasSdModel = false;
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;
}
} else if (args[i] === '--sdmodel') {
hasSdModel = true;
}
}
const isImageMode = hasSdModel;
return { host, port, isImageMode };
}

142
yarn.lock
View file

@ -1358,106 +1358,106 @@ __metadata:
languageName: node
linkType: hard
"@typescript-eslint/eslint-plugin@npm:^8.41.0":
version: 8.41.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.41.0"
"@typescript-eslint/eslint-plugin@npm:^8.42.0":
version: 8.42.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.42.0"
dependencies:
"@eslint-community/regexpp": "npm:^4.10.0"
"@typescript-eslint/scope-manager": "npm:8.41.0"
"@typescript-eslint/type-utils": "npm:8.41.0"
"@typescript-eslint/utils": "npm:8.41.0"
"@typescript-eslint/visitor-keys": "npm:8.41.0"
"@typescript-eslint/scope-manager": "npm:8.42.0"
"@typescript-eslint/type-utils": "npm:8.42.0"
"@typescript-eslint/utils": "npm:8.42.0"
"@typescript-eslint/visitor-keys": "npm:8.42.0"
graphemer: "npm:^1.4.0"
ignore: "npm:^7.0.0"
natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.1.0"
peerDependencies:
"@typescript-eslint/parser": ^8.41.0
"@typescript-eslint/parser": ^8.42.0
eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/29812ee5deeae65e67db29faa8d96bc70255c45788f342b11838850ea29a96e4331622cad3e703ffacaa895372845d44fd6b04786117c78f1a027595adff2e62
checksum: 10c0/835fd7497f0e4eaef55dc3d94079acc0ad1dc74735916915f160419b1e7f44d04fbce683b4871148d1af33046bd5ae3fed59103d4c49460776b560c42173bbff
languageName: node
linkType: hard
"@typescript-eslint/parser@npm:^8.41.0":
version: 8.41.0
resolution: "@typescript-eslint/parser@npm:8.41.0"
"@typescript-eslint/parser@npm:^8.42.0":
version: 8.42.0
resolution: "@typescript-eslint/parser@npm:8.42.0"
dependencies:
"@typescript-eslint/scope-manager": "npm:8.41.0"
"@typescript-eslint/types": "npm:8.41.0"
"@typescript-eslint/typescript-estree": "npm:8.41.0"
"@typescript-eslint/visitor-keys": "npm:8.41.0"
"@typescript-eslint/scope-manager": "npm:8.42.0"
"@typescript-eslint/types": "npm:8.42.0"
"@typescript-eslint/typescript-estree": "npm:8.42.0"
"@typescript-eslint/visitor-keys": "npm:8.42.0"
debug: "npm:^4.3.4"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/ca13ff505e9253aee761741f96714cd65a296bbfcac961efbbf7a909ff3d180b2142a23db0a2a5e50b928fa56586528b7e47ba6301089dd850945018dbf2ef50
checksum: 10c0/f071154bce7f874449236919a7367d977317959fe6d454fe5369ca54dee7d057fe3b8b250c5990ea4205a9c52fd59702da63d1721895c72d745168aa31532112
languageName: node
linkType: hard
"@typescript-eslint/project-service@npm:8.41.0":
version: 8.41.0
resolution: "@typescript-eslint/project-service@npm:8.41.0"
"@typescript-eslint/project-service@npm:8.42.0":
version: 8.42.0
resolution: "@typescript-eslint/project-service@npm:8.42.0"
dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.41.0"
"@typescript-eslint/types": "npm:^8.41.0"
"@typescript-eslint/tsconfig-utils": "npm:^8.42.0"
"@typescript-eslint/types": "npm:^8.42.0"
debug: "npm:^4.3.4"
peerDependencies:
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/907ba880fcaf0805fc97012b431536b5b06db6ae4a0095708f9d9a4406feddabd964f09ea4ca99d8fa7bd141dbcc9496f1a9eb6683361a6bb01fb714a361126c
checksum: 10c0/788b0bc52683be376cd768a4fed3202cdaccc86f231ec94a0f6bbb1389fdfd0e14c505f03015cefb73869de63c8089b78a169ed957048a1e5ee1b6250ec19604
languageName: node
linkType: hard
"@typescript-eslint/scope-manager@npm:8.41.0":
version: 8.41.0
resolution: "@typescript-eslint/scope-manager@npm:8.41.0"
"@typescript-eslint/scope-manager@npm:8.42.0":
version: 8.42.0
resolution: "@typescript-eslint/scope-manager@npm:8.42.0"
dependencies:
"@typescript-eslint/types": "npm:8.41.0"
"@typescript-eslint/visitor-keys": "npm:8.41.0"
checksum: 10c0/6b339ac1fc37a1e05dc6de421db9f9b138c357497ec87af2471ad30e48c78b4979d3da40943a1c81fc85d1537326a4f938843434db63d29eff414b9364daf8e8
"@typescript-eslint/types": "npm:8.42.0"
"@typescript-eslint/visitor-keys": "npm:8.42.0"
checksum: 10c0/caca15f2124909c588ed3e48fe0769ad8baa296a0b229f724ec94f5f746e486e08dd49eeddd66d01f09e2ddaed03f9e18d7b535a44196d413f283e22f929f623
languageName: node
linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.41.0, @typescript-eslint/tsconfig-utils@npm:^8.41.0":
version: 8.41.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.41.0"
"@typescript-eslint/tsconfig-utils@npm:8.42.0, @typescript-eslint/tsconfig-utils@npm:^8.42.0":
version: 8.42.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.42.0"
peerDependencies:
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/98618a536b9cb071eacba2970ce2ca1b9243de78f4604c2e350823a5275b9d7d15238dbe6acd197c30c0b6cbbf37782c247d14984e1015a109431e4180d76af6
checksum: 10c0/03882eeee279fafa2cb4ee3154742417fd29395b3bfe3f867d9d4cb9cb68d1200c885c35b96dd558a1aff8561ac3700cff8ca7680a5cf34e5e0e136a6ee3c30c
languageName: node
linkType: hard
"@typescript-eslint/type-utils@npm:8.41.0":
version: 8.41.0
resolution: "@typescript-eslint/type-utils@npm:8.41.0"
"@typescript-eslint/type-utils@npm:8.42.0":
version: 8.42.0
resolution: "@typescript-eslint/type-utils@npm:8.42.0"
dependencies:
"@typescript-eslint/types": "npm:8.41.0"
"@typescript-eslint/typescript-estree": "npm:8.41.0"
"@typescript-eslint/utils": "npm:8.41.0"
"@typescript-eslint/types": "npm:8.42.0"
"@typescript-eslint/typescript-estree": "npm:8.42.0"
"@typescript-eslint/utils": "npm:8.42.0"
debug: "npm:^4.3.4"
ts-api-utils: "npm:^2.1.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/d4f9ae07a30f1cf331c3e3a67f8749b38f199ba5000f7a600492c27f6bec774f15c3553f293c520fb999fb88108665f2785d5261daec1445b17af14a7bb0bfac
checksum: 10c0/47e5f7276cafd7719d3e2f2e456fa988927e658d15c2c188a692d9c639f9d76f582a6c133cb1bf01eba9027e1022eb6b79b57861a96302460e5e847c2b536afa
languageName: node
linkType: hard
"@typescript-eslint/types@npm:8.41.0, @typescript-eslint/types@npm:^8.41.0":
version: 8.41.0
resolution: "@typescript-eslint/types@npm:8.41.0"
checksum: 10c0/4945a7ed7789e0527833ee378b962416d6d0d61eb6c891fe49cb6c8dc8a9adbfc58676080ca767a1f034f74f9a981caf5f4d4706cba5025c0520a801fb45d7e1
"@typescript-eslint/types@npm:8.42.0, @typescript-eslint/types@npm:^8.42.0":
version: 8.42.0
resolution: "@typescript-eslint/types@npm:8.42.0"
checksum: 10c0/d585dff5005328282cc59f9402e886a3db64727906ad3e68b49d7ef73bc07bef3ed569287ba826ebaa07b69be42a72232a38529951d64c28cebd83db0892cd33
languageName: node
linkType: hard
"@typescript-eslint/typescript-estree@npm:8.41.0":
version: 8.41.0
resolution: "@typescript-eslint/typescript-estree@npm:8.41.0"
"@typescript-eslint/typescript-estree@npm:8.42.0":
version: 8.42.0
resolution: "@typescript-eslint/typescript-estree@npm:8.42.0"
dependencies:
"@typescript-eslint/project-service": "npm:8.41.0"
"@typescript-eslint/tsconfig-utils": "npm:8.41.0"
"@typescript-eslint/types": "npm:8.41.0"
"@typescript-eslint/visitor-keys": "npm:8.41.0"
"@typescript-eslint/project-service": "npm:8.42.0"
"@typescript-eslint/tsconfig-utils": "npm:8.42.0"
"@typescript-eslint/types": "npm:8.42.0"
"@typescript-eslint/visitor-keys": "npm:8.42.0"
debug: "npm:^4.3.4"
fast-glob: "npm:^3.3.2"
is-glob: "npm:^4.0.3"
@ -1466,32 +1466,32 @@ __metadata:
ts-api-utils: "npm:^2.1.0"
peerDependencies:
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/e86233d895403ec4986ced25f56898b2704a84545bb7dfe933f5c64f2ab969dcb7ada7e21ea7e015c875cc94a0767e70573442724960c631b7b3fc556a984c9c
checksum: 10c0/2d3354d780421cfa90f812048984c43cd47aabecef7a5c0f56ad0b91331cb369d1c8366da90bf9a8f6df47df3741f9e16897e998f16270ac55376f519b775c23
languageName: node
linkType: hard
"@typescript-eslint/utils@npm:8.41.0":
version: 8.41.0
resolution: "@typescript-eslint/utils@npm:8.41.0"
"@typescript-eslint/utils@npm:8.42.0":
version: 8.42.0
resolution: "@typescript-eslint/utils@npm:8.42.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.7.0"
"@typescript-eslint/scope-manager": "npm:8.41.0"
"@typescript-eslint/types": "npm:8.41.0"
"@typescript-eslint/typescript-estree": "npm:8.41.0"
"@typescript-eslint/scope-manager": "npm:8.42.0"
"@typescript-eslint/types": "npm:8.42.0"
"@typescript-eslint/typescript-estree": "npm:8.42.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/3a2ed9b5f801afeccde44dbacdeae0b9c82cc3e1af5e92926929ad86384dc0fb0027152e68c5edfabe904647c2160c0c45ec9c848a8d67c3efb86b78a1343acb
checksum: 10c0/acf30019023669ddae00c02cabfa74fc12defccd4703e552ab5115edbeceaaf1688c1586873bf66aefeb3f03eb1ed456905403303913c724db38bf030e40a700
languageName: node
linkType: hard
"@typescript-eslint/visitor-keys@npm:8.41.0":
version: 8.41.0
resolution: "@typescript-eslint/visitor-keys@npm:8.41.0"
"@typescript-eslint/visitor-keys@npm:8.42.0":
version: 8.42.0
resolution: "@typescript-eslint/visitor-keys@npm:8.42.0"
dependencies:
"@typescript-eslint/types": "npm:8.41.0"
"@typescript-eslint/types": "npm:8.42.0"
eslint-visitor-keys: "npm:^4.2.1"
checksum: 10c0/cfe52e77b9e07c23a4d9f4adf9e6bf27822e58694c9a34fefa4b9fc96d553e9df561971c4da5fc78392522e34696fc1149a76f6a02c328136771c5efe0fd1029
checksum: 10c0/22c942f2a100d71c08f952b976446e824ddf227d4ac02b7016e12d4a33804ab06072d570695baed3565d0a08a1d3fa6ff3ccf97a122d63e65780f871d597f1b1
languageName: node
linkType: hard
@ -2747,16 +2747,16 @@ __metadata:
languageName: node
linkType: hard
"electron@npm:^37.4.0":
version: 37.4.0
resolution: "electron@npm:37.4.0"
"electron@npm:^38.0.0":
version: 38.0.0
resolution: "electron@npm:38.0.0"
dependencies:
"@electron/get": "npm:^2.0.0"
"@types/node": "npm:^22.7.7"
extract-zip: "npm:^2.0.1"
bin:
electron: cli.js
checksum: 10c0/92a0c41190e234d302bc612af6cce9af08cd07f6699c1ff21a9365297e73dc9d88c6c4c25ddabf352447e3e555878d2ab0f2f31a14e210dda6de74d2787ff323
checksum: 10c0/df3e4eaead2b0de4612a1b593f46667dfd4aff4755727371b5630aada40094cddf82b3837ce0ba3babed8c503275da59187f11da9f4ab26cd20e1362dc103ea6
languageName: node
linkType: hard
@ -3711,12 +3711,12 @@ __metadata:
"@types/node": "npm:^24.3.0"
"@types/react": "npm:^19.1.12"
"@types/react-dom": "npm:^19.1.9"
"@typescript-eslint/eslint-plugin": "npm:^8.41.0"
"@typescript-eslint/parser": "npm:^8.41.0"
"@typescript-eslint/eslint-plugin": "npm:^8.42.0"
"@typescript-eslint/parser": "npm:^8.42.0"
"@vitejs/plugin-react": "npm:^5.0.2"
axios: "npm:^1.11.0"
cross-env: "npm:^10.0.0"
electron: "npm:^37.4.0"
electron: "npm:^38.0.0"
electron-builder: "npm:^26.0.12"
electron-vite: "npm:^4.0.0"
eslint: "npm:^9.34.0"