custom frontend users will require to pre-install Node.js, welcome screen clarifications, don't package mac entitlements for other platforms

This commit is contained in:
Egor 2025-08-28 01:47:34 -07:00
parent a559f8c86d
commit 1b392be5f0
29 changed files with 323 additions and 1231 deletions

View file

@ -1,7 +1,7 @@
# Friendly Kobold
A desktop app for running Large Language Models locally. <!-- markdownlint-disable MD033 -->
<img src="assets/icon.png" alt="FriendlyKobold Icon" width="32" height="32">
<img src="src/assets/icon.png" alt="FriendlyKobold Icon" width="32" height="32">
<!-- markdownlint-enable MD033 -->

View file

@ -27,7 +27,7 @@ export default defineConfig({
},
renderer: {
root: '.',
publicDir: 'assets',
publicDir: 'src/assets',
build: {
outDir: 'dist',
rollupOptions: {

View file

@ -85,7 +85,6 @@
"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",
"systeminformation": "^5.27.8",
@ -97,7 +96,7 @@
"appId": "com.friendly-kobold.app",
"productName": "Friendly Kobold",
"compression": "normal",
"icon": "assets/icon.png",
"icon": "src/assets/icon.png",
"publish": null,
"electronLanguages": [
"en-US"
@ -107,18 +106,18 @@
},
"files": [
"out/**/*",
"dist/**/*",
"assets/**/*"
"dist/**/*"
],
"extraFiles": [
{
"from": "assets/friendly-kobold.desktop",
"to": "assets/friendly-kobold.desktop",
"filter": [
"**/*"
]
}
],
"extraResources": [
{
"from": "assets",
"to": "assets",
"filter": [
"**/*",
"!screenshots/**/*"
]
},
{
"from": "src/main/templates",
"to": "templates",
@ -134,6 +133,12 @@
"gatekeeperAssess": false,
"entitlements": "assets/entitlements.mac.plist",
"entitlementsInherit": "assets/entitlements.mac.plist",
"extraFiles": [
{
"from": "assets/entitlements.mac.plist",
"to": "assets/entitlements.mac.plist"
}
],
"target": [
{
"target": "dmg",

View file

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 235 KiB

View file

@ -11,7 +11,7 @@ import {
Tooltip,
} from '@mantine/core';
import { Settings, ArrowLeft } from 'lucide-react';
import { soundAssets, playSound, initializeAudio } from '@/utils';
import { soundAssets, playSound, initializeAudio } from '@/utils/sounds';
import iconUrl from '/icon.png';
import { FRONTENDS } from '@/constants';
import type { InterfaceTab, FrontendPreference, Screen } from '@/types';

View file

@ -1,7 +1,7 @@
import { Group, TextInput, Button } from '@mantine/core';
import { File, Search } from 'lucide-react';
import { LabelWithTooltip } from '@/components/LabelWithTooltip';
import { getInputValidationState } from '@/utils';
import { getInputValidationState } from '@/utils/validation';
import styles from '@/styles/layout.module.css';
interface ModelFileFieldProps {

View file

@ -1,7 +1,8 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { Card, Text, Title, Loader, Stack, Container } from '@mantine/core';
import { DownloadCard } from '@/components/DownloadCard';
import { getPlatformDisplayName, formatDownloadSize } from '@/utils';
import { getPlatformDisplayName } from '@/utils/platform';
import { formatDownloadSize } from '@/utils/downloadUtils';
import { getAssetDescription, sortDownloadsByType } from '@/utils/assets';
import { useKoboldVersions } from '@/hooks/useKoboldVersions';
import type { DownloadItem } from '@/types/electron';

View file

@ -9,7 +9,8 @@ import {
import { ChevronDown } from 'lucide-react';
import styles from '@/styles/layout.module.css';
import { UI } from '@/constants';
import { handleTerminalOutput, processTerminalContent } from '@/utils';
import { handleTerminalOutput } from '@/utils/terminal';
import { processTerminalContent } from '@/utils/linkifyTerminal';
import { useLaunchConfigStore } from '@/stores/launchConfigStore';
interface TerminalTabProps {

View file

@ -2,7 +2,7 @@ import { Stack } from '@mantine/core';
import { useState } from 'react';
import { ModelFileField } from '@/components/ModelFileField';
import { SelectWithTooltip } from '@/components/SelectWithTooltip';
import { IMAGE_MODEL_PRESETS } from '@/utils';
import { IMAGE_MODEL_PRESETS } from '@/constants/imageModelPresets';
import { useLaunchConfig } from '@/hooks/useLaunchConfig';
export const ImageGenerationTab = () => {

View file

@ -73,7 +73,22 @@ export const WelcomeScreen = ({ onGetStarted }: WelcomeScreenProps) => (
- Use powerful AI models for free once downloaded
</Text>
</List.Item>
<List.Item>
<Text>
<Text component="span" fw={500}>
Hardware acceleration
</Text>{' '}
- Supports CUDA, ROCm, Vulkan, and CLBlast backends for faster
inference
</Text>
</List.Item>
</List>
<Text size="xs" c="dimmed" ta="center" mt="sm">
Note: Hardware acceleration requires appropriate drivers to be
manually installed by the user (CUDA for NVIDIA GPUs, ROCm for AMD
GPUs, etc.)
</Text>
</Stack>
<Button size="lg" onClick={onGetStarted}>

View file

@ -7,6 +7,7 @@ import {
Button,
rem,
Select,
Anchor,
} from '@mantine/core';
import { Folder, FolderOpen, Monitor } from 'lucide-react';
import styles from '@/styles/layout.module.css';
@ -17,12 +18,50 @@ export const GeneralTab = () => {
const [installDir, setInstallDir] = useState<string>('');
const [FrontendPreference, setFrontendPreference] =
useState<FrontendPreference>('koboldcpp');
const [isNpxAvailable, setIsNpxAvailable] = useState<boolean>(true);
useEffect(() => {
loadCurrentInstallDir();
loadFrontendPreference();
const initialize = async () => {
await Promise.all([loadCurrentInstallDir(), loadFrontendPreference()]);
};
initialize();
}, []);
useEffect(() => {
const checkNpxAvailability = async () => {
try {
const available = await window.electronAPI.sillytavern.isNpxAvailable();
setIsNpxAvailable(available);
if (!available && FrontendPreference === 'sillytavern') {
await window.electronAPI.config.set(
'frontendPreference',
'koboldcpp'
);
setFrontendPreference('koboldcpp');
}
} catch (error) {
window.electronAPI.logs.logError(
'Failed to check npx availability:',
error as Error
);
setIsNpxAvailable(false);
if (FrontendPreference === 'sillytavern') {
await window.electronAPI.config.set(
'frontendPreference',
'koboldcpp'
);
setFrontendPreference('koboldcpp');
}
}
};
if (FrontendPreference) {
checkNpxAvailability();
}
}, [FrontendPreference]);
const loadCurrentInstallDir = async () => {
try {
const currentDir = await window.electronAPI.kobold.getCurrentInstallDir();
@ -112,12 +151,33 @@ export const GeneralTab = () => {
<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>
{isNpxAvailable && (
<Text size="sm" c="dimmed" mb="md">
Choose which frontend interface to use for interacting with models
</Text>
)}
{!isNpxAvailable && (
<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>{' '}
to be installed on your system
</Text>
)}
<Select
value={FrontendPreference}
onChange={handleFrontendPreferenceChange}
disabled={!isNpxAvailable}
data={[
{ value: 'koboldcpp', label: 'KoboldCpp (Built-in)' },
{

View file

@ -15,10 +15,9 @@ import { DownloadCard } from '@/components/DownloadCard';
import { getAssetDescription, sortDownloadsByType } from '@/utils/assets';
import {
getDisplayNameFromPath,
formatDownloadSize,
stripAssetExtensions,
compareVersions,
} from '@/utils';
} from '@/utils/versionUtils';
import { formatDownloadSize, compareVersions } from '@/utils/downloadUtils';
import { useKoboldVersions } from '@/hooks/useKoboldVersions';
import type { InstalledVersion, ReleaseWithStatus } from '@/types/electron';

View file

@ -1,8 +1,8 @@
import { useLaunchConfigStore } from '@/stores/launchConfigStore';
import {
IMAGE_MODEL_PRESETS,
type ImageModelPreset,
} from '@/utils/imageModelPresets';
IMAGE_MODEL_PRESETS,
} from '@/constants/imageModelPresets';
export const useLaunchConfig = () => {
const state = useLaunchConfigStore();

View file

@ -1,6 +1,6 @@
import { useState, useCallback, useEffect } from 'react';
import { getDisplayNameFromPath } from '@/utils/versionUtils';
import { compareVersions } from '@/utils';
import { compareVersions } from '@/utils/downloadUtils';
import type { InstalledVersion, DownloadItem } from '@/types/electron';
interface UpdateInfo {

View file

@ -52,7 +52,7 @@ export class IPCHandlers {
await this.sillyTavernManager.startFrontend(args);
} catch (error) {
this.logManager.logError(
'Failed to setup SillyTavern text frontend:',
'Failed to setup SillyTavern:',
error as Error
);
}
@ -247,5 +247,9 @@ export class IPCHandlers {
this.logManager.logError(message, error);
}
);
ipcMain.handle('sillytavern:isNpxAvailable', () =>
this.sillyTavernManager.isNpxAvailable()
);
}
}

View file

@ -22,7 +22,7 @@ import { LogManager } from '@/main/managers/LogManager';
import { WindowManager } from '@/main/managers/WindowManager';
import { ROCM, PRODUCT_NAME } from '@/constants';
import { stripAssetExtensions } from '@/utils/versionUtils';
import { compareVersions } from '@/utils';
import { compareVersions } from '@/utils/downloadUtils';
import type {
DownloadItem,
GitHubAsset,

View file

@ -1,6 +1,5 @@
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';
@ -9,7 +8,6 @@ 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;
@ -79,6 +77,31 @@ export class SillyTavernManager {
}
}
async isNpxAvailable(): Promise<boolean> {
try {
const testProcess = spawn('npx', ['--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 async ensureSillyTavernSettings(): Promise<void> {
const settingsPath = this.getSillyTavernSettingsPath();
@ -91,22 +114,17 @@ export class SillyTavernManager {
'SillyTavern settings not found, starting SillyTavern briefly to generate config...'
);
const bundledNpxPath = getPlatformPathFromWindowsPath(
app.getAppPath(),
'npx.cmd'
const spawnArgs = ['sillytavern', '--browserLaunchEnabled', 'false'];
this.windowManager.sendKoboldOutput(
`Running command: npx ${spawnArgs.join(' ')}`
);
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,
}
);
const initProcess = spawn('npx', spawnArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
detached: false,
});
let hasResolved = false;
@ -127,8 +145,12 @@ export class SillyTavernManager {
cleanupAndResolve();
}, 20000);
initProcess.on('exit', () => {
initProcess.on('exit', (code: number | null, signal: string | null) => {
clearTimeout(timeout);
const exitMessage = signal
? `SillyTavern init process terminated with signal ${signal}`
: `SillyTavern init process exited with code ${code}`;
this.windowManager.sendKoboldOutput(exitMessage);
cleanupAndResolve();
});
@ -140,6 +162,9 @@ export class SillyTavernManager {
'Failed to initialize SillyTavern settings:',
error
);
this.windowManager.sendKoboldOutput(
`SillyTavern initialization error: ${error.message}`
);
reject(error);
}
});
@ -147,6 +172,9 @@ export class SillyTavernManager {
if (initProcess.stdout) {
initProcess.stdout.on('data', (data: Buffer) => {
const output = data.toString();
this.windowManager.sendKoboldOutput(
`[SillyTavern stdout]: ${output.trim()}`
);
if (output.includes('SillyTavern is listening')) {
setTimeout(() => {
if (!initProcess.killed && !hasResolved) {
@ -158,6 +186,15 @@ export class SillyTavernManager {
});
}
if (initProcess.stderr) {
initProcess.stderr.on('data', (data: Buffer) => {
const output = data.toString();
this.windowManager.sendKoboldOutput(
`[SillyTavern stderr]: ${output.trim()}`
);
});
}
setTimeout(() => {
if (!initProcess.killed && !hasResolved) {
this.windowManager.sendKoboldOutput(
@ -166,7 +203,7 @@ export class SillyTavernManager {
initProcess.kill('SIGTERM');
cleanupAndResolve();
}
}, 8000);
}, 10000);
});
}
@ -331,16 +368,7 @@ export class SillyTavernManager {
'false',
];
const bundledNpxPath = getPlatformPathFromWindowsPath(
app.getAppPath(),
'npx.cmd'
);
this.windowManager.sendKoboldOutput(
`Using bundled npx: ${bundledNpxPath}`
);
this.sillyTavernProcess = spawn(bundledNpxPath, sillyTavernArgs, {
this.sillyTavernProcess = spawn('npx', sillyTavernArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
detached: false,
});

View file

@ -1,7 +1,7 @@
import type { GitHubRelease, DownloadItem } from '@/types/electron';
import { LogManager } from '@/main/managers/LogManager';
import { GITHUB_API } from '@/constants';
import { filterAssetsByPlatform } from '@/utils';
import { filterAssetsByPlatform } from '@/utils/platform';
export class GitHubService {
private lastApiCall = 0;

View file

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

View file

@ -1,5 +1,11 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron';
import type { KoboldAPI, AppAPI, ConfigAPI, LogsAPI } from '@/types/electron';
import type {
KoboldAPI,
AppAPI,
ConfigAPI,
LogsAPI,
SillyTavernAPI,
} from '@/types/electron';
const koboldAPI: KoboldAPI = {
getInstalledVersions: () => ipcRenderer.invoke('kobold:getInstalledVersions'),
@ -116,9 +122,14 @@ const logsAPI: LogsAPI = {
ipcRenderer.invoke('logs:logError', message, error),
};
const sillyTavernAPI: SillyTavernAPI = {
isNpxAvailable: () => ipcRenderer.invoke('sillytavern:isNpxAvailable'),
};
contextBridge.exposeInMainWorld('electronAPI', {
kobold: koboldAPI,
app: appAPI,
config: configAPI,
logs: logsAPI,
sillytavern: sillyTavernAPI,
});

View file

@ -1,6 +1,6 @@
import { create } from 'zustand';
import type { ConfigFile, SdConvDirectMode } from '@/types';
import type { ImageModelPreset } from '@/utils/imageModelPresets';
import type { ImageModelPreset } from '@/constants/imageModelPresets';
import { DEFAULT_CONTEXT_SIZE } from '@/constants';
interface LaunchConfigState {

View file

@ -156,6 +156,10 @@ export interface LogsAPI {
logError: (message: string, error?: Error) => Promise<void>;
}
export interface SillyTavernAPI {
isNpxAvailable: () => Promise<boolean>;
}
declare global {
interface Window {
electronAPI: {
@ -163,6 +167,7 @@ declare global {
app: AppAPI;
config: ConfigAPI;
logs: LogsAPI;
sillytavern: SillyTavernAPI;
};
}
}

View file

@ -1,9 +0,0 @@
export * from './assets';
export * from './downloadUtils';
export * from './imageModelPresets';
export * from './linkifyTerminal';
export * from './platform';
export * from './sounds';
export * from './terminal';
export * from './validation';
export * from './versionUtils';

View file

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

View file

@ -1,9 +1,9 @@
import elephantSoundUrl from '@/assets/sounds/elephant-trunk.mp3';
import mouseSqueak1Url from '@/assets/sounds/mouse-squeak1.mp3';
import mouseSqueak2Url from '@/assets/sounds/mouse-squeak2.mp3';
import mouseSqueak3Url from '@/assets/sounds/mouse-squeak3.mp3';
import mouseSqueak4Url from '@/assets/sounds/mouse-squeak4.mp3';
import mouseSqueak5Url from '@/assets/sounds/mouse-squeak5.mp3';
import elephantSoundUrl from '/sounds/elephant-trunk.mp3';
import mouseSqueak1Url from '/sounds/mouse-squeak1.mp3';
import mouseSqueak2Url from '/sounds/mouse-squeak2.mp3';
import mouseSqueak3Url from '/sounds/mouse-squeak3.mp3';
import mouseSqueak4Url from '/sounds/mouse-squeak4.mp3';
import mouseSqueak5Url from '/sounds/mouse-squeak5.mp3';
export const soundAssets = {
elephant: elephantSoundUrl,

1276
yarn.lock

File diff suppressed because it is too large Load diff