making it better and refactoring, digging through the AI generated slop

This commit is contained in:
Egor 2025-08-13 00:02:47 -07:00
parent 678acb48b8
commit d1b76772be
37 changed files with 2706 additions and 1924 deletions

71
.github/RELEASE.md vendored Normal file
View file

@ -0,0 +1,71 @@
# Release Workflow
This GitHub Action workflow automatically builds and releases FriendlyKobold for macOS, Windows, and Linux.
## How to Create a Release
### Method 1: Tag-based Release (Recommended)
1. Create and push a new tag:
```bash
git tag v1.0.0
git push origin v1.0.0
```
2. The workflow will automatically:
- Build the app for all platforms
- Run type checking and linting
- Create a GitHub release
- Upload the built files as release assets
### Method 2: Manual Release
1. Go to the "Actions" tab in your GitHub repository
2. Select the "Release" workflow
3. Click "Run workflow"
4. Enter the tag version (e.g., `v1.0.0`)
5. Click "Run workflow"
## Generated Files
The workflow creates the following files:
- **macOS**: `FriendlyKobold-{version}.dmg`
- **Windows**: `FriendlyKobold Setup {version}.exe`
- **Linux**: `FriendlyKobold-{version}.AppImage`
## Requirements
- The repository must have a `GITHUB_TOKEN` (automatically provided by GitHub)
- Node.js 20 and npm for building
- All dependencies must be properly defined in `package.json`
## Troubleshooting
If the build fails:
1. Check that all dependencies are installed correctly
2. Ensure the build script works locally: `npm run build`
3. Check the workflow logs for specific error messages
4. Verify that the version tag follows semantic versioning (e.g., `v1.0.0`)
## Adding Icons
To add custom icons for your releases:
1. Create an `assets` folder in the project root
2. Add the following icon files:
- `icon.icns` for macOS
- `icon.ico` for Windows
- `icon.png` for Linux
3. Update the `build` section in `package.json` to reference these icons
## Customizing the Release
You can customize the release by editing `.github/workflows/release.yml`:
- Change the supported platforms in the `matrix.os` array
- Modify the release notes template
- Add additional build steps or checks
- Change the artifact upload logic

36
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,36 @@
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run type check
run: npm run type-check
- name: Run linter
run: npm run lint:check
- name: Run spell check
run: npm run spell-check
- name: Test build
run: npm run build:electron

154
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,154 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Tag version to release (e.g., v1.0.0)'
required: true
type: string
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run type check
run: npm run type-check
- name: Run linter
run: npm run lint:check
- name: Build Electron app
run: npm run build
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload artifacts (macOS)
if: matrix.os == 'macos-latest'
uses: actions/upload-artifact@v4
with:
name: macos-release
path: |
release/*.dmg
release/latest-mac.yml
- name: Upload artifacts (Windows)
if: matrix.os == 'windows-latest'
uses: actions/upload-artifact@v4
with:
name: windows-release
path: |
release/*.exe
release/latest.yml
- name: Upload artifacts (Linux)
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@v4
with:
name: linux-release
path: |
release/*.AppImage
release/latest-linux.yml
release:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Display structure of downloaded files
run: ls -la artifacts/**/*
- name: Determine tag name
id: tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi
- name: Create Release
run: |
gh release create ${{ steps.tag.outputs.tag }} \
--title "FriendlyKobold ${{ steps.tag.outputs.tag }}" \
--notes "## What's New
Release ${{ steps.tag.outputs.tag }} of FriendlyKobold
### Downloads
- **macOS**: Download the \`.dmg\` file
- **Windows**: Download the \`.exe\` installer
- **Linux**: Download the \`.AppImage\` file
### Installation
- **macOS**: Open the \`.dmg\` file and drag FriendlyKobold to Applications
- **Windows**: Run the \`.exe\` installer
- **Linux**: Make the \`.AppImage\` executable and run it
---
For more information, visit our [repository](https://github.com/${{ github.repository }})."
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload macOS Release Asset
run: |
for file in artifacts/macos-release/*.dmg; do
if [ -f "$file" ]; then
filename=$(basename "$file")
gh release upload ${{ steps.tag.outputs.tag }} "$file" --clobber
fi
done
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Windows Release Asset
run: |
for file in artifacts/windows-release/*.exe; do
if [ -f "$file" ]; then
filename=$(basename "$file")
gh release upload ${{ steps.tag.outputs.tag }} "$file" --clobber
fi
done
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Linux Release Asset
run: |
for file in artifacts/linux-release/*.AppImage; do
if [ -f "$file" ]; then
filename=$(basename "$file")
gh release upload ${{ steps.tag.outputs.tag }} "$file" --clobber
fi
done
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

141
LICENSE
View file

@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@ -7,17 +7,15 @@
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View file

@ -24,4 +24,4 @@ A koboldcpp manager.
## License
GPL3 License - see LICENSE file for details
AGPL v3 License - see LICENSE file for details

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

View file

@ -2,227 +2,224 @@
"version": "0.2",
"language": "en",
"words": [
// Project specific terms
"kobold",
"koboldcpp",
"friendlykobold",
"kcpps",
"kcppt",
// Technology terms
"vite",
"vitejs",
"vitest",
"tailwindcss",
"postcss",
"eslint",
"sonarjs",
"typescript",
"tsx",
"jsx",
"husky",
"linted",
"prettierrc",
"gitignore",
"tsconfig",
"treemap",
"cuda",
"rocm",
"openblas",
"nvidia",
"geforce",
"radeon",
"addEventListener",
"admin",
"allowRunningInsecureContent",
"amdgpu",
"wmic",
"oldpc",
"nocuda",
"cooldown",
"togglefullscreen",
"libvk",
"swiftshader",
"icns",
"nsis",
"api",
"apis",
"appendChild",
"arg",
"args",
"asar",
"async",
"auth",
"await",
"babel",
"basename",
"bgcolor",
"browserWindow",
"bundler",
"bundling",
"can",
"classList",
"className",
"cloneNode",
"codeinterface",
"tabler",
"deps",
"devs",
"repo",
"repos",
"componentDidCatch",
"componentDidMount",
"componentDidUpdate",
"componentWillUnmount",
"config",
"configs",
"contextBridge",
"contextIsolation",
"contextsize",
"cooldown",
"couldn",
"createContext",
"createElement",
"createRef",
"css",
"cuda",
"dataset",
"deps",
"destructuring",
"devs",
"dirname",
"doesn",
"don",
"Egor",
"env",
"envs",
"eot",
"eslint",
"filename",
"filenames",
"filepath",
"filepaths",
"pathname",
"pathnames",
"dirname",
"basename",
"readonly",
"inline",
"async",
"await",
"destructuring",
"refactor",
"refactoring",
"typeof",
"instanceof",
"addEventListener",
"removeEventListener",
"preventDefault",
"stopPropagation",
"querySelector",
"querySelectorAll",
"innerHTML",
"textContent",
"classList",
"className",
"dataset",
"setAttribute",
"getAttribute",
"removeAttribute",
"createElement",
"appendChild",
"removeChild",
"insertBefore",
"cloneNode",
// Electron specific
"preload",
"webContents",
"browserWindow",
"mainWindow",
"ipcMain",
"ipcRenderer",
"contextBridge",
"nodeIntegration",
"contextIsolation",
"webSecurity",
"allowRunningInsecureContent",
// React specific
"useEffect",
"useState",
"useContext",
"useReducer",
"useMemo",
"useCallback",
"useRef",
"forwardRef",
"createContext",
"createRef",
"componentDidMount",
"componentDidUpdate",
"componentWillUnmount",
"shouldComponentUpdate",
"getDerivedStateFromProps",
"getSnapshotBeforeUpdate",
"componentDidCatch",
"getDerivedStateFromError",
// CSS/Styling
"flexbox",
"flexdir",
"flexwrap",
"gridcol",
"gridrow",
"bgcolor",
"textcolor",
"fontsize",
"fontweight",
"lineheight",
"forwardRef",
"friendlykobold",
"geforce",
"getAttribute",
"getDerivedStateFromError",
"getDerivedStateFromProps",
"getSnapshotBeforeUpdate",
"gguf",
"GGUF",
"gif",
"gitignore",
"gpulayers",
"gridcol",
"gridrow",
"hostname",
"html",
"http",
"https",
"husky",
"icns",
"ico",
"impl",
"impls",
"inline",
"innerHTML",
"insertBefore",
"instanceof",
"ipcMain",
"ipcRenderer",
"jpeg",
"jpg",
"json",
"jsx",
"kcpps",
"kcppt",
"kobold",
"KOBOLDAI",
"koboldcpp",
"less",
"letterspacing",
"wordspacing",
"textalign",
"textdecoration",
"texttransform",
"whitespace",
"wordbreak",
"wordwrap",
"libvk",
"lineheight",
"linted",
"localhost",
"mainWindow",
"minified",
"minify",
"moz",
"namespace",
"namespaces",
"newpackagename",
"nocuda",
"nodeIntegration",
"nsis",
"nvidia",
"oldpc",
"openblas",
"otf",
"overflow",
"overflowx",
"overflowy",
"scrollbar",
"webkit",
"moz",
// Build tools
"sourcemap",
"sourcemaps",
"minify",
"minified",
"bundler",
"bundling",
"treeshake",
"treeshaking",
"param",
"params",
"parcel",
"pathname",
"pathnames",
"Philippov",
"png",
"polyfill",
"polyfills",
"postcss",
"preload",
"prettierrc",
"preventDefault",
"querySelector",
"querySelectorAll",
"radeon",
"readonly",
"refactor",
"refactoring",
"removeAttribute",
"removeChild",
"removeEventListener",
"repo",
"repos",
"rocm",
"rollup",
"sass",
"scrollbar",
"scss",
"setAttribute",
"shouldComponentUpdate",
"shouldn",
"sonarjs",
"sourcemap",
"sourcemaps",
"spec",
"specs",
"stopPropagation",
"subdomain",
"svg",
"swiftshader",
"tabler",
"temp",
"textalign",
"textcolor",
"textContent",
"textdecoration",
"texttransform",
"tmp",
"togglefullscreen",
"toml",
"transpile",
"transpiled",
"transpiling",
"babel",
"rollup",
"webpack",
"parcel",
// Common abbreviations
"utils",
"util",
"impl",
"impls",
"spec",
"specs",
"param",
"params",
"arg",
"args",
"env",
"envs",
"var",
"vars",
"tmp",
"temp",
"auth",
"admin",
"api",
"apis",
"url",
"urls",
"treemap",
"treeshake",
"treeshaking",
"tsconfig",
"tsx",
"ttf",
"typeof",
"typescript",
"uri",
"uris",
"http",
"https",
"localhost",
"hostname",
"subdomain",
"namespace",
"namespaces",
// File extensions
"json",
"yaml",
"toml",
"xml",
"html",
"css",
"scss",
"sass",
"less",
"svg",
"png",
"jpg",
"jpeg",
"gif",
"url",
"urls",
"useCallback",
"useContext",
"useEffect",
"useMemo",
"useReducer",
"useRef",
"useState",
"util",
"utils",
"var",
"vars",
"vite",
"vitejs",
"vitest",
"webContents",
"webkit",
"webp",
"ico",
"webpack",
"webSecurity",
"whitespace",
"wmic",
"woff",
"woff2",
"ttf",
"otf",
"eot",
// Common contractions and informal words
"doesn",
"don",
"won",
"can",
"couldn",
"shouldn",
"wordbreak",
"wordspacing",
"wordwrap",
"wouldn",
// Documentation examples
"newpackagename"
"xml",
"yaml"
],
"ignorePaths": [
"node_modules/**",

View file

@ -6,6 +6,11 @@ import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
define: {
'process.env.VITE_DEV_SERVER_URL': JSON.stringify(
process.env.VITE_DEV_SERVER_URL
@ -14,6 +19,11 @@ export default defineConfig({
},
preload: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
},
renderer: {
root: '.',

View file

@ -1,37 +1,35 @@
import js from '@eslint/js';
import globals from 'globals';
import tseslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import react from 'eslint-plugin-react';
import importPlugin from 'eslint-plugin-import';
import tseslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import sonarjs from 'eslint-plugin-sonarjs';
import security from 'eslint-plugin-security';
import cspell from '@cspell/eslint-plugin';
import type { Linter } from 'eslint';
const config: Linter.Config[] = [
const config = [
js.configs.recommended,
{
ignores: ['dist', 'dist-electron', 'out', 'electron', 'scripts'],
},
js.configs.recommended,
sonarjs.configs.recommended,
security.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
files: ['**/*.{js,mjs,cjs,ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: {
...globals.browser,
...globals.node,
},
parser: tsParser,
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
globals: {
...globals.browser,
...globals.node,
},
},
plugins: {
'@typescript-eslint': tseslint,
@ -39,18 +37,36 @@ const config: Linter.Config[] = [
'react-refresh': reactRefresh,
react: react,
import: importPlugin,
sonarjs: sonarjs,
'@cspell': cspell,
},
settings: {
react: {
version: 'detect',
},
},
rules: {
// Use recommended rules from plugins
...tseslint.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
...react.configs.recommended.rules,
// Essential TypeScript rules
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
'no-unused-vars': 'off', // Turn off base rule to use TypeScript version
'@typescript-eslint/no-explicit-any': 'warn',
// React-specific rules you wanted
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
// Override only the specific modern React patterns we want to enforce
'react/function-component-definition': [
'error',
{
@ -59,6 +75,8 @@ const config: Linter.Config[] = [
},
],
'react/react-in-jsx-scope': 'off', // Not needed with new JSX transform
// No default React imports - force specific imports
'no-restricted-imports': [
'error',
{
@ -72,33 +90,45 @@ const config: Linter.Config[] = [
],
},
],
// Enforce named exports for React components
// Import rules - enforce named exports
'import/no-default-export': 'error',
'import/prefer-default-export': 'off',
// Enforce arrow function shorthand when possible
'arrow-body-style': ['error', 'as-needed'],
'prefer-arrow-callback': ['error', { allowNamedFunctions: false }],
// Disallow console.log usage
// Forbid console.log usage
'no-console': ['error', { allow: ['warn', 'error'] }],
// Warn about unnecessary explicit type annotations
// TypeScript rules
'@typescript-eslint/no-inferrable-types': 'warn',
// Don't require explicit return types (prefer inference)
'@typescript-eslint/explicit-function-return-type': 'off',
// Disable some overly strict security rules for Electron apps
'security/detect-non-literal-fs-filename': 'off',
'security/detect-object-injection': 'off',
// Relax cognitive complexity for complex business logic
// SonarJS rules - keep cognitive complexity reasonable
'sonarjs/cognitive-complexity': ['warn', 25],
// Spell checking for code
'@cspell/spellchecker': ['warn'],
},
},
{
// TypeScript definition files should have relaxed rules
files: ['**/*.d.ts'],
rules: {
// Allow unused variables in type definitions
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'off',
// Allow any types in definitions
'@typescript-eslint/no-explicit-any': 'off',
},
},
{
// Allow default exports for config files
files: [
'*.config.*',
'vite.config.*',
'tailwind.config.*',
'eslint.config.*',
'postcss.config.*',
],
@ -106,36 +136,6 @@ const config: Linter.Config[] = [
'import/no-default-export': 'off',
},
},
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
react: react,
import: importPlugin,
},
rules: {
...reactHooks.configs.recommended.rules,
...react.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'react/react-in-jsx-scope': 'off', // Not needed with new JSX transform
},
},
];
export default config;

1137
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -20,11 +20,10 @@
"format": "prettier --write . --ignore-path .gitignore",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint:check": "eslint . --max-warnings 0",
"type-check": "tsc --noEmit",
"compile": "tsc --noEmit",
"spell-check": "cspell \"**/*.{ts,tsx,js,jsx,md,json}\" --no-progress",
"spell-check:fix": "cspell \"**/*.{ts,tsx,js,jsx,md,json}\" --no-progress --show-suggestions",
"check-all": "npm run lint && npm run type-check && npm run spell-check",
"check-all": "npm run lint && npm run compile && npm run spell-check",
"prepare": "husky"
},
"lint-staged": {
@ -44,12 +43,11 @@
"ai",
"llm"
],
"author": "",
"license": "GPL-3.0-or-later",
"author": "Egor Philippov",
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@cspell/eslint-plugin": "^9.2.0",
"@eslint/js": "^9.33.0",
"@tailwindcss/postcss": "^4.1.11",
"@types/node": "^24.2.1",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
@ -66,14 +64,13 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"eslint-plugin-security": "^3.0.1",
"eslint-plugin-sonarjs": "^3.0.4",
"globals": "^16.3.0",
"husky": "^9.1.7",
"jiti": "^2.5.1",
"lint-staged": "^16.1.5",
"prettier": "^3.6.2",
"rollup-plugin-visualizer": "^6.0.3",
"tailwindcss": "^4.1.11",
"typescript": "^5.9.2",
"vite": "^7.1.2",
"wait-on": "^8.0.4"
@ -82,23 +79,41 @@
"@emotion/react": "^11.14.0",
"@mantine/core": "^8.2.4",
"@mantine/hooks": "^8.2.4",
"@tabler/icons-react": "^3.34.1",
"@mantine/notifications": "^8.2.4",
"lucide-react": "^0.539.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"build": {
"appId": "com.friendlykobold.app",
"productName": "FriendlyKobold",
"compression": "maximum",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"dist-electron/**/*",
"node_modules/**/*"
"out/**/*",
"!**/node_modules/**/*",
"!out/renderer/node_modules/**/*",
"!**/*.map",
"!**/*.ts",
"!**/*.tsx",
"!**/test/**/*",
"!**/tests/**/*",
"!**/__tests__/**/*",
"!**/coverage/**/*",
"!**/.nyc_output/**/*",
"package.json"
],
"asarUnpack": [
"!**/node_modules/**/*"
],
"mac": {
"icon": "assets/icon.icns",
"category": "public.app-category.productivity",
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist",
"target": [
{
"target": "dmg",
@ -110,7 +125,6 @@
]
},
"win": {
"icon": "assets/icon.ico",
"target": [
{
"target": "nsis",
@ -121,7 +135,6 @@
]
},
"linux": {
"icon": "assets/icon.png",
"target": [
{
"target": "AppImage",
@ -130,6 +143,9 @@
]
}
]
},
"nsis": {
"differentialPackage": true
}
}
}

View file

@ -1,7 +0,0 @@
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
module.exports = config;

View file

@ -1,19 +1,82 @@
import { useState, useEffect } from 'react';
import { AppShell, Group, ActionIcon, Tooltip, rem } from '@mantine/core';
import { IconSettings } from '@tabler/icons-react';
import { useState, useEffect, ReactNode } from 'react';
import {
AppShell,
Group,
ActionIcon,
Tooltip,
rem,
Transition,
Loader,
Center,
Stack,
Text,
Button,
useMantineColorScheme,
} from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { Settings, ArrowLeft } from 'lucide-react';
import { DownloadScreen } from '@/screens/DownloadScreen';
import { LaunchScreen } from '@/screens/LaunchScreen';
import { TerminalScreen } from '@/screens/TerminalScreen';
import { UpdateDialog } from '@/components/UpdateDialog';
import { SettingsModal } from '@/components/SettingsModal';
import type { UpdateInfo } from '@/types/electron';
import { useNotifications } from '@/hooks/useNotifications';
import type { UpdateInfo } from '@/types';
type Screen = 'download' | 'launch';
type Screen = 'download' | 'launch' | 'terminal';
interface ScreenTransitionProps {
isActive: boolean;
shouldAnimate: boolean;
children: ReactNode;
}
const ScreenTransition = ({
isActive,
shouldAnimate,
children,
}: ScreenTransitionProps) => {
const getTransform = () => {
if (!shouldAnimate) return undefined;
const scale = isActive ? 1 : 0.98;
return `scale(${scale})`;
};
return (
<Transition
mounted={isActive}
transition="fade"
duration={shouldAnimate ? 350 : 0}
timingFunction="ease-out"
>
{(styles) => (
<div
style={{
...styles,
position: isActive ? 'static' : 'absolute',
width: '100%',
top: 0,
left: 0,
zIndex: isActive ? 1 : 0,
transform: `${styles.transform || ''} ${getTransform() || ''}`,
transition: shouldAnimate ? 'all 350ms ease-out' : undefined,
}}
>
{children}
</div>
)}
</Transition>
);
};
export const App = () => {
const [currentScreen, setCurrentScreen] = useState<Screen>('download');
const [currentScreen, setCurrentScreen] = useState<Screen | null>(null);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
const [settingsOpened, setSettingsOpened] = useState(false);
const [hasInitialized, setHasInitialized] = useState(false);
const { colorScheme } = useMantineColorScheme();
const notify = useNotifications();
useEffect(() => {
const checkInstallation = async () => {
@ -21,9 +84,13 @@ export const App = () => {
try {
const installed = await window.electronAPI.kobold.isInstalled();
setCurrentScreen(installed ? 'launch' : 'download');
setHasInitialized(true);
} catch (error) {
console.error('Error checking installation:', error);
setHasInitialized(true);
}
} else {
setHasInitialized(true);
}
};
@ -34,19 +101,62 @@ export const App = () => {
setUpdateInfo(info);
setShowUpdateDialog(true);
});
}
return () => {
if (window.electronAPI) {
const cleanupInstallDirListener =
window.electronAPI.kobold.onInstallDirChanged(() => {
checkInstallation();
});
return () => {
window.electronAPI.kobold.removeAllListeners('update-available');
}
};
cleanupInstallDirListener();
};
}
}, []);
const handleInstallComplete = () => {
const handleDownloadComplete = () => {
notify.success(
'Download Complete',
'KoboldCpp has been successfully installed'
);
setTimeout(() => {
setCurrentScreen('launch');
}, 100);
};
const handleLaunch = () => {
setCurrentScreen('terminal');
notify.success('Launch Started', 'KoboldCpp is starting up...');
};
const handleBackToLaunch = () => {
setCurrentScreen('launch');
};
const handleEject = async () => {
// Show confirmation dialog
try {
const confirmed = await window.electronAPI.kobold.confirmEject();
if (!confirmed) {
return; // User cancelled
}
} catch (error) {
console.error('Error showing confirmation dialog:', error);
return;
}
if (window.electronAPI?.kobold?.stopKoboldCpp) {
try {
await window.electronAPI.kobold.stopKoboldCpp();
} catch (error) {
console.error('Error stopping KoboldCpp:', error);
notify.error('Stop Failed', 'Failed to stop KoboldCpp process');
}
}
handleBackToLaunch();
};
const handleUpdateIgnore = () => {
setShowUpdateDialog(false);
};
@ -57,41 +167,108 @@ export const App = () => {
};
return (
<AppShell header={{ height: 60 }} padding="md">
<AppShell.Header>
<Group h="100%" px="md" justify="flex-end">
<Tooltip label="Settings" position="bottom">
<ActionIcon
variant="subtle"
size="lg"
onClick={() => setSettingsOpened(true)}
aria-label="Open settings"
>
<IconSettings style={{ width: rem(18), height: rem(18) }} />
</ActionIcon>
</Tooltip>
</Group>
</AppShell.Header>
<AppShell.Main>
{currentScreen === 'download' && (
<DownloadScreen onInstallComplete={handleInstallComplete} />
)}
{currentScreen === 'launch' && <LaunchScreen />}
{showUpdateDialog && updateInfo && (
<UpdateDialog
updateInfo={updateInfo}
onIgnore={handleUpdateIgnore}
onAccept={handleUpdateAccept}
/>
)}
</AppShell.Main>
<SettingsModal
opened={settingsOpened}
onClose={() => setSettingsOpened(false)}
<>
<Notifications
position="bottom-right"
zIndex={1000}
containerWidth={320}
/>
</AppShell>
<AppShell header={{ height: 60 }} padding="md">
<AppShell.Header
style={{
borderBottom: `1px solid var(--mantine-color-${colorScheme === 'dark' ? 'dark-4' : 'gray-3'})`,
backdropFilter: 'blur(10px)',
background:
colorScheme === 'dark'
? 'rgba(26, 27, 30, 0.8)'
: 'rgba(255, 255, 255, 0.8)',
transition: 'all 200ms ease',
}}
>
<Group h="100%" px="md" justify="space-between">
{currentScreen === 'terminal' && (
<Button
variant="light"
color="red"
leftSection={<ArrowLeft size={16} />}
onClick={handleEject}
>
Eject
</Button>
)}
<Group ml="auto">
<Tooltip label="Settings" position="bottom">
<ActionIcon
variant="subtle"
color="gray"
size="xl"
onClick={() => setSettingsOpened(true)}
aria-label="Open settings"
style={{
transition: 'all 200ms ease',
}}
>
<Settings style={{ width: rem(20), height: rem(20) }} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</AppShell.Header>
<AppShell.Main
style={{
position: 'relative',
overflow: 'hidden',
minHeight: 'calc(100vh - 60px)',
}}
>
{currentScreen === null ? (
<Center h="100%" style={{ minHeight: '400px' }}>
<Stack align="center" gap="lg">
<Loader size="xl" type="dots" />
<Text c="dimmed" size="lg">
Initializing...
</Text>
</Stack>
</Center>
) : (
<>
<ScreenTransition
isActive={currentScreen === 'download'}
shouldAnimate={hasInitialized}
>
<DownloadScreen onDownloadComplete={handleDownloadComplete} />
</ScreenTransition>
<ScreenTransition
isActive={currentScreen === 'launch'}
shouldAnimate={hasInitialized}
>
<LaunchScreen onLaunch={handleLaunch} />
</ScreenTransition>
<ScreenTransition
isActive={currentScreen === 'terminal'}
shouldAnimate={hasInitialized}
>
<TerminalScreen />
</ScreenTransition>
</>
)}
{showUpdateDialog && updateInfo && (
<UpdateDialog
updateInfo={updateInfo}
onIgnore={handleUpdateIgnore}
onAccept={handleUpdateAccept}
/>
)}
</AppShell.Main>
<SettingsModal
opened={settingsOpened}
onClose={() => setSettingsOpened(false)}
/>
</AppShell>
</>
);
};

View file

@ -0,0 +1,110 @@
import { Select, Text, Badge, Group } from '@mantine/core';
import { File } from 'lucide-react';
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import type { ConfigFile } from '@/types';
interface ConfigFileSelectProps {
configFiles: ConfigFile[];
selectedFile: string | null;
loading: boolean;
onFileSelection: (fileName: string) => void;
}
interface SelectItemProps extends ComponentPropsWithoutRef<'div'> {
label: string;
extension: string;
}
const getBadgeColor = (extension: string) => {
switch (extension.toLowerCase()) {
case '.kcpps':
return 'blue';
case '.kcppt':
return 'green';
default:
return 'gray';
}
};
const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
({ label, extension, ...others }, ref) => (
<div ref={ref} {...others}>
<Group justify="space-between" wrap="nowrap">
<Text size="sm" truncate>
{label}
</Text>
<Badge size="xs" variant="light" color={getBadgeColor(extension)}>
{extension}
</Badge>
</Group>
</div>
)
);
SelectItem.displayName = 'SelectItem';
export const ConfigFileSelect = ({
configFiles,
selectedFile,
loading,
onFileSelection,
}: ConfigFileSelectProps) => {
if (loading) {
return (
<Text c="dimmed" ta="center">
Loading configuration files...
</Text>
);
}
if (configFiles.length === 0) {
return (
<Text c="dimmed" ta="center">
No configuration files found in the installation directory.
<br />
Please ensure your .kcpps or .kcppt files are in the correct location.
</Text>
);
}
const selectData = configFiles.map((file) => {
const extension = file.name.split('.').pop() || '';
const nameWithoutExtension = file.name.replace(`.${extension}`, '');
return {
value: file.name,
label: nameWithoutExtension, // Clean label for selected value
extension: `.${extension}`, // Store extension separately
};
});
return (
<Select
label="Configuration File"
placeholder="Select a configuration file"
value={selectedFile}
onChange={(value) => value && onFileSelection(value)}
data={selectData}
leftSection={<File size={16} />}
searchable
clearable={false}
w="100%"
filter={({ options, search }) =>
options.filter((option) => {
if ('label' in option) {
return option.label
.toLowerCase()
.includes(search.toLowerCase().trim());
}
return false;
})
}
renderOption={({ option }) => {
// Find the original data item to get the extension
const dataItem = selectData.find((item) => item.value === option.value);
const extension = dataItem?.extension || '';
return <SelectItem label={option.label} extension={extension} />;
}}
/>
);
};

View file

@ -1,5 +1,14 @@
import { Card, Stack, Group, Text, Badge, Button, Loader } from '@mantine/core';
import { IconDownload } from '@tabler/icons-react';
import {
Card,
Stack,
Group,
Text,
Badge,
Button,
Loader,
Progress,
} from '@mantine/core';
import { Download } from 'lucide-react';
import { MouseEvent } from 'react';
interface DownloadOptionCardProps {
@ -9,6 +18,7 @@ interface DownloadOptionCardProps {
isSelected: boolean;
isRecommended: boolean;
isDownloading: boolean;
downloadProgress?: number;
onClick: () => void;
onDownload: (e: MouseEvent<HTMLButtonElement>) => void;
}
@ -20,6 +30,7 @@ export const DownloadOptionCard = ({
isSelected,
isRecommended,
isDownloading,
downloadProgress = 0,
onClick,
onDownload,
}: DownloadOptionCardProps) => (
@ -52,16 +63,12 @@ export const DownloadOptionCard = ({
</Text>
{isSelected && (
<Group justify="center" pt="sm">
<Stack gap="sm" pt="sm">
<Button
onClick={onDownload}
disabled={isDownloading}
leftSection={
isDownloading ? (
<Loader size="1rem" />
) : (
<IconDownload size="1rem" />
)
isDownloading ? <Loader size="1rem" /> : <Download size="1rem" />
}
size="sm"
radius="md"
@ -69,7 +76,16 @@ export const DownloadOptionCard = ({
>
{isDownloading ? 'Downloading...' : 'Download'}
</Button>
</Group>
{isDownloading && (
<Stack gap="xs">
<Progress value={downloadProgress} color="blue" radius="xl" />
<Text size="xs" c="dimmed" ta="center">
{downloadProgress.toFixed(1)}% complete
</Text>
</Stack>
)}
</Stack>
)}
</Stack>
</Card>

View file

@ -11,25 +11,24 @@ import {
Button,
Card,
Badge,
Progress,
Loader,
} from '@mantine/core';
import {
IconSettings,
IconPalette,
IconMoon,
IconSun,
IconDeviceDesktop,
IconAdjustments,
IconFolder,
IconFolderOpen,
IconVersions,
IconDownload,
IconRefresh,
} from '@tabler/icons-react';
import { useTheme } from '@/contexts/ThemeContext';
import {
getPlatformDisplayName,
isAssetCompatibleWithPlatform,
} from '@/utils/platform';
Settings,
Palette,
Moon,
Sun,
Monitor,
SlidersHorizontal,
Folder,
FolderOpen,
GitBranch,
Download,
RotateCcw,
} from 'lucide-react';
import { useTheme, type ThemeMode } from '@/contexts/ThemeContext';
import { isAssetCompatibleWithPlatform } from '@/utils/platform';
import type { InstalledVersion, ReleaseWithStatus } from '@/types/electron';
interface SettingsModalProps {
@ -40,7 +39,6 @@ interface SettingsModalProps {
export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
const { themeMode, setThemeMode } = useTheme();
const [installDir, setInstallDir] = useState<string>('');
const [loading, setLoading] = useState(false);
const [installedVersions, setInstalledVersions] = useState<
InstalledVersion[]
>([]);
@ -52,12 +50,15 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
);
const [loadingRelease, setLoadingRelease] = useState(false);
const [downloading, setDownloading] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState<{
[key: string]: number;
}>({});
const [downloadingROCm, setDownloadingROCm] = useState(false);
const [rocmDownload, setRocmDownload] = useState<{
name: string;
url: string;
size: number;
type: 'rocm';
version?: string;
} | null>(null);
const [userPlatform, setUserPlatform] = useState<string>('');
const [activeTab, setActiveTab] = useState('general');
@ -85,8 +86,24 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
loadLatestRelease();
loadROCmDownload();
loadUserPlatform();
// Set up progress listener
const handleProgress = (progress: number) => {
if (downloading) {
setDownloadProgress((prev) => ({
...prev,
[downloading]: progress,
}));
}
};
window.electronAPI.kobold.onDownloadProgress?.(handleProgress);
return () => {
window.electronAPI.kobold.removeAllListeners?.('download-progress');
};
}
}, [opened]);
}, [opened, downloading]);
const loadCurrentInstallDir = async () => {
try {
@ -141,6 +158,8 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
const handleDownload = async (assetName: string, downloadUrl: string) => {
setDownloading(assetName);
setDownloadProgress((prev) => ({ ...prev, [assetName]: 0 }));
try {
const result = await window.electronAPI.kobold.downloadRelease({
name: assetName,
@ -157,6 +176,11 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
console.error('Failed to download:', error);
} finally {
setDownloading(null);
setDownloadProgress((prev) => {
const newProgress = { ...prev };
delete newProgress[assetName];
return newProgress;
});
}
};
@ -258,17 +282,15 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
const handleSelectInstallDir = async () => {
if (!window.electronAPI) return;
setLoading(true);
try {
const selectedDir =
await window.electronAPI.kobold.selectInstallDirectory();
if (selectedDir) {
setInstallDir(selectedDir);
}
} catch (error) {
console.error('Failed to select install directory:', error);
} finally {
setLoading(false);
}
};
@ -278,7 +300,7 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
onClose={onClose}
title={
<Group gap="xs">
<IconSettings size={20} />
<Settings size={20} />
<Text fw={500}>Settings</Text>
</Group>
}
@ -319,7 +341,7 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
<Tabs.Tab
value="general"
leftSection={
<IconAdjustments style={{ width: rem(16), height: rem(16) }} />
<SlidersHorizontal style={{ width: rem(16), height: rem(16) }} />
}
>
General
@ -327,7 +349,7 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
<Tabs.Tab
value="versions"
leftSection={
<IconVersions style={{ width: rem(16), height: rem(16) }} />
<GitBranch style={{ width: rem(16), height: rem(16) }} />
}
>
Versions
@ -335,7 +357,7 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
<Tabs.Tab
value="appearance"
leftSection={
<IconPalette style={{ width: rem(16), height: rem(16) }} />
<Palette style={{ width: rem(16), height: rem(16) }} />
}
>
Appearance
@ -349,7 +371,7 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
Installation Directory
</Text>
<Text size="sm" c="dimmed" mb="md">
Choose where KoboldCpp files will be downloaded and stored
Choose where application files will be downloaded and stored
</Text>
<Group gap="xs">
<TextInput
@ -358,17 +380,14 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
placeholder="Default installation directory"
style={{ flex: 1 }}
leftSection={
<IconFolder style={{ width: rem(16), height: rem(16) }} />
<Folder style={{ width: rem(16), height: rem(16) }} />
}
/>
<Button
variant="outline"
onClick={handleSelectInstallDir}
loading={loading}
leftSection={
<IconFolderOpen
style={{ width: rem(16), height: rem(16) }}
/>
<FolderOpen style={{ width: rem(16), height: rem(16) }} />
}
>
Browse
@ -389,39 +408,44 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
onClick={loadLatestRelease}
loading={loadingRelease}
leftSection={
<IconRefresh style={{ width: rem(14), height: rem(14) }} />
<RotateCcw style={{ width: rem(14), height: rem(14) }} />
}
>
Check for Updates
</Button>
</Group>
<Text size="sm" c="dimmed" mb="md">
KoboldCpp versions available for{' '}
{getPlatformDisplayName(userPlatform)}. Click on downloaded
versions to set them as current.
</Text>
<Stack gap="xs">
{(() => {
const allVersions = getAllVersions();
return allVersions.map((version, index) => (
{(() =>
getAllVersions().map((version, index) => (
<Card
key={`${version.name}-${version.version}-${index}`}
withBorder
radius="sm"
padding="sm"
style={(() => {
let backgroundColor = undefined;
if (version.isCurrent) {
backgroundColor = 'var(--mantine-color-blue-0)';
}
return {
backgroundColor,
borderColor: version.isCurrent
? 'var(--mantine-color-blue-6)'
style={{
cursor:
version.isDownloaded && !version.isCurrent
? 'pointer'
: undefined,
};
})()}
}}
bd={
version.isCurrent
? '2px solid var(--mantine-color-blue-filled)'
: undefined
}
bg={
version.isCurrent
? 'var(--mantine-color-blue-light)'
: undefined
}
onClick={
version.isDownloaded &&
!version.isCurrent &&
version.installedData
? () => handleVersionSelect(version.installedData!)
: undefined
}
>
<Group justify="space-between" align="center">
<div style={{ flex: 1 }}>
@ -459,33 +483,38 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
loading={downloading === version.name}
disabled={downloading !== null}
leftSection={
<IconDownload
style={{ width: rem(14), height: rem(14) }}
/>
downloading === version.name ? (
<Loader size="1rem" />
) : (
<Download
style={{ width: rem(14), height: rem(14) }}
/>
)
}
>
Download
{downloading === version.name
? 'Downloading...'
: 'Download'}
</Button>
)}
{version.isDownloaded &&
!version.isCurrent &&
version.installedData && (
<Button
variant="outline"
size="xs"
color="blue"
onClick={(e) => {
e.stopPropagation();
handleVersionSelect(version.installedData!);
}}
>
Make Current
</Button>
)}
</Group>
{downloading === version.name &&
downloadProgress[version.name] !== undefined && (
<Stack gap="xs" mt="sm">
<Progress
value={downloadProgress[version.name]}
color="blue"
radius="xl"
/>
<Text size="xs" c="dimmed" ta="center">
{downloadProgress[version.name].toFixed(1)}%
complete
</Text>
</Stack>
)}
</Card>
));
})()}
)))()}
{userPlatform === 'linux' && rocmDownload && (
<Card withBorder radius="sm" padding="sm">
@ -497,7 +526,7 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
</Text>
</Group>
<Text size="xs" c="dimmed">
AMD GPU support with ROCm
Version {rocmDownload.version || 'latest'}
{rocmDownload.size && (
<>
{' '}
@ -519,7 +548,7 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
loading={downloadingROCm}
disabled={downloading !== null || downloadingROCm}
leftSection={
<IconDownload
<Download
style={{ width: rem(14), height: rem(14) }}
/>
}
@ -556,14 +585,12 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
<SegmentedControl
fullWidth
value={themeMode}
onChange={(value) =>
setThemeMode(value as 'light' | 'dark' | 'system')
}
onChange={(value) => setThemeMode(value as ThemeMode)}
data={[
{
label: (
<Group gap="xs" justify="center">
<IconSun style={{ width: rem(16), height: rem(16) }} />
<Sun style={{ width: rem(16), height: rem(16) }} />
<span>Light</span>
</Group>
),
@ -572,7 +599,7 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
{
label: (
<Group gap="xs" justify="center">
<IconMoon style={{ width: rem(16), height: rem(16) }} />
<Moon style={{ width: rem(16), height: rem(16) }} />
<span>Dark</span>
</Group>
),
@ -581,9 +608,7 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
{
label: (
<Group gap="xs" justify="center">
<IconDeviceDesktop
style={{ width: rem(16), height: rem(16) }}
/>
<Monitor style={{ width: rem(16), height: rem(16) }} />
<span>System</span>
</Group>
),

View file

@ -1,19 +1,5 @@
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
interface GitHubRelease {
tag_name: string;
name: string;
published_at: string;
body: string;
assets: GitHubAsset[];
}
interface GitHubAsset {
name: string;
browser_download_url: string;
size: number;
created_at: string;
}
import type { GitHubRelease } from '@/types';
interface UpdateDialogProps {
updateInfo: {

36
src/constants/app.ts Normal file
View file

@ -0,0 +1,36 @@
export const APP_NAME = 'friendly-kobold';
export const CONFIG_FILE_NAME = 'config.json';
export const DIALOG_TITLES = {
SELECT_INSTALL_DIR: 'Select the Friendly Kobold Installation Directory',
} as const;
export const GITHUB_API = {
BASE_URL: 'https://api.github.com',
KOBOLDCPP_REPO: 'LostRuins/koboldcpp',
get LATEST_RELEASE_URL() {
return `${this.BASE_URL}/repos/${this.KOBOLDCPP_REPO}/releases/latest`;
},
get ALL_RELEASES_URL() {
return `${this.BASE_URL}/repos/${this.KOBOLDCPP_REPO}/releases`;
},
} as const;
export const ASSET_SUFFIXES = {
ROCM: 'rocm',
NOCUDA: 'nocuda',
OLDPC: 'oldpc',
} as const;
export const KOBOLDAI_URLS = {
STANDARD_DOWNLOAD: 'https://koboldai.org/cpp',
ROCM_DOWNLOAD: 'https://koboldai.org/cpplinuxrocm',
} as const;
export const ROCM = {
BINARY_NAME: 'koboldcpp-linux-x64-rocm',
DOWNLOAD_URL: KOBOLDAI_URLS.ROCM_DOWNLOAD,
ERROR_MESSAGE: 'ROCm version is only available for Linux',
SIZE_BYTES: 1024 * 1024 * 1024, // 1GB
} as const;

View file

@ -7,7 +7,7 @@ import {
} from 'react';
import { MantineColorScheme } from '@mantine/core';
type ThemeMode = 'light' | 'dark' | 'system';
export type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeContextType {
themeMode: ThemeMode;

View file

@ -0,0 +1,137 @@
import { useState, useCallback } from 'react';
import type { ConfigFile } from '@/types';
export const useLaunchConfig = () => {
const [serverOnly, setServerOnly] = useState<boolean>(false);
const [gpuLayers, setGpuLayers] = useState<number>(0);
const [autoGpuLayers, setAutoGpuLayers] = useState<boolean>(false);
const [contextSize, setContextSize] = useState<number>(2048);
const [modelPath, setModelPath] = useState<string>('');
const [additionalArguments, setAdditionalArguments] = useState<string>('');
const parseAndApplyConfigFile = useCallback(async (configPath: string) => {
const configData =
await window.electronAPI.kobold.parseConfigFile(configPath);
if (configData) {
if (typeof configData.gpulayers === 'number') {
setGpuLayers(configData.gpulayers);
} else {
setGpuLayers(0);
}
if (typeof configData.contextsize === 'number') {
setContextSize(configData.contextsize);
} else {
setContextSize(2048);
}
if (typeof configData.model_param === 'string') {
setModelPath(configData.model_param);
await window.electronAPI.config.setModelPath(configData.model_param);
}
} else {
setGpuLayers(0);
setContextSize(2048);
}
}, []);
const loadSavedSettings = useCallback(async () => {
const [savedServerOnly, savedModelPath] = await Promise.all([
window.electronAPI.config.getServerOnly(),
window.electronAPI.config.getModelPath(),
]);
setServerOnly(savedServerOnly);
setModelPath(savedModelPath || '');
setGpuLayers(0);
setContextSize(2048);
}, []);
const loadConfigFromFile = useCallback(
async (configFiles: ConfigFile[], savedConfig: string | null) => {
let currentSelectedFile = null;
if (savedConfig && configFiles.some((f) => f.name === savedConfig)) {
currentSelectedFile = savedConfig;
} else if (configFiles.length > 0) {
currentSelectedFile = configFiles[0].name;
}
if (currentSelectedFile) {
const selectedConfig = configFiles.find(
(f) => f.name === currentSelectedFile
);
if (selectedConfig) {
await parseAndApplyConfigFile(selectedConfig.path);
}
}
return currentSelectedFile;
},
[parseAndApplyConfigFile]
);
const handleServerOnlyChange = useCallback(async (checked: boolean) => {
setServerOnly(checked);
await window.electronAPI.config.setServerOnly(checked);
}, []);
const handleGpuLayersChange = useCallback(async (value: number) => {
setGpuLayers(value);
}, []);
const roundToValidContextSize = useCallback((value: number): number => {
if (value < 1024) {
return Math.round(value / 256) * 256;
}
return Math.round(value / 1024) * 1024;
}, []);
const handleContextSizeChangeWithStep = useCallback(
async (value: number) => {
const roundedValue = roundToValidContextSize(value);
setContextSize(roundedValue);
},
[roundToValidContextSize]
);
const handleModelPathChange = useCallback(async (value: string) => {
setModelPath(value);
await window.electronAPI.config.setModelPath(value);
}, []);
const handleSelectModelFile = useCallback(async () => {
const filePath = await window.electronAPI.kobold.selectModelFile();
if (filePath) {
await handleModelPathChange(filePath);
}
}, [handleModelPathChange]);
const handleAdditionalArgumentsChange = useCallback((value: string) => {
setAdditionalArguments(value);
}, []);
const handleAutoGpuLayersChange = useCallback((checked: boolean) => {
setAutoGpuLayers(checked);
}, []);
return {
serverOnly,
gpuLayers,
autoGpuLayers,
contextSize,
modelPath,
additionalArguments,
parseAndApplyConfigFile,
loadSavedSettings,
loadConfigFromFile,
handleServerOnlyChange,
handleGpuLayersChange,
handleAutoGpuLayersChange,
handleContextSizeChangeWithStep,
handleModelPathChange,
handleSelectModelFile,
handleAdditionalArgumentsChange,
};
};

View file

@ -0,0 +1,91 @@
import { createElement, type ReactNode } from 'react';
import { notifications } from '@mantine/notifications';
import { Check, X, Info, AlertTriangle } from 'lucide-react';
export const useNotifications = () => {
const success = (title: string, message?: string) => {
notifications.show({
title,
message,
color: 'green',
icon: createElement(Check, { size: 18 }),
position: 'top-right',
autoClose: 5000,
});
};
const error = (title: string, message?: string) => {
notifications.show({
title,
message,
color: 'red',
icon: createElement(X, { size: 18 }),
position: 'top-right',
autoClose: 7000,
});
};
const info = (title: string, message?: string) => {
notifications.show({
title,
message,
color: 'blue',
icon: createElement(Info, { size: 18 }),
position: 'top-right',
autoClose: 5000,
});
};
const warning = (title: string, message?: string) => {
notifications.show({
title,
message,
color: 'yellow',
icon: createElement(AlertTriangle, { size: 18 }),
position: 'top-right',
autoClose: 6000,
});
};
const custom = (options: {
title: string;
message?: string;
color?: string;
icon?: ReactNode;
autoClose?: number | false;
position?:
| 'top-left'
| 'top-right'
| 'top-center'
| 'bottom-left'
| 'bottom-right'
| 'bottom-center';
}) => {
const { title, message = '', ...rest } = options;
notifications.show({
title,
message,
position: 'top-right',
autoClose: 5000,
...rest,
});
};
const hide = (id: string) => {
notifications.hide(id);
};
const clean = () => {
notifications.clean();
};
return {
success,
error,
info,
warning,
custom,
hide,
clean,
};
};

View file

@ -1,5 +1 @@
@import '@mantine/core/styles.css';
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -3,12 +3,13 @@ import { join } from 'path';
import { existsSync, mkdirSync } from 'fs';
import { homedir } from 'os';
import { WindowManager } from './managers/WindowManager';
import { ConfigManager } from './managers/ConfigManager';
import { KoboldCppManager } from './managers/KoboldCppManager';
import { GitHubService } from './services/GitHubService';
import { GPUService } from './services/GPUService';
import { IPCHandlers } from './utils/IPCHandlers';
import { WindowManager } from '@/main/managers/WindowManager';
import { ConfigManager } from '@/main/managers/ConfigManager';
import { KoboldCppManager } from '@/main/managers/KoboldCppManager';
import { GitHubService } from '@/main/services/GitHubService';
import { GPUService } from '@/main/services/GPUService';
import { IPCHandlers } from '@/main/utils/IPCHandlers';
import { APP_NAME, CONFIG_FILE_NAME } from '@/constants/app';
class FriendlyKoboldApp {
private windowManager: WindowManager;
@ -25,7 +26,8 @@ class FriendlyKoboldApp {
this.gpuService = new GPUService();
this.koboldManager = new KoboldCppManager(
this.configManager,
this.githubService
this.githubService,
this.windowManager
);
this.ipcHandlers = new IPCHandlers(
this.koboldManager,
@ -38,7 +40,7 @@ class FriendlyKoboldApp {
}
private getConfigPath() {
return join(app.getPath('userData'), 'config.json');
return join(app.getPath('userData'), CONFIG_FILE_NAME);
}
private getDefaultInstallPath() {
@ -47,11 +49,11 @@ class FriendlyKoboldApp {
switch (platform) {
case 'win32':
return join(home, 'FriendlyKobold');
return join(home, APP_NAME);
case 'darwin':
return join(home, 'Applications', 'FriendlyKobold');
return join(home, 'Applications', APP_NAME);
default:
return join(home, '.local', 'share', 'friendly-kobold');
return join(home, '.local', 'share', APP_NAME);
}
}
@ -76,11 +78,25 @@ class FriendlyKoboldApp {
this.ipcHandlers.setupHandlers();
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
if (process.platform === 'darwin') {
return;
}
});
app.on('before-quit', async (event) => {
// Prevent immediate quit to allow cleanup
event.preventDefault();
// Clean up KoboldCpp process
await this.koboldManager.cleanup();
// Clean up window manager
this.windowManager.cleanup();
// Now actually quit
app.exit(0);
});
app.on('activate', () => {
if (!this.windowManager.getMainWindow()) {
this.windowManager.createMainWindow();

View file

@ -4,8 +4,10 @@ type ConfigValue = string | number | boolean | unknown[] | undefined;
interface AppConfig {
installDir?: string;
currentVersion?: string;
currentKoboldBinary?: string;
selectedConfig?: string;
serverOnly?: boolean;
modelPath?: string;
[key: string]: ConfigValue;
}
@ -55,12 +57,12 @@ export class ConfigManager {
this.saveConfig();
}
getCurrentVersion(): string | undefined {
return this.config.currentVersion;
getCurrentKoboldBinary(): string | undefined {
return this.config.currentKoboldBinary as string | undefined;
}
setCurrentVersion(version: string) {
this.config.currentVersion = version;
setCurrentKoboldBinary(binaryPath: string) {
this.config.currentKoboldBinary = binaryPath;
this.saveConfig();
}
@ -72,4 +74,22 @@ export class ConfigManager {
this.config.selectedConfig = configName;
this.saveConfig();
}
getServerOnly(): boolean {
return this.config.serverOnly || false;
}
setServerOnly(serverOnly: boolean) {
this.config.serverOnly = serverOnly;
this.saveConfig();
}
getModelPath(): string | undefined {
return this.config.modelPath;
}
setModelPath(path: string) {
this.config.modelPath = path;
this.saveConfig();
}
}

View file

@ -1,9 +1,18 @@
import { spawn, ChildProcess } from 'child_process';
import { join } from 'path';
import { existsSync, readdirSync, statSync, createWriteStream } from 'fs';
import {
existsSync,
readdirSync,
statSync,
createWriteStream,
chmodSync,
readFileSync,
} from 'fs';
import { dialog } from 'electron';
import { GitHubService } from '../services/GitHubService';
import { ConfigManager } from './ConfigManager';
import { GitHubService } from '@/main/services/GitHubService';
import { ConfigManager } from '@/main/managers/ConfigManager';
import { WindowManager } from '@/main/managers/WindowManager';
import { APP_NAME, DIALOG_TITLES, ROCM } from '@/constants/app';
interface GitHubAsset {
name: string;
@ -39,7 +48,6 @@ interface ReleaseWithStatus {
export interface InstalledVersion {
version: string;
path: string;
type: 'github' | 'rocm';
downloadDate: string;
filename: string;
}
@ -49,13 +57,19 @@ export class KoboldCppManager {
private koboldProcess: ChildProcess | null = null;
private configManager: ConfigManager;
private githubService: GitHubService;
private windowManager: WindowManager;
constructor(configManager: ConfigManager, githubService: GitHubService) {
constructor(
configManager: ConfigManager,
githubService: GitHubService,
windowManager: WindowManager
) {
this.configManager = configManager;
this.githubService = githubService;
this.windowManager = windowManager;
this.installDir =
this.configManager.getInstallDir() ||
join(process.env.HOME || process.env.USERPROFILE || '.', 'KoboldCpp');
join(process.env.HOME || process.env.USERPROFILE || '.', APP_NAME);
}
async downloadRelease(
@ -105,14 +119,23 @@ export class KoboldCppManager {
reader.releaseLock();
}
if (process.platform !== 'win32') {
try {
chmodSync(filePath, 0o755);
} catch (error) {
console.warn('Failed to make binary executable:', error);
}
}
const currentBinary = this.configManager.getCurrentKoboldBinary();
if (!currentBinary) {
this.configManager.setCurrentKoboldBinary(filePath);
}
return filePath;
}
async getInstalledVersions(): Promise<InstalledVersion[]> {
const configData = this.configManager.get('installedVersions');
const configVersions: InstalledVersion[] = Array.isArray(configData)
? (configData as unknown as InstalledVersion[])
: [];
const scannedVersions: InstalledVersion[] = [];
try {
@ -126,48 +149,26 @@ export class KoboldCppManager {
statSync(filePath).isFile() &&
(file.includes('koboldcpp') || file.includes('kobold'))
) {
const existingVersion = configVersions.find(
(v: InstalledVersion) => v.path === filePath
);
try {
const detectedVersion = await this.getVersionFromBinary(filePath);
const version = detectedVersion || 'unknown';
if (existingVersion) {
scannedVersions.push(existingVersion);
} else {
try {
const detectedVersion =
await this.getVersionFromBinary(filePath);
const version = detectedVersion || 'unknown';
const newVersion: InstalledVersion = {
version,
path: filePath,
downloadDate: new Date().toISOString(),
filename: file,
};
const newVersion: InstalledVersion = {
version,
path: filePath,
type: 'github',
downloadDate: new Date().toISOString(),
filename: file,
};
scannedVersions.push(newVersion);
} catch (error) {
console.warn(`Could not detect version for ${file}:`, error);
}
scannedVersions.push(newVersion);
} catch (error) {
console.warn(`Could not detect version for ${file}:`, error);
}
}
}
}
} catch (error) {
console.warn('Error scanning install directory:', error);
return configVersions.filter((version: InstalledVersion) =>
existsSync(version.path)
);
}
if (
scannedVersions.length !== configVersions.length ||
!scannedVersions.every((sv) =>
configVersions.some((cv: InstalledVersion) => cv.path === sv.path)
)
) {
this.configManager.set('installedVersions', scannedVersions as unknown[]);
}
return scannedVersions;
@ -210,60 +211,87 @@ export class KoboldCppManager {
return configFiles.sort((a, b) => a.name.localeCompare(b.name));
}
async parseConfigFile(filePath: string): Promise<{
gpulayers?: number;
contextsize?: number;
model_param?: string;
[key: string]: unknown;
} | null> {
try {
if (!existsSync(filePath)) {
return null;
}
const content = readFileSync(filePath, 'utf-8');
const config = JSON.parse(content);
return config;
} catch (error) {
console.warn('Error parsing config file:', error);
return null;
}
}
async selectModelFile(): Promise<string | null> {
try {
const mainWindow = this.windowManager.getMainWindow();
if (!mainWindow) {
return null;
}
const result = await dialog.showOpenDialog(mainWindow, {
title: 'Select Model File',
filters: [
{ name: 'GGUF Files', extensions: ['gguf'] },
{ name: 'All Files', extensions: ['*'] },
],
properties: ['openFile'],
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
return result.filePaths[0];
} catch (error) {
console.warn('Error selecting model file:', error);
return null;
}
}
async getCurrentVersion(): Promise<InstalledVersion | null> {
const versions = await this.getInstalledVersions();
const currentVersionString = this.configManager.getCurrentVersion();
const currentBinaryPath = this.configManager.getCurrentKoboldBinary();
if (currentVersionString) {
const found =
versions.find((v) => v.version === currentVersionString) || null;
return found;
if (currentBinaryPath) {
const found = versions.find((v) => v.path === currentBinaryPath);
if (found && existsSync(found.path)) {
return found;
}
// If the current binary no longer exists, clear it
this.configManager.setCurrentKoboldBinary('');
}
const fallback =
// If no current binary is set, return the most recent one
return (
versions.sort(
(a, b) =>
new Date(b.downloadDate).getTime() -
new Date(a.downloadDate).getTime()
)[0] || null;
return fallback;
)[0] || null
);
}
async setCurrentVersion(version: string): Promise<boolean> {
const versions = await this.getInstalledVersions();
const targetVersion = versions.find((v) => v.version === version);
if (!targetVersion || !existsSync(targetVersion.path)) {
if (targetVersion) {
const updatedVersions = versions.filter((v) => v.version !== version);
this.configManager.set(
'installedVersions',
updatedVersions as unknown[]
);
}
return false;
if (targetVersion && existsSync(targetVersion.path)) {
this.configManager.setCurrentKoboldBinary(targetVersion.path);
return true;
}
this.configManager.setCurrentVersion(version);
const installedVersionsData = this.configManager.get('installedVersions');
const installedVersions: InstalledVersion[] = Array.isArray(
installedVersionsData
)
? (installedVersionsData as unknown as InstalledVersion[])
: [];
const versionToUpdate = installedVersions.find(
(v: InstalledVersion) => v.version === version
);
if (versionToUpdate) {
this.configManager.set(
'installedVersions',
installedVersions as unknown[]
);
}
return true;
return false;
}
async getVersionFromBinary(binaryPath: string): Promise<string | null> {
@ -338,48 +366,33 @@ export class KoboldCppManager {
return this.installDir;
}
getWindowManager() {
return this.windowManager;
}
async selectInstallDirectory(): Promise<string | null> {
const result = await dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
title: 'Select KoboldCpp Installation Directory',
title: DIALOG_TITLES.SELECT_INSTALL_DIR,
defaultPath: this.installDir,
buttonLabel: 'Select Directory',
});
if (!result.canceled && result.filePaths.length > 0) {
const newPath = join(result.filePaths[0], 'KoboldCpp');
this.installDir = newPath;
this.configManager.setInstallDir(newPath);
return newPath;
this.installDir = result.filePaths[0];
this.configManager.setInstallDir(result.filePaths[0]);
const mainWindow = this.windowManager.getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('install-dir-changed', result.filePaths[0]);
}
return result.filePaths[0];
}
return null;
}
async addInstalledVersion(
version: string,
path: string,
type: 'github' | 'rocm' = 'github'
) {
const versions = await this.getInstalledVersions();
const filteredVersions = versions.filter((v) => v.version !== version);
const filename = path.split(/[/\\]/).pop() || 'unknown';
const newVersion: InstalledVersion = {
version,
path,
type,
downloadDate: new Date().toISOString(),
filename,
};
this.configManager.set('installedVersions', [
...filteredVersions,
newVersion,
] as unknown[]);
this.configManager.setCurrentVersion(version);
}
async launchKobold(
versionPath: string,
args: string[] = [],
@ -439,7 +452,6 @@ export class KoboldCppManager {
name: string;
url: string;
size: number;
type: 'rocm';
version?: string;
} | null> {
const platform = process.platform;
@ -451,15 +463,14 @@ export class KoboldCppManager {
const version = latestRelease?.tag_name?.replace(/^v/, '') || 'unknown';
return {
name: 'koboldcpp-linux-x64-rocm',
url: 'https://koboldai.org/cpplinuxrocm',
size: 1024 * 1024 * 1024,
type: 'rocm',
name: ROCM.BINARY_NAME,
url: ROCM.DOWNLOAD_URL,
size: ROCM.SIZE_BYTES,
version,
};
}
async downloadROCm(): Promise<{
async downloadROCm(onProgress?: (progress: number) => void): Promise<{
success: boolean;
path?: string;
error?: string;
@ -469,11 +480,11 @@ export class KoboldCppManager {
if (platform !== 'linux') {
return {
success: false,
error: 'ROCm version is only available for Linux',
error: ROCM.ERROR_MESSAGE,
};
}
const response = await fetch('https://koboldai.org/cpplinuxrocm');
const response = await fetch(ROCM.DOWNLOAD_URL);
if (!response.ok) {
return {
success: false,
@ -481,24 +492,52 @@ export class KoboldCppManager {
};
}
const filePath = join(this.installDir, 'koboldcpp-linux-x64-rocm');
const totalBytes = ROCM.SIZE_BYTES;
let downloadedBytes = 0;
const reader = response.body?.getReader();
if (!reader) {
return {
success: false,
error: 'Failed to get response reader',
};
}
const filePath = join(this.installDir, ROCM.BINARY_NAME);
const writer = createWriteStream(filePath);
response.body?.pipeTo(
new WritableStream({
write(chunk) {
writer.write(chunk);
},
close() {
writer.end();
},
})
);
try {
while (true) {
const { done, value } = await reader.read();
await new Promise<void>((resolve, reject) => {
writer.on('finish', resolve);
writer.on('error', reject);
});
if (done) break;
downloadedBytes += value.length;
writer.write(value);
if (onProgress && totalBytes > 0) {
onProgress((downloadedBytes / totalBytes) * 100);
}
}
writer.end();
await new Promise<void>((resolve, reject) => {
writer.on('finish', resolve);
writer.on('error', reject);
});
} finally {
reader.releaseLock();
}
// Make the binary executable on Unix-like systems (Linux/macOS)
if (process.platform !== 'win32') {
try {
chmodSync(filePath, 0o755);
} catch (error) {
console.warn('Failed to make ROCm binary executable:', error);
}
}
return {
success: true,
@ -589,65 +628,122 @@ export class KoboldCppManager {
}
}
async openInstallDialog(): Promise<{
success: boolean;
version?: string;
path?: string;
error?: string;
}> {
try {
const result = await dialog.showOpenDialog({
title: 'Select KoboldCpp executable',
filters: [
{ name: 'Executables', extensions: ['exe', 'app', 'AppImage'] },
{ name: 'All Files', extensions: ['*'] },
],
properties: ['openFile'],
});
if (!result.canceled && result.filePaths.length > 0) {
const filePath = result.filePaths[0];
const detectedVersion = await this.getVersionFromBinary(filePath);
const version = detectedVersion || 'unknown';
await this.addInstalledVersion(version, filePath);
return {
success: true,
version,
path: filePath,
};
}
return { success: false, error: 'No file selected' };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
async launchKoboldCpp(
args: string[] = []
args: string[] = [],
configFilePath?: string
): Promise<{ success: boolean; pid?: number; error?: string }> {
try {
if (this.koboldProcess) {
this.stopKoboldCpp();
}
const currentVersion = await this.getCurrentVersion();
if (!currentVersion || !existsSync(currentVersion.path)) {
return {
success: false,
error: 'KoboldCpp not found or no version selected',
error: 'KoboldCpp not found',
};
}
const child = spawn(currentVersion.path, args, {
detached: true,
stdio: 'ignore',
const finalArgs = [...args]; // Start with the provided arguments
if (configFilePath && existsSync(configFilePath)) {
finalArgs.push('--config', configFilePath);
}
const child = spawn(currentVersion.path, finalArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
detached: false,
});
await this.setCurrentVersion(currentVersion.version);
child.unref();
this.koboldProcess = child;
const mainWindow = this.windowManager.getMainWindow();
if (mainWindow) {
child.stdout?.on('data', (data) => {
const output = data.toString();
mainWindow.webContents.send('kobold-output', output);
});
child.stderr?.on('data', (data) => {
const output = data.toString();
mainWindow.webContents.send('kobold-output', output);
});
child.on('exit', (code, signal) => {
const exitMessage = signal
? `\nProcess terminated with signal ${signal}\n`
: `\nProcess exited with code ${code}\n`;
mainWindow.webContents.send('kobold-output', exitMessage);
this.koboldProcess = null;
});
child.on('error', (error) => {
mainWindow.webContents.send(
'kobold-output',
`\nProcess error: ${error.message}\n`
);
this.koboldProcess = null;
});
}
return { success: true, pid: child.pid };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
stopKoboldCpp(): void {
if (this.koboldProcess) {
try {
// Try graceful termination first
this.koboldProcess.kill('SIGTERM');
// Force kill after 5 seconds if still running
setTimeout(() => {
if (this.koboldProcess && !this.koboldProcess.killed) {
this.koboldProcess.kill('SIGKILL');
}
}, 5000);
this.koboldProcess = null;
} catch (error) {
console.warn('Error stopping KoboldCpp process:', error);
this.koboldProcess = null;
}
}
}
// Method to handle app termination - ensures process cleanup
async cleanup(): Promise<void> {
if (this.koboldProcess) {
return new Promise((resolve) => {
if (!this.koboldProcess) {
resolve();
return;
}
// Set up cleanup timeout
const cleanup = () => {
this.koboldProcess = null;
resolve();
};
// Listen for process exit
this.koboldProcess.once('exit', cleanup);
this.koboldProcess.once('error', cleanup);
// Try graceful shutdown
this.koboldProcess.kill('SIGTERM');
// Force kill after 3 seconds
setTimeout(() => {
if (this.koboldProcess && !this.koboldProcess.killed) {
this.koboldProcess.kill('SIGKILL');
}
cleanup();
}, 3000);
});
}
}
}

View file

@ -1,8 +1,10 @@
import { BrowserWindow, app, Menu, shell } from 'electron';
import { BrowserWindow, app, Menu, shell, Tray, nativeImage } from 'electron';
import { join } from 'path';
export class WindowManager {
private mainWindow: BrowserWindow | null = null;
private tray: Tray | null = null;
private isQuitting = false;
createMainWindow(): BrowserWindow {
this.mainWindow = new BrowserWindow({
@ -28,6 +30,14 @@ export class WindowManager {
this.mainWindow = null;
});
this.mainWindow.on('close', (event) => {
if (this.tray && !this.isQuitting) {
event.preventDefault();
this.mainWindow?.hide();
}
});
this.createSystemTray();
this.setupContextMenu();
return this.mainWindow;
}
@ -36,10 +46,60 @@ export class WindowManager {
return this.mainWindow;
}
private createSystemTray() {
// Create system tray icon
const iconPath = join(process.cwd(), 'assets', 'icon.png');
this.tray = new Tray(nativeImage.createFromPath(iconPath));
this.tray.setToolTip('Friendly Kobold');
// Create context menu for tray
const trayMenu = Menu.buildFromTemplate([
{
label: 'Show',
click: () => {
this.mainWindow?.show();
},
},
{
label: 'Hide',
click: () => {
this.mainWindow?.hide();
},
},
{ type: 'separator' },
{
label: 'Quit',
click: () => {
this.isQuitting = true;
app.quit();
},
},
]);
this.tray.setContextMenu(trayMenu);
// Double-click to show/hide window
this.tray.on('double-click', () => {
if (this.mainWindow?.isVisible()) {
this.mainWindow.hide();
} else {
this.mainWindow?.show();
}
});
}
public cleanup() {
this.tray?.destroy();
this.tray = null;
}
private setupContextMenu() {
if (!this.mainWindow) return;
this.mainWindow.webContents.on('context-menu', (_event, params) => {
const hasLinkURL = !!params.linkURL;
const menu = Menu.buildFromTemplate([
{
label: 'Inspect Element',
@ -53,10 +113,10 @@ export class WindowManager {
{ label: 'Paste', role: 'paste' },
{ type: 'separator' },
{ label: 'Select All', role: 'selectAll' },
{ type: 'separator' },
...(hasLinkURL ? [{ type: 'separator' as const }] : []),
{
label: 'Open Link in Browser',
visible: !!params.linkURL,
visible: hasLinkURL,
click: () => {
if (params.linkURL) {
shell.openExternal(params.linkURL);
@ -78,6 +138,7 @@ export class WindowManager {
label: 'Quit',
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
click: () => {
this.isQuitting = true;
app.quit();
},
},

View file

@ -1,4 +1,5 @@
import type { GitHubRelease } from '../../types/electron';
import type { GitHubRelease } from '@/types/electron';
import { GITHUB_API } from '@/constants/app';
export class GitHubService {
private lastApiCall = 0;
@ -13,16 +14,14 @@ export class GitHubService {
}
try {
const response = await fetch(
'https://api.github.com/repos/LostRuins/koboldcpp/releases/latest'
);
const response = await fetch(GITHUB_API.LATEST_RELEASE_URL);
if (!response.ok) {
if (response.status === 403) {
console.warn(
'GitHub API rate limit reached, using cached data or fallback'
'GitHub API rate limit reached, using cached data if available'
);
return this.cachedRelease || this.getFallbackRelease();
return this.cachedRelease;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
@ -32,7 +31,7 @@ export class GitHubService {
return this.cachedRelease;
} catch (error) {
console.error('Error fetching latest release:', error);
return this.cachedRelease || this.getFallbackRelease();
return this.cachedRelease;
}
}
@ -47,16 +46,14 @@ export class GitHubService {
}
try {
const response = await fetch(
'https://api.github.com/repos/LostRuins/koboldcpp/releases'
);
const response = await fetch(GITHUB_API.ALL_RELEASES_URL);
if (!response.ok) {
if (response.status === 403) {
console.warn('GitHub API rate limit reached, using cached data');
return this.cachedReleases.length > 0
? this.cachedReleases
: [this.getFallbackRelease()];
console.warn(
'GitHub API rate limit reached, using cached data if available'
);
return this.cachedReleases;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
@ -66,32 +63,7 @@ export class GitHubService {
return this.cachedReleases;
} catch (error) {
console.error('Error fetching releases:', error);
return this.cachedReleases.length > 0
? this.cachedReleases
: [this.getFallbackRelease()];
return this.cachedReleases;
}
}
private getFallbackRelease(): GitHubRelease {
return {
tag_name: 'v1.70.1',
name: 'KoboldCpp v1.70.1 (Fallback)',
published_at: new Date().toISOString(),
body: 'Fallback release data - GitHub API unavailable',
assets: [
{
name: 'koboldcpp-linux-x64',
browser_download_url: 'https://koboldai.org/cpp',
size: 50000000,
created_at: new Date().toISOString(),
},
{
name: 'koboldcpp-linux-x64-rocm',
browser_download_url: 'https://koboldai.org/cpplinuxrocm',
size: 80000000,
created_at: new Date().toISOString(),
},
],
};
}
}

View file

@ -1,9 +1,9 @@
import { ipcMain } from 'electron';
import { ipcMain, dialog } from 'electron';
import { shell, app } from 'electron';
import { KoboldCppManager } from '../managers/KoboldCppManager';
import { ConfigManager } from '../managers/ConfigManager';
import { GitHubService } from '../services/GitHubService';
import { GPUService } from '../services/GPUService';
import { KoboldCppManager } from '@/main/managers/KoboldCppManager';
import { ConfigManager } from '@/main/managers/ConfigManager';
import { GitHubService } from '@/main/services/GitHubService';
import { GPUService } from '@/main/services/GPUService';
export class IPCHandlers {
private koboldManager: KoboldCppManager;
@ -32,11 +32,43 @@ export class IPCHandlers {
this.githubService.getAllReleases()
);
ipcMain.handle(
'kobold:downloadRelease',
async (_event, asset, onProgress) =>
this.koboldManager.downloadRelease(asset, onProgress)
);
ipcMain.handle('kobold:checkForUpdates', async () => {
const latest = await this.githubService.getLatestRelease();
return latest;
});
ipcMain.handle('kobold:openInstallDialog', async () => {
const result = await dialog.showOpenDialog({
properties: ['openDirectory'],
title: 'Select Installation Directory',
});
if (!result.canceled && result.filePaths.length > 0) {
return result.filePaths[0];
}
return null;
});
ipcMain.handle('kobold:downloadRelease', async (_event, asset) => {
try {
const mainWindow = this.koboldManager
.getWindowManager()
.getMainWindow();
const filePath = await this.koboldManager.downloadRelease(
asset,
(progress: number) => {
if (mainWindow) {
mainWindow.webContents.send('download-progress', progress);
}
}
);
return { success: true, path: filePath };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('kobold:getInstalledVersions', () =>
this.koboldManager.getInstalledVersions()
@ -74,12 +106,6 @@ export class IPCHandlers {
this.koboldManager.selectInstallDirectory()
);
ipcMain.handle(
'kobold:addInstalledVersion',
(_event, version, path, type) =>
this.koboldManager.addInstalledVersion(version, path, type)
);
ipcMain.handle('kobold:detectGPU', () => this.gpuService.detectGPU());
ipcMain.handle('kobold:getPlatform', () => ({
@ -93,20 +119,20 @@ export class IPCHandlers {
ipcMain.handle('kobold:downloadROCm', async () => {
try {
return await this.koboldManager.downloadROCm();
const mainWindow = this.koboldManager
.getWindowManager()
.getMainWindow();
return await this.koboldManager.downloadROCm((progress: number) => {
if (mainWindow) {
mainWindow.webContents.send('download-progress', progress);
}
});
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('kobold:launchKobold', (_event, versionPath, args) =>
this.koboldManager.launchKobold(versionPath, args)
);
ipcMain.handle('kobold:stopKobold', () => this.koboldManager.stopKobold());
ipcMain.handle('kobold:isRunning', () => this.koboldManager.isRunning());
ipcMain.handle('kobold:getInstalledVersion', () =>
this.koboldManager.getInstalledVersion()
);
@ -115,20 +141,58 @@ export class IPCHandlers {
this.koboldManager.getVersionFromBinary(binaryPath)
);
ipcMain.handle('kobold:checkForUpdates', () =>
this.koboldManager.checkForUpdates()
);
ipcMain.handle('kobold:getLatestReleaseWithStatus', () =>
this.koboldManager.getLatestReleaseWithDownloadStatus()
);
ipcMain.handle('kobold:launchKoboldCpp', (_event, args) =>
this.koboldManager.launchKoboldCpp(args)
ipcMain.handle('kobold:launchKoboldCpp', (_event, args, configFilePath) =>
this.koboldManager.launchKoboldCpp(args, configFilePath)
);
ipcMain.handle('kobold:openInstallDialog', () =>
this.koboldManager.openInstallDialog()
ipcMain.handle('kobold:stopKoboldCpp', () =>
this.koboldManager.stopKoboldCpp()
);
ipcMain.handle('kobold:confirmEject', async () => {
const mainWindow = this.koboldManager.getWindowManager().getMainWindow();
if (!mainWindow) return false;
const result = await dialog.showMessageBox(mainWindow, {
type: 'warning',
title: 'Confirm Eject',
message: 'Are you sure you want to stop KoboldCpp?',
detail:
'This will terminate the running process and return to the launch screen.',
buttons: ['Cancel', 'Stop KoboldCpp'],
defaultId: 0,
cancelId: 0,
});
return result.response === 1; // Returns true if user clicked "Stop KoboldCpp"
});
ipcMain.handle('kobold:parseConfigFile', (_event, filePath) =>
this.koboldManager.parseConfigFile(filePath)
);
ipcMain.handle('kobold:selectModelFile', () =>
this.koboldManager.selectModelFile()
);
ipcMain.handle('config:getServerOnly', () =>
this.configManager.getServerOnly()
);
ipcMain.handle('config:setServerOnly', (_event, serverOnly) =>
this.configManager.setServerOnly(serverOnly)
);
ipcMain.handle('config:getModelPath', () =>
this.configManager.getModelPath()
);
ipcMain.handle('config:setModelPath', (_event, path) =>
this.configManager.setModelPath(path)
);
ipcMain.handle('config:get', (_event, key) => this.configManager.get(key));

View file

@ -1,5 +1,10 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron';
import type { KoboldAPI, AppAPI, UpdateInfo } from '../types/electron';
import type {
KoboldAPI,
AppAPI,
ConfigAPI,
UpdateInfo,
} from '@/types/electron';
const koboldAPI: KoboldAPI = {
isInstalled: () => ipcRenderer.invoke('kobold:isInstalled'),
@ -10,7 +15,6 @@ const koboldAPI: KoboldAPI = {
ipcRenderer.invoke('kobold:setCurrentVersion', version),
getVersionFromBinary: (binaryPath: string) =>
ipcRenderer.invoke('kobold:getVersionFromBinary', binaryPath),
checkForUpdates: () => ipcRenderer.invoke('kobold:checkForUpdates'),
getLatestReleaseWithStatus: () =>
ipcRenderer.invoke('kobold:getLatestReleaseWithStatus'),
getROCmDownload: () => ipcRenderer.invoke('kobold:getROCmDownload'),
@ -24,12 +28,19 @@ const koboldAPI: KoboldAPI = {
ipcRenderer.invoke('kobold:selectInstallDirectory'),
downloadRelease: (asset) =>
ipcRenderer.invoke('kobold:downloadRelease', asset),
launchKoboldCpp: (args) => ipcRenderer.invoke('kobold:launchKoboldCpp', args),
launchKoboldCpp: (args?: string[], configFilePath?: string) =>
ipcRenderer.invoke('kobold:launchKoboldCpp', args, configFilePath),
openInstallDialog: () => ipcRenderer.invoke('kobold:openInstallDialog'),
checkForUpdates: () => ipcRenderer.invoke('kobold:checkForUpdates'),
getConfigFiles: () => ipcRenderer.invoke('kobold:getConfigFiles'),
getSelectedConfig: () => ipcRenderer.invoke('kobold:getSelectedConfig'),
setSelectedConfig: (configName: string) =>
ipcRenderer.invoke('kobold:setSelectedConfig', configName),
parseConfigFile: (filePath: string) =>
ipcRenderer.invoke('kobold:parseConfigFile', filePath),
selectModelFile: () => ipcRenderer.invoke('kobold:selectModelFile'),
stopKoboldCpp: () => ipcRenderer.invoke('kobold:stopKoboldCpp'),
confirmEject: () => ipcRenderer.invoke('kobold:confirmEject'),
onDownloadProgress: (callback) => {
ipcRenderer.on(
'download-progress',
@ -42,6 +53,22 @@ const koboldAPI: KoboldAPI = {
(_: IpcRendererEvent, updateInfo: UpdateInfo) => callback(updateInfo)
);
},
onInstallDirChanged: (callback: (newPath: string) => void) => {
const handler = (_: IpcRendererEvent, newPath: string) => callback(newPath);
ipcRenderer.on('install-dir-changed', handler);
return () => {
ipcRenderer.removeListener('install-dir-changed', handler);
};
},
onKoboldOutput: (callback: (data: string) => void) => {
const handler = (_: IpcRendererEvent, data: string) => callback(data);
ipcRenderer.on('kobold-output', handler);
return () => {
ipcRenderer.removeListener('kobold-output', handler);
};
},
removeAllListeners: (channel) => {
ipcRenderer.removeAllListeners(channel);
},
@ -52,7 +79,20 @@ const appAPI: AppAPI = {
openExternal: (url) => ipcRenderer.invoke('app:openExternal', url),
};
const configAPI: ConfigAPI = {
getServerOnly: () => ipcRenderer.invoke('config:getServerOnly'),
setServerOnly: (serverOnly: boolean) =>
ipcRenderer.invoke('config:setServerOnly', serverOnly),
getModelPath: () => ipcRenderer.invoke('config:getModelPath'),
setModelPath: (path: string) =>
ipcRenderer.invoke('config:setModelPath', path),
get: (key: string) => ipcRenderer.invoke('config:get', key),
set: (key: string, value: unknown) =>
ipcRenderer.invoke('config:set', key, value),
};
contextBridge.exposeInMainWorld('electronAPI', {
kobold: koboldAPI,
app: appAPI,
config: configAPI,
});

View file

@ -1,16 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import {
Card,
Text,
Title,
Loader,
Alert,
Stack,
Container,
Progress,
} from '@mantine/core';
import { IconAlertCircle } from '@tabler/icons-react';
import { DownloadOptionCard } from '../components/DownloadOptionCard';
import { Card, Text, Title, Loader, Stack, Container } from '@mantine/core';
import { useNotifications } from '@/hooks/useNotifications';
import { DownloadOptionCard } from '@/components/DownloadOptionCard';
import {
getPlatformDisplayName,
filterAssetsByPlatform,
@ -21,27 +12,15 @@ import {
isAssetRecommended,
sortAssetsByRecommendation,
} from '@/utils/assets';
import { ROCM } from '@/constants/app';
import type { GitHubAsset, GitHubRelease } from '@/types';
interface DownloadScreenProps {
onInstallComplete: () => void;
onDownloadComplete: () => void;
}
interface GitHubAsset {
name: string;
browser_download_url: string;
size: number;
created_at: string;
}
interface GitHubRelease {
tag_name: string;
name: string;
published_at: string;
body: string;
assets: GitHubAsset[];
}
export const DownloadScreen = ({ onInstallComplete }: DownloadScreenProps) => {
export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
const notify = useNotifications();
const [latestRelease, setLatestRelease] = useState<GitHubRelease | null>(
null
);
@ -53,15 +32,15 @@ export const DownloadScreen = ({ onInstallComplete }: DownloadScreenProps) => {
const [loading, setLoading] = useState(true);
const [downloading, setDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [downloadingType, setDownloadingType] = useState<
'asset' | 'rocm' | null
>(null);
const [rocmDownload, setRocmDownload] = useState<{
name: string;
url: string;
size: number;
type: 'rocm';
version?: string;
} | null>(null);
const [downloadingROCm, setDownloadingROCm] = useState(false);
const loadLatestReleaseAndPlatform = useCallback(async () => {
if (!window.electronAPI) return;
@ -90,18 +69,25 @@ export const DownloadScreen = ({ onInstallComplete }: DownloadScreenProps) => {
setHasAMDGPU(false);
}
const filtered = filterAssetsByPlatform(
releaseData.assets,
platformInfo.platform
);
setFilteredAssets(filtered);
if (releaseData) {
const filtered = filterAssetsByPlatform(
releaseData.assets,
platformInfo.platform
);
setFilteredAssets(filtered);
} else {
notify.error(
'Error',
'GitHub API is currently unavailable. Please try again later.'
);
}
} catch (err) {
setError('Failed to load release information');
notify.error('Error', 'Failed to load release information');
console.error('Error loading release:', err);
} finally {
setLoading(false);
}
}, []);
}, [notify]);
useEffect(() => {
loadLatestReleaseAndPlatform();
@ -119,83 +105,73 @@ export const DownloadScreen = ({ onInstallComplete }: DownloadScreenProps) => {
};
}, [loadLatestReleaseAndPlatform]);
const handleDownload = async () => {
if (!selectedAsset || !window.electronAPI) return;
const handleDownload = async (type: 'asset' | 'rocm' = 'asset') => {
if (!window.electronAPI) return;
if (type === 'asset' && !selectedAsset) return;
try {
setDownloading(true);
setDownloadProgress(0);
setError(null);
setDownloadingType(type);
const result =
await window.electronAPI.kobold.downloadRelease(selectedAsset);
type === 'rocm'
? await window.electronAPI.kobold.downloadROCm()
: await window.electronAPI.kobold.downloadRelease(selectedAsset!);
if (result.success) {
onInstallComplete();
onDownloadComplete();
} else {
setError(result.error || 'Download failed');
notify.error(
'Download Failed',
result.error || `${type === 'rocm' ? 'ROCm' : ''} Download failed`
);
}
} catch (err) {
setError('Download failed');
console.error('Download error:', err);
notify.error(
'Download Failed',
`${type === 'rocm' ? 'ROCm' : ''} Download failed`
);
console.error(`${type === 'rocm' ? 'ROCm' : ''} Download error:`, err);
} finally {
setDownloading(false);
setDownloadProgress(0);
setDownloadingType(null);
}
};
const handleDownloadROCm = async () => {
if (!window.electronAPI) return;
const renderROCmCard = () => {
if (!rocmDownload) return null;
try {
setDownloadingROCm(true);
setDownloadProgress(0);
setError(null);
const result = await window.electronAPI.kobold.downloadROCm();
if (result.success) {
onInstallComplete();
} else {
setError(result.error || 'ROCm download failed');
}
} catch (err) {
setError('ROCm download failed');
console.error('ROCm download error:', err);
} finally {
setDownloadingROCm(false);
setDownloadProgress(0);
}
return (
<DownloadOptionCard
name={ROCM.BINARY_NAME}
description={getAssetDescription(ROCM.BINARY_NAME)}
size={formatFileSize(ROCM.SIZE_BYTES)}
isSelected={selectedROCm}
isRecommended={isAssetRecommended(ROCM.BINARY_NAME, hasAMDGPU)}
isDownloading={downloading && downloadingType === 'rocm'}
downloadProgress={downloadingType === 'rocm' ? downloadProgress : 0}
onClick={() => {
if (!selectedROCm) {
setSelectedROCm(true);
setSelectedAsset(null);
}
}}
onDownload={(e) => {
e.stopPropagation();
handleDownload('rocm');
}}
/>
);
};
return (
<Container size="md" py="xl">
<Container size="sm" py="xl">
<Stack gap="xl">
{error && (
<Alert
icon={<IconAlertCircle size="1rem" />}
color="red"
variant="light"
>
{error}
</Alert>
)}
{downloading && selectedAsset && (
<Card withBorder radius="md" shadow="sm">
<Stack gap="md">
<Text fw={500}>Downloading {selectedAsset.name}...</Text>
<Progress value={downloadProgress} color="blue" radius="xl" />
<Text size="sm" c="dimmed">
{downloadProgress.toFixed(1)}% complete
</Text>
</Stack>
</Card>
)}
<Card withBorder radius="md" shadow="sm">
<Stack gap="lg">
<Title order={3}>Available Downloads for Your Platform</Title>
<Title order={3}>Available Binaries for Your Platform</Title>
{loading ? (
<Stack align="center" gap="md" py="xl">
@ -234,31 +210,7 @@ export const DownloadScreen = ({ onInstallComplete }: DownloadScreenProps) => {
{filteredAssets.length > 0 || rocmDownload ? (
<Stack gap="sm">
{rocmDownload && hasAMDGPU && (
<DownloadOptionCard
name="koboldcpp-linux-x64-rocm"
description={getAssetDescription(
'koboldcpp-linux-x64-rocm'
)}
size="~1GB"
isSelected={selectedROCm}
isRecommended={isAssetRecommended(
'koboldcpp-linux-x64-rocm',
hasAMDGPU
)}
isDownloading={downloadingROCm}
onClick={() => {
if (!selectedROCm) {
setSelectedROCm(true);
setSelectedAsset(null);
}
}}
onDownload={(e) => {
e.stopPropagation();
handleDownloadROCm();
}}
/>
)}
{rocmDownload && hasAMDGPU && renderROCmCard()}
{sortAssetsByRecommendation(
filteredAssets,
@ -274,7 +226,18 @@ export const DownloadScreen = ({ onInstallComplete }: DownloadScreenProps) => {
asset.name,
hasAMDGPU
)}
isDownloading={downloading}
isDownloading={
downloading &&
downloadingType === 'asset' &&
selectedAsset?.name === asset.name
}
downloadProgress={
downloading &&
downloadingType === 'asset' &&
selectedAsset?.name === asset.name
? downloadProgress
: 0
}
onClick={() => {
if (selectedAsset?.name !== asset.name) {
setSelectedAsset(asset);
@ -288,48 +251,18 @@ export const DownloadScreen = ({ onInstallComplete }: DownloadScreenProps) => {
/>
))}
{rocmDownload && !hasAMDGPU && (
<DownloadOptionCard
name="koboldcpp-linux-x64-rocm"
description={getAssetDescription(
'koboldcpp-linux-x64-rocm'
)}
size="~1GB"
isSelected={selectedROCm}
isRecommended={isAssetRecommended(
'koboldcpp-linux-x64-rocm',
hasAMDGPU
)}
isDownloading={downloadingROCm}
onClick={() => {
if (!selectedROCm) {
setSelectedROCm(true);
setSelectedAsset(null);
}
}}
onDownload={(e) => {
e.stopPropagation();
handleDownloadROCm();
}}
/>
)}
{rocmDownload && !hasAMDGPU && renderROCmCard()}
</Stack>
) : (
<Alert
icon={<IconAlertCircle size="1rem" />}
color="yellow"
variant="light"
>
<Card withBorder p="md" bg="yellow.0" c="yellow.9">
<Stack gap="xs">
<Text fw={500}>No downloads available</Text>
<Text size="sm">
No downloads available for your platform (
{getPlatformDisplayName(userPlatform)}). This might
be a new release that doesn&apos;t have builds ready
yet.
{getPlatformDisplayName(userPlatform)}).
</Text>
</Stack>
</Alert>
</Card>
)}
</>
)}

View file

@ -7,31 +7,57 @@ import {
Stack,
Group,
ActionIcon,
Select,
Switch,
Slider,
TextInput,
NumberInput,
Tooltip,
Checkbox,
} from '@mantine/core';
import { IconFile, IconRefresh } from '@tabler/icons-react';
import { RotateCcw, File, Info } from 'lucide-react';
import { useState, useEffect, useCallback } from 'react';
import { ConfigFileSelect } from '@/components/ConfigFileSelect';
import { useLaunchConfig } from '@/hooks/useLaunchConfig';
import type { ConfigFile } from '@/types';
interface ConfigFile {
name: string;
path: string;
size: number;
interface LaunchScreenProps {
onLaunch: () => void;
}
export const LaunchScreen = () => {
export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
const [configFiles, setConfigFiles] = useState<ConfigFile[]>([]);
const [loading, setLoading] = useState(true);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [, setInstallDir] = useState<string>('');
const {
serverOnly,
gpuLayers,
autoGpuLayers,
contextSize,
modelPath,
additionalArguments,
parseAndApplyConfigFile,
loadSavedSettings,
loadConfigFromFile,
handleServerOnlyChange,
handleGpuLayersChange,
handleAutoGpuLayersChange,
handleContextSizeChangeWithStep,
handleModelPathChange,
handleSelectModelFile,
handleAdditionalArgumentsChange,
} = useLaunchConfig();
const loadConfigFiles = useCallback(async () => {
try {
setLoading(true);
const [files, currentDir, savedConfig] = await Promise.all([
window.electronAPI.kobold.getConfigFiles(),
window.electronAPI.kobold.getCurrentInstallDir(),
window.electronAPI.kobold.getSelectedConfig(),
]);
setConfigFiles(files);
setInstallDir(currentDir);
@ -40,73 +66,78 @@ export const LaunchScreen = () => {
} else if (files.length > 0 && !selectedFile) {
setSelectedFile(files[0].name);
}
await loadSavedSettings();
const currentSelectedFile = await loadConfigFromFile(files, savedConfig);
if (currentSelectedFile && !selectedFile) {
setSelectedFile(currentSelectedFile);
}
} finally {
setLoading(false);
}
}, [selectedFile]);
}, [selectedFile, loadSavedSettings, loadConfigFromFile]);
const handleFileSelection = async (fileName: string) => {
setSelectedFile(fileName);
await window.electronAPI.kobold.setSelectedConfig(fileName);
const selectedConfig = configFiles.find((f) => f.name === fileName);
if (selectedConfig) {
await parseAndApplyConfigFile(selectedConfig.path);
}
};
useEffect(() => {
void loadConfigFiles();
const handleInstallDirChange = () => {
void loadConfigFiles();
};
const cleanup = window.electronAPI.kobold.onInstallDirChanged(
handleInstallDirChange
);
return cleanup;
}, [loadConfigFiles]);
const renderContent = () => {
if (loading) {
return (
<Text c="dimmed" ta="center">
Loading configuration files...
</Text>
);
}
if (configFiles.length === 0) {
return (
<Text c="dimmed" ta="center">
No configuration files found in the installation directory.
<br />
Please ensure your .kcpps or .kcppt files are in the correct location.
</Text>
);
}
const selectData = configFiles.map((file) => ({
value: file.name,
label: file.name,
}));
return (
<Select
label="Configuration File"
placeholder="Select a configuration file"
value={selectedFile}
onChange={(value) => value && handleFileSelection(value)}
data={selectData}
leftSection={<IconFile size={16} />}
searchable
clearable={false}
w="100%"
/>
);
};
const handleLaunch = async () => {
if (!selectedFile) return;
try {
const selectedConfig = configFiles.find((f) => f.name === selectedFile);
if (selectedConfig) {
const result = await window.electronAPI.kobold.launchKoboldCpp([
selectedConfig.path,
]);
if (result.success) {
// Launch successful
} else {
console.error('Launch failed:', result.error);
}
const selectedConfig = selectedFile
? configFiles.find((f) => f.name === selectedFile)
: null;
const args: string[] = [];
if (modelPath) {
args.push('--model', modelPath);
}
if (autoGpuLayers) {
args.push('--gpulayers', '-1');
} else if (gpuLayers > 0) {
args.push('--gpulayers', gpuLayers.toString());
}
if (contextSize) {
args.push('--contextsize', contextSize.toString());
}
if (additionalArguments.trim()) {
const additionalArgs = additionalArguments.trim().split(/\s+/);
args.push(...additionalArgs);
}
const result = await window.electronAPI.kobold.launchKoboldCpp(
args,
selectedConfig?.path
);
if (result.success) {
onLaunch();
} else {
console.error('Launch failed:', result.error);
}
} catch (error) {
console.error('Error launching KoboldCpp:', error);
@ -114,10 +145,10 @@ export const LaunchScreen = () => {
};
return (
<Container size="md" py="xl">
<Container size="sm" py="xl">
<Card withBorder radius="md" shadow="sm">
<Stack gap="lg" align="center">
<Title order={2}>Launch Configuration</Title>
<Stack gap="lg">
<Title order={3}>Launch Configuration</Title>
<Card withBorder radius="md" w="100%">
<Group justify="space-between" mb="md">
@ -128,11 +159,198 @@ export const LaunchScreen = () => {
loading={loading}
size="sm"
>
<IconRefresh size={16} />
<RotateCcw size={16} />
</ActionIcon>
</Group>
{renderContent()}
<ConfigFileSelect
configFiles={configFiles}
selectedFile={selectedFile}
loading={loading}
onFileSelection={handleFileSelection}
/>
</Card>
<Card withBorder radius="md" w="100%">
<Text fw={500} mb="md">
Launch Settings
</Text>
<Stack gap="l">
<div>
<Text size="sm" fw={500} mb="xs">
Model File
</Text>
<Group gap="xs">
<TextInput
placeholder="Select a .gguf model file"
value={modelPath}
onChange={(event) =>
handleModelPathChange(event.currentTarget.value)
}
style={{ flex: 1 }}
/>
<Button
onClick={handleSelectModelFile}
variant="light"
leftSection={<File size={16} />}
>
Browse
</Button>
</Group>
</div>
<div>
<Group justify="space-between" align="center" mb="xs">
<Group gap="xs" align="center">
<Text size="sm" fw={500}>
GPU Layers
</Text>
<Tooltip
label="The number of layer's to offload to your GPU's VRAM. Ideally the entire LLM should fit inside the VRAM for optimal performance."
multiline
w={300}
withArrow
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
</Group>
<Group gap="lg" align="center">
<Group gap="xs" align="center">
<Checkbox
label="Auto"
checked={autoGpuLayers}
onChange={(event) =>
handleAutoGpuLayersChange(event.currentTarget.checked)
}
size="sm"
/>
<Tooltip
label="Automatically try to allocate the GPU layers based on available VRAM."
multiline
w={300}
withArrow
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
</Group>
<NumberInput
value={gpuLayers}
onChange={(value) =>
handleGpuLayersChange(Number(value) || 0)
}
min={0}
max={100}
step={1}
size="sm"
w={80}
disabled={autoGpuLayers}
hideControls
/>
</Group>
</Group>
<Slider
value={gpuLayers}
min={0}
max={100}
step={1}
onChange={handleGpuLayersChange}
disabled={autoGpuLayers}
/>
</div>
<div>
<Group justify="space-between" align="center" mb="xs">
<Group gap="xs" align="center">
<Text size="sm" fw={500}>
Context Size
</Text>
<Tooltip
label="Controls the memory allocated for maximum context size. The larger the context, the larger the required memory."
multiline
w={300}
withArrow
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
</Group>
<NumberInput
value={contextSize}
onChange={(value) =>
handleContextSizeChangeWithStep(Number(value) || 256)
}
min={256}
max={131072}
step={256}
size="sm"
w={100}
hideControls
/>
</Group>
<Slider
value={contextSize}
min={256}
max={131072}
step={1}
onChange={handleContextSizeChangeWithStep}
/>
</div>
<div>
<Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}>
Additional arguments
</Text>
<Tooltip
label="Additional command line arguments to pass to the KoboldCPP binary. Leave this empty if you don't know what they are."
multiline
w={300}
withArrow
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
</Group>
<TextInput
placeholder="Additional command line arguments"
value={additionalArguments}
onChange={(event) =>
handleAdditionalArgumentsChange(event.currentTarget.value)
}
/>
</div>
<div>
<Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}>
Server-only mode
</Text>
<Tooltip
label="In server-only mode, the KoboldAI Lite web UI won't be displayed. Use this if you'll be using your own frontend to interact with the LLM."
multiline
w={300}
withArrow
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
</Group>
<Switch
checked={serverOnly}
onChange={(event) =>
handleServerOnlyChange(event.currentTarget.checked)
}
/>
</div>
</Stack>
</Card>
<Group gap="md" justify="center">

View file

@ -0,0 +1,121 @@
import { useState, useEffect, useRef } from 'react';
import {
Box,
Card,
Container,
Group,
ScrollArea,
Stack,
Text,
Title,
} from '@mantine/core';
export const TerminalScreen = () => {
const [terminalContent, setTerminalContent] = useState<string>('');
const scrollAreaRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (viewportRef.current) {
viewportRef.current.scrollTop = viewportRef.current.scrollHeight;
}
}, [terminalContent]);
useEffect(() => {
if (window.electronAPI?.kobold?.onKoboldOutput) {
const cleanup = window.electronAPI.kobold.onKoboldOutput(
(data: string) => {
setTerminalContent((prev) => {
// Handle carriage returns for progress bars
const lines = prev.split('\n');
const newData = data.toString();
// If the new data contains carriage returns, handle line overwriting
if (newData.includes('\r')) {
const parts = newData.split('\r');
for (let i = 0; i < parts.length; i++) {
if (i === 0) {
// First part gets appended to the last line
if (lines.length > 0) {
lines[lines.length - 1] += parts[i];
} else {
lines.push(parts[i]);
}
} else {
// Subsequent parts overwrite the last line
if (lines.length > 0) {
lines[lines.length - 1] = parts[i];
} else {
lines.push(parts[i]);
}
}
}
return lines.join('\n');
} else {
// No carriage returns, just append
return prev + newData;
}
});
}
);
return cleanup;
}
}, []);
return (
<Container size="lg" style={{ height: '100%', paddingTop: '2rem' }}>
<Stack gap="lg" style={{ height: '100%' }}>
<Group justify="space-between" align="center">
<Title order={3}>KoboldCpp Terminal</Title>
</Group>
<Card
withBorder
radius="md"
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
backgroundColor: 'var(--mantine-color-dark-8)',
minHeight: 0,
}}
>
<ScrollArea
ref={scrollAreaRef}
viewportRef={viewportRef}
style={{
flex: 1,
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace',
fontSize: '14px',
lineHeight: 1.4,
}}
>
<Box p="md">
{terminalContent.length === 0 ? (
<Text c="dimmed" style={{ fontFamily: 'inherit' }}>
Starting KoboldCpp...
</Text>
) : (
<Text
component="pre"
style={{
margin: 0,
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
color: 'var(--mantine-color-gray-0)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{terminalContent}
</Text>
)}
</Box>
</ScrollArea>
</Card>
</Stack>
</Container>
);
};

View file

@ -73,7 +73,8 @@ export interface KoboldAPI {
checkForUpdates: () => Promise<UpdateInfo | null>;
getLatestReleaseWithStatus: () => Promise<ReleaseWithStatus | null>;
launchKoboldCpp: (
args?: string[]
args?: string[],
configFilePath?: string
) => Promise<{ success: boolean; pid?: number; error?: string }>;
openInstallDialog: () => Promise<{ success: boolean; path?: string }>;
getConfigFiles: () => Promise<
@ -81,8 +82,19 @@ export interface KoboldAPI {
>;
getSelectedConfig: () => Promise<string | null>;
setSelectedConfig: (configName: string) => Promise<boolean>;
parseConfigFile: (filePath: string) => Promise<{
gpulayers?: number;
contextsize?: number;
model_param?: string;
[key: string]: unknown;
} | null>;
selectModelFile: () => Promise<string | null>;
stopKoboldCpp: () => void;
confirmEject: () => Promise<boolean>;
onDownloadProgress: (callback: (progress: number) => void) => void;
onUpdateAvailable: (callback: (updateInfo: UpdateInfo) => void) => void;
onInstallDirChanged: (callback: (newPath: string) => void) => () => void;
onKoboldOutput: (callback: (data: string) => void) => () => void;
removeAllListeners: (channel: string) => void;
}
@ -91,11 +103,21 @@ export interface AppAPI {
openExternal: (url: string) => Promise<void>;
}
export interface ConfigAPI {
getServerOnly: () => Promise<boolean>;
setServerOnly: (serverOnly: boolean) => Promise<void>;
getModelPath: () => Promise<string | null>;
setModelPath: (path: string) => Promise<void>;
get: (key: string) => Promise<unknown>;
set: (key: string, value: unknown) => Promise<void>;
}
declare global {
interface Window {
electronAPI: {
kobold: KoboldAPI;
app: AppAPI;
config: ConfigAPI;
};
}
}

50
src/types/index.ts Normal file
View file

@ -0,0 +1,50 @@
// Common interfaces used across multiple components
export interface ConfigFile {
name: string;
path: string;
size: number;
}
export interface GitHubAsset {
name: string;
browser_download_url: string;
size: number;
created_at: string;
}
export interface GitHubRelease {
tag_name: string;
name: string;
published_at: string;
body: string;
assets: GitHubAsset[];
}
export interface UpdateInfo {
currentVersion: string;
latestVersion: string;
releaseInfo: GitHubRelease;
hasUpdate: boolean;
}
export interface ReleaseWithStatus {
release: GitHubRelease;
availableAssets: Array<{
asset: GitHubAsset;
isDownloaded: boolean;
installedVersion?: string;
}>;
}
export interface InstalledVersion {
version: string;
path: string;
downloadDate: string;
filename: string;
}
export interface ROCmDownload {
name: string;
url: string;
size: number;
}

View file

@ -1,16 +1,18 @@
import { ASSET_SUFFIXES } from '@/constants/app';
export const getAssetDescription = (assetName: string): string => {
const name = assetName.toLowerCase();
if (name.includes('rocm')) {
if (name.includes(ASSET_SUFFIXES.ROCM)) {
return 'Optimized for AMD GPUs with ROCm support.';
}
if (name.endsWith('oldpc')) {
if (name.endsWith(ASSET_SUFFIXES.OLDPC)) {
return 'Meant for old PCs that cannot normally run the standard build.';
}
if (name.endsWith('nocuda')) {
return 'Standard build with NVIDIA CUDA removed for minimal file size.';
if (name.endsWith(ASSET_SUFFIXES.NOCUDA)) {
return 'Standard build with NVIDIA CUDA support removed for minimal file size.';
}
return "Standard build that's ideal for most cases.";
@ -22,15 +24,15 @@ export const isAssetRecommended = (
): boolean => {
const name = assetName.toLowerCase();
if (hasAMDGPU && name.includes('rocm')) {
if (hasAMDGPU && name.includes(ASSET_SUFFIXES.ROCM)) {
return true;
}
return (
!hasAMDGPU &&
!name.includes('rocm') &&
!name.endsWith('oldpc') &&
!name.endsWith('nocuda')
!name.includes(ASSET_SUFFIXES.ROCM) &&
!name.endsWith(ASSET_SUFFIXES.OLDPC) &&
!name.endsWith(ASSET_SUFFIXES.NOCUDA)
);
};

View file

@ -1,12 +0,0 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
important: '#root',
} satisfies Config;
export default config;

View file

@ -9,7 +9,11 @@
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"skipLibCheck": true
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/main/**/*", "src/preload/**/*"],
"exclude": ["node_modules", "dist", "dist-electron"]