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 GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 29 June 2007 Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies Everyone is permitted to copy and distribute verbatim copies
@ -7,17 +7,15 @@
Preamble Preamble
The GNU General Public License is a free, copyleft license for The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works. 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 The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast, 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 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 software for all its users.
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.
When we speak of free software, we are referring to freedom, not When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you 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 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. free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you Developers that use our General Public Licenses protect your rights
these rights or asking you to surrender the rights. Therefore, you have with two steps: (1) assert copyright on the software, and (2) offer
certain responsibilities if you distribute copies of the software, or if you this License which gives you legal permission to copy, distribute
you modify it: responsibilities to respect the freedom of others. and/or modify the software.
For example, if you distribute copies of such a program, whether A secondary benefit of defending all users' freedom is that
gratis or for a fee, you must pass on to the recipients the same improvements made in alternate versions of the program, if they
freedoms that you received. You must make sure that they, too, receive receive widespread use, become available for other developers to
or can get the source code. And you must show them these terms so they incorporate. Many developers of free software are heartened and
know their rights. 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: The GNU Affero General Public License is designed specifically to
(1) assert copyright on the software, and (2) offer you this License ensure that, in such cases, the modified source code becomes available
giving you legal permission to copy, distribute and/or modify it. 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 An older license, called the Affero General Public License and
that there is no warranty for this free software. For both users' and published by Affero, was designed to accomplish similar goals. This is
authors' sake, the GPL requires that modified versions be marked as a different license, not a version of the Affero GPL, but Affero has
changed, so that their problems will not be attributed erroneously to released a new version of the Affero GPL which permits relicensing under
authors of previous versions. this license.
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.
The precise terms and conditions for copying, distribution and The precise terms and conditions for copying, distribution and
modification follow. modification follow.
@ -72,7 +60,7 @@ modification follow.
0. Definitions. 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 "Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks. 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 the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program. 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 Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed 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 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, License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License, but the work with which it is combined will remain governed by version
section 13, concerning interaction through a network will apply to the 3 of the GNU General Public License.
combination as such.
14. Revised Versions of this License. 14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of 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 the GNU Affero General Public License from time to time. Such new versions
be similar in spirit to the present version, but may differ in detail to will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns. address new problems or concerns.
Each version is given a distinguishing version number. If the 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 Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the 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. by the Free Software Foundation.
If the Program specifies that a proxy can decide which future 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 public statement of acceptance of a version permanently authorizes you
to choose that version for the Program. 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> Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify 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 the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 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/>. 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. Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short If your software can interact with users remotely through a computer
notice like this when it starts in an interactive mode: 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
<program> Copyright (C) <year> <name of author> interface could display a "Source" link that leads users to an archive
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. of the code. There are many ways you could offer source, and different
This is free software, and you are welcome to redistribute it solutions will be better for different programs; see section 13 for the
under certain conditions; type `show c' for details. specific requirements.
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".
You should also get your employer (if you work as a programmer) or school, 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. 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/>. <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 ## 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", "version": "0.2",
"language": "en", "language": "en",
"words": [ "words": [
// Project specific terms "addEventListener",
"kobold", "admin",
"koboldcpp", "allowRunningInsecureContent",
"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",
"amdgpu", "amdgpu",
"wmic", "api",
"oldpc", "apis",
"nocuda", "appendChild",
"cooldown", "arg",
"togglefullscreen", "args",
"libvk", "asar",
"swiftshader", "async",
"icns", "auth",
"nsis", "await",
"babel",
"basename",
"bgcolor",
"browserWindow",
"bundler",
"bundling",
"can",
"classList",
"className",
"cloneNode",
"codeinterface", "codeinterface",
"tabler", "componentDidCatch",
"deps", "componentDidMount",
"devs", "componentDidUpdate",
"repo", "componentWillUnmount",
"repos",
"config", "config",
"configs", "configs",
"contextBridge",
"contextIsolation",
"contextsize",
"cooldown",
"couldn",
"createContext",
"createElement",
"createRef",
"css",
"cuda",
"dataset",
"deps",
"destructuring",
"devs",
"dirname",
"doesn",
"don",
"Egor",
"env",
"envs",
"eot",
"eslint",
"filename", "filename",
"filenames", "filenames",
"filepath", "filepath",
"filepaths", "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", "flexbox",
"flexdir", "flexdir",
"flexwrap", "flexwrap",
"gridcol",
"gridrow",
"bgcolor",
"textcolor",
"fontsize", "fontsize",
"fontweight", "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", "letterspacing",
"wordspacing", "libvk",
"textalign", "lineheight",
"textdecoration", "linted",
"texttransform", "localhost",
"whitespace", "mainWindow",
"wordbreak", "minified",
"wordwrap", "minify",
"moz",
"namespace",
"namespaces",
"newpackagename",
"nocuda",
"nodeIntegration",
"nsis",
"nvidia",
"oldpc",
"openblas",
"otf",
"overflow", "overflow",
"overflowx", "overflowx",
"overflowy", "overflowy",
"scrollbar", "param",
"webkit", "params",
"moz", "parcel",
// Build tools "pathname",
"sourcemap", "pathnames",
"sourcemaps", "Philippov",
"minify", "png",
"minified",
"bundler",
"bundling",
"treeshake",
"treeshaking",
"polyfill", "polyfill",
"polyfills", "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", "transpile",
"transpiled", "transpiled",
"transpiling", "transpiling",
"babel", "treemap",
"rollup", "treeshake",
"webpack", "treeshaking",
"parcel", "tsconfig",
// Common abbreviations "tsx",
"utils", "ttf",
"util", "typeof",
"impl", "typescript",
"impls",
"spec",
"specs",
"param",
"params",
"arg",
"args",
"env",
"envs",
"var",
"vars",
"tmp",
"temp",
"auth",
"admin",
"api",
"apis",
"url",
"urls",
"uri", "uri",
"uris", "uris",
"http", "url",
"https", "urls",
"localhost", "useCallback",
"hostname", "useContext",
"subdomain", "useEffect",
"namespace", "useMemo",
"namespaces", "useReducer",
// File extensions "useRef",
"json", "useState",
"yaml", "util",
"toml", "utils",
"xml", "var",
"html", "vars",
"css", "vite",
"scss", "vitejs",
"sass", "vitest",
"less", "webContents",
"svg", "webkit",
"png",
"jpg",
"jpeg",
"gif",
"webp", "webp",
"ico", "webpack",
"webSecurity",
"whitespace",
"wmic",
"woff", "woff",
"woff2", "woff2",
"ttf",
"otf",
"eot",
// Common contractions and informal words
"doesn",
"don",
"won", "won",
"can", "wordbreak",
"couldn", "wordspacing",
"shouldn", "wordwrap",
"wouldn", "wouldn",
// Documentation examples "xml",
"newpackagename" "yaml"
], ],
"ignorePaths": [ "ignorePaths": [
"node_modules/**", "node_modules/**",

View file

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

View file

@ -1,37 +1,35 @@
import js from '@eslint/js'; import js from '@eslint/js';
import globals from 'globals'; 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 reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh'; import reactRefresh from 'eslint-plugin-react-refresh';
import react from 'eslint-plugin-react'; import react from 'eslint-plugin-react';
import importPlugin from 'eslint-plugin-import'; 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 sonarjs from 'eslint-plugin-sonarjs';
import security from 'eslint-plugin-security';
import cspell from '@cspell/eslint-plugin'; 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'], 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: { languageOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
globals: {
...globals.browser,
...globals.node,
},
parser: tsParser, parser: tsParser,
parserOptions: { parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: { ecmaFeatures: {
jsx: true, jsx: true,
}, },
}, },
globals: {
...globals.browser,
...globals.node,
},
}, },
plugins: { plugins: {
'@typescript-eslint': tseslint, '@typescript-eslint': tseslint,
@ -39,18 +37,36 @@ const config: Linter.Config[] = [
'react-refresh': reactRefresh, 'react-refresh': reactRefresh,
react: react, react: react,
import: importPlugin, import: importPlugin,
sonarjs: sonarjs,
'@cspell': cspell, '@cspell': cspell,
}, },
settings: {
react: {
version: 'detect',
},
},
rules: { rules: {
// Use recommended rules from plugins // Use recommended rules from plugins
...tseslint.configs.recommended.rules,
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
...react.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': [ 'react-refresh/only-export-components': [
'warn', 'warn',
{ allowConstantExport: true }, { allowConstantExport: true },
], ],
// Override only the specific modern React patterns we want to enforce
'react/function-component-definition': [ 'react/function-component-definition': [
'error', 'error',
{ {
@ -59,6 +75,8 @@ const config: Linter.Config[] = [
}, },
], ],
'react/react-in-jsx-scope': 'off', // Not needed with new JSX transform 'react/react-in-jsx-scope': 'off', // Not needed with new JSX transform
// No default React imports - force specific imports
'no-restricted-imports': [ 'no-restricted-imports': [
'error', '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/no-default-export': 'error',
'import/prefer-default-export': 'off', 'import/prefer-default-export': 'off',
// Enforce arrow function shorthand when possible // Enforce arrow function shorthand when possible
'arrow-body-style': ['error', 'as-needed'], 'arrow-body-style': ['error', 'as-needed'],
'prefer-arrow-callback': ['error', { allowNamedFunctions: false }], 'prefer-arrow-callback': ['error', { allowNamedFunctions: false }],
// Disallow console.log usage
// Forbid console.log usage
'no-console': ['error', { allow: ['warn', 'error'] }], 'no-console': ['error', { allow: ['warn', 'error'] }],
// Warn about unnecessary explicit type annotations
// TypeScript rules
'@typescript-eslint/no-inferrable-types': 'warn', '@typescript-eslint/no-inferrable-types': 'warn',
// Don't require explicit return types (prefer inference)
'@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-function-return-type': 'off',
// Disable some overly strict security rules for Electron apps
'security/detect-non-literal-fs-filename': 'off', // SonarJS rules - keep cognitive complexity reasonable
'security/detect-object-injection': 'off',
// Relax cognitive complexity for complex business logic
'sonarjs/cognitive-complexity': ['warn', 25], 'sonarjs/cognitive-complexity': ['warn', 25],
// Spell checking for code // Spell checking for code
'@cspell/spellchecker': ['warn'], '@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 // Allow default exports for config files
files: [ files: [
'*.config.*', '*.config.*',
'vite.config.*', 'vite.config.*',
'tailwind.config.*',
'eslint.config.*', 'eslint.config.*',
'postcss.config.*', 'postcss.config.*',
], ],
@ -106,36 +136,6 @@ const config: Linter.Config[] = [
'import/no-default-export': 'off', '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; 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", "format": "prettier --write . --ignore-path .gitignore",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"lint:check": "eslint . --max-warnings 0", "compile": "tsc --noEmit",
"type-check": "tsc --noEmit",
"spell-check": "cspell \"**/*.{ts,tsx,js,jsx,md,json}\" --no-progress", "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", "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" "prepare": "husky"
}, },
"lint-staged": { "lint-staged": {
@ -44,12 +43,11 @@
"ai", "ai",
"llm" "llm"
], ],
"author": "", "author": "Egor Philippov",
"license": "GPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"devDependencies": { "devDependencies": {
"@cspell/eslint-plugin": "^9.2.0", "@cspell/eslint-plugin": "^9.2.0",
"@eslint/js": "^9.33.0", "@eslint/js": "^9.33.0",
"@tailwindcss/postcss": "^4.1.11",
"@types/node": "^24.2.1", "@types/node": "^24.2.1",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
@ -66,14 +64,13 @@
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"eslint-plugin-security": "^3.0.1",
"eslint-plugin-sonarjs": "^3.0.4", "eslint-plugin-sonarjs": "^3.0.4",
"globals": "^16.3.0", "globals": "^16.3.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jiti": "^2.5.1",
"lint-staged": "^16.1.5", "lint-staged": "^16.1.5",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"rollup-plugin-visualizer": "^6.0.3", "rollup-plugin-visualizer": "^6.0.3",
"tailwindcss": "^4.1.11",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vite": "^7.1.2", "vite": "^7.1.2",
"wait-on": "^8.0.4" "wait-on": "^8.0.4"
@ -82,23 +79,41 @@
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@mantine/core": "^8.2.4", "@mantine/core": "^8.2.4",
"@mantine/hooks": "^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": "^19.1.1",
"react-dom": "^19.1.1" "react-dom": "^19.1.1"
}, },
"build": { "build": {
"appId": "com.friendlykobold.app", "appId": "com.friendlykobold.app",
"productName": "FriendlyKobold", "productName": "FriendlyKobold",
"compression": "maximum",
"directories": { "directories": {
"output": "release" "output": "release"
}, },
"files": [ "files": [
"dist/**/*", "out/**/*",
"dist-electron/**/*", "!**/node_modules/**/*",
"node_modules/**/*" "!out/renderer/node_modules/**/*",
"!**/*.map",
"!**/*.ts",
"!**/*.tsx",
"!**/test/**/*",
"!**/tests/**/*",
"!**/__tests__/**/*",
"!**/coverage/**/*",
"!**/.nyc_output/**/*",
"package.json"
],
"asarUnpack": [
"!**/node_modules/**/*"
], ],
"mac": { "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": [
{ {
"target": "dmg", "target": "dmg",
@ -110,7 +125,6 @@
] ]
}, },
"win": { "win": {
"icon": "assets/icon.ico",
"target": [ "target": [
{ {
"target": "nsis", "target": "nsis",
@ -121,7 +135,6 @@
] ]
}, },
"linux": { "linux": {
"icon": "assets/icon.png",
"target": [ "target": [
{ {
"target": "AppImage", "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 { useState, useEffect, ReactNode } from 'react';
import { AppShell, Group, ActionIcon, Tooltip, rem } from '@mantine/core'; import {
import { IconSettings } from '@tabler/icons-react'; 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 { DownloadScreen } from '@/screens/DownloadScreen';
import { LaunchScreen } from '@/screens/LaunchScreen'; import { LaunchScreen } from '@/screens/LaunchScreen';
import { TerminalScreen } from '@/screens/TerminalScreen';
import { UpdateDialog } from '@/components/UpdateDialog'; import { UpdateDialog } from '@/components/UpdateDialog';
import { SettingsModal } from '@/components/SettingsModal'; 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 = () => { export const App = () => {
const [currentScreen, setCurrentScreen] = useState<Screen>('download'); const [currentScreen, setCurrentScreen] = useState<Screen | null>(null);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null); const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [showUpdateDialog, setShowUpdateDialog] = useState(false); const [showUpdateDialog, setShowUpdateDialog] = useState(false);
const [settingsOpened, setSettingsOpened] = useState(false); const [settingsOpened, setSettingsOpened] = useState(false);
const [hasInitialized, setHasInitialized] = useState(false);
const { colorScheme } = useMantineColorScheme();
const notify = useNotifications();
useEffect(() => { useEffect(() => {
const checkInstallation = async () => { const checkInstallation = async () => {
@ -21,9 +84,13 @@ export const App = () => {
try { try {
const installed = await window.electronAPI.kobold.isInstalled(); const installed = await window.electronAPI.kobold.isInstalled();
setCurrentScreen(installed ? 'launch' : 'download'); setCurrentScreen(installed ? 'launch' : 'download');
setHasInitialized(true);
} catch (error) { } catch (error) {
console.error('Error checking installation:', error); console.error('Error checking installation:', error);
setHasInitialized(true);
} }
} else {
setHasInitialized(true);
} }
}; };
@ -34,19 +101,62 @@ export const App = () => {
setUpdateInfo(info); setUpdateInfo(info);
setShowUpdateDialog(true); setShowUpdateDialog(true);
}); });
}
return () => { const cleanupInstallDirListener =
if (window.electronAPI) { window.electronAPI.kobold.onInstallDirChanged(() => {
checkInstallation();
});
return () => {
window.electronAPI.kobold.removeAllListeners('update-available'); 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'); 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 = () => { const handleUpdateIgnore = () => {
setShowUpdateDialog(false); setShowUpdateDialog(false);
}; };
@ -57,41 +167,108 @@ export const App = () => {
}; };
return ( return (
<AppShell header={{ height: 60 }} padding="md"> <>
<AppShell.Header> <Notifications
<Group h="100%" px="md" justify="flex-end"> position="bottom-right"
<Tooltip label="Settings" position="bottom"> zIndex={1000}
<ActionIcon containerWidth={320}
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)}
/> />
</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 {
import { IconDownload } from '@tabler/icons-react'; Card,
Stack,
Group,
Text,
Badge,
Button,
Loader,
Progress,
} from '@mantine/core';
import { Download } from 'lucide-react';
import { MouseEvent } from 'react'; import { MouseEvent } from 'react';
interface DownloadOptionCardProps { interface DownloadOptionCardProps {
@ -9,6 +18,7 @@ interface DownloadOptionCardProps {
isSelected: boolean; isSelected: boolean;
isRecommended: boolean; isRecommended: boolean;
isDownloading: boolean; isDownloading: boolean;
downloadProgress?: number;
onClick: () => void; onClick: () => void;
onDownload: (e: MouseEvent<HTMLButtonElement>) => void; onDownload: (e: MouseEvent<HTMLButtonElement>) => void;
} }
@ -20,6 +30,7 @@ export const DownloadOptionCard = ({
isSelected, isSelected,
isRecommended, isRecommended,
isDownloading, isDownloading,
downloadProgress = 0,
onClick, onClick,
onDownload, onDownload,
}: DownloadOptionCardProps) => ( }: DownloadOptionCardProps) => (
@ -52,16 +63,12 @@ export const DownloadOptionCard = ({
</Text> </Text>
{isSelected && ( {isSelected && (
<Group justify="center" pt="sm"> <Stack gap="sm" pt="sm">
<Button <Button
onClick={onDownload} onClick={onDownload}
disabled={isDownloading} disabled={isDownloading}
leftSection={ leftSection={
isDownloading ? ( isDownloading ? <Loader size="1rem" /> : <Download size="1rem" />
<Loader size="1rem" />
) : (
<IconDownload size="1rem" />
)
} }
size="sm" size="sm"
radius="md" radius="md"
@ -69,7 +76,16 @@ export const DownloadOptionCard = ({
> >
{isDownloading ? 'Downloading...' : 'Download'} {isDownloading ? 'Downloading...' : 'Download'}
</Button> </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> </Stack>
</Card> </Card>

View file

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

View file

@ -1,19 +1,5 @@
import { Modal, Text, Button, Group, Stack } from '@mantine/core'; import { Modal, Text, Button, Group, Stack } from '@mantine/core';
import type { GitHubRelease } from '@/types';
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;
}
interface UpdateDialogProps { interface UpdateDialogProps {
updateInfo: { 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'; } from 'react';
import { MantineColorScheme } from '@mantine/core'; import { MantineColorScheme } from '@mantine/core';
type ThemeMode = 'light' | 'dark' | 'system'; export type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeContextType { interface ThemeContextType {
themeMode: ThemeMode; 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'; @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 { existsSync, mkdirSync } from 'fs';
import { homedir } from 'os'; import { homedir } from 'os';
import { WindowManager } from './managers/WindowManager'; import { WindowManager } from '@/main/managers/WindowManager';
import { ConfigManager } from './managers/ConfigManager'; import { ConfigManager } from '@/main/managers/ConfigManager';
import { KoboldCppManager } from './managers/KoboldCppManager'; import { KoboldCppManager } from '@/main/managers/KoboldCppManager';
import { GitHubService } from './services/GitHubService'; import { GitHubService } from '@/main/services/GitHubService';
import { GPUService } from './services/GPUService'; import { GPUService } from '@/main/services/GPUService';
import { IPCHandlers } from './utils/IPCHandlers'; import { IPCHandlers } from '@/main/utils/IPCHandlers';
import { APP_NAME, CONFIG_FILE_NAME } from '@/constants/app';
class FriendlyKoboldApp { class FriendlyKoboldApp {
private windowManager: WindowManager; private windowManager: WindowManager;
@ -25,7 +26,8 @@ class FriendlyKoboldApp {
this.gpuService = new GPUService(); this.gpuService = new GPUService();
this.koboldManager = new KoboldCppManager( this.koboldManager = new KoboldCppManager(
this.configManager, this.configManager,
this.githubService this.githubService,
this.windowManager
); );
this.ipcHandlers = new IPCHandlers( this.ipcHandlers = new IPCHandlers(
this.koboldManager, this.koboldManager,
@ -38,7 +40,7 @@ class FriendlyKoboldApp {
} }
private getConfigPath() { private getConfigPath() {
return join(app.getPath('userData'), 'config.json'); return join(app.getPath('userData'), CONFIG_FILE_NAME);
} }
private getDefaultInstallPath() { private getDefaultInstallPath() {
@ -47,11 +49,11 @@ class FriendlyKoboldApp {
switch (platform) { switch (platform) {
case 'win32': case 'win32':
return join(home, 'FriendlyKobold'); return join(home, APP_NAME);
case 'darwin': case 'darwin':
return join(home, 'Applications', 'FriendlyKobold'); return join(home, 'Applications', APP_NAME);
default: default:
return join(home, '.local', 'share', 'friendly-kobold'); return join(home, '.local', 'share', APP_NAME);
} }
} }
@ -76,11 +78,25 @@ class FriendlyKoboldApp {
this.ipcHandlers.setupHandlers(); this.ipcHandlers.setupHandlers();
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (process.platform !== 'darwin') { if (process.platform === 'darwin') {
app.quit(); 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', () => { app.on('activate', () => {
if (!this.windowManager.getMainWindow()) { if (!this.windowManager.getMainWindow()) {
this.windowManager.createMainWindow(); this.windowManager.createMainWindow();

View file

@ -4,8 +4,10 @@ type ConfigValue = string | number | boolean | unknown[] | undefined;
interface AppConfig { interface AppConfig {
installDir?: string; installDir?: string;
currentVersion?: string; currentKoboldBinary?: string;
selectedConfig?: string; selectedConfig?: string;
serverOnly?: boolean;
modelPath?: string;
[key: string]: ConfigValue; [key: string]: ConfigValue;
} }
@ -55,12 +57,12 @@ export class ConfigManager {
this.saveConfig(); this.saveConfig();
} }
getCurrentVersion(): string | undefined { getCurrentKoboldBinary(): string | undefined {
return this.config.currentVersion; return this.config.currentKoboldBinary as string | undefined;
} }
setCurrentVersion(version: string) { setCurrentKoboldBinary(binaryPath: string) {
this.config.currentVersion = version; this.config.currentKoboldBinary = binaryPath;
this.saveConfig(); this.saveConfig();
} }
@ -72,4 +74,22 @@ export class ConfigManager {
this.config.selectedConfig = configName; this.config.selectedConfig = configName;
this.saveConfig(); 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 { spawn, ChildProcess } from 'child_process';
import { join } from 'path'; 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 { dialog } from 'electron';
import { GitHubService } from '../services/GitHubService'; import { GitHubService } from '@/main/services/GitHubService';
import { ConfigManager } from './ConfigManager'; import { ConfigManager } from '@/main/managers/ConfigManager';
import { WindowManager } from '@/main/managers/WindowManager';
import { APP_NAME, DIALOG_TITLES, ROCM } from '@/constants/app';
interface GitHubAsset { interface GitHubAsset {
name: string; name: string;
@ -39,7 +48,6 @@ interface ReleaseWithStatus {
export interface InstalledVersion { export interface InstalledVersion {
version: string; version: string;
path: string; path: string;
type: 'github' | 'rocm';
downloadDate: string; downloadDate: string;
filename: string; filename: string;
} }
@ -49,13 +57,19 @@ export class KoboldCppManager {
private koboldProcess: ChildProcess | null = null; private koboldProcess: ChildProcess | null = null;
private configManager: ConfigManager; private configManager: ConfigManager;
private githubService: GitHubService; private githubService: GitHubService;
private windowManager: WindowManager;
constructor(configManager: ConfigManager, githubService: GitHubService) { constructor(
configManager: ConfigManager,
githubService: GitHubService,
windowManager: WindowManager
) {
this.configManager = configManager; this.configManager = configManager;
this.githubService = githubService; this.githubService = githubService;
this.windowManager = windowManager;
this.installDir = this.installDir =
this.configManager.getInstallDir() || this.configManager.getInstallDir() ||
join(process.env.HOME || process.env.USERPROFILE || '.', 'KoboldCpp'); join(process.env.HOME || process.env.USERPROFILE || '.', APP_NAME);
} }
async downloadRelease( async downloadRelease(
@ -105,14 +119,23 @@ export class KoboldCppManager {
reader.releaseLock(); 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; return filePath;
} }
async getInstalledVersions(): Promise<InstalledVersion[]> { async getInstalledVersions(): Promise<InstalledVersion[]> {
const configData = this.configManager.get('installedVersions');
const configVersions: InstalledVersion[] = Array.isArray(configData)
? (configData as unknown as InstalledVersion[])
: [];
const scannedVersions: InstalledVersion[] = []; const scannedVersions: InstalledVersion[] = [];
try { try {
@ -126,48 +149,26 @@ export class KoboldCppManager {
statSync(filePath).isFile() && statSync(filePath).isFile() &&
(file.includes('koboldcpp') || file.includes('kobold')) (file.includes('koboldcpp') || file.includes('kobold'))
) { ) {
const existingVersion = configVersions.find( try {
(v: InstalledVersion) => v.path === filePath const detectedVersion = await this.getVersionFromBinary(filePath);
); const version = detectedVersion || 'unknown';
if (existingVersion) { const newVersion: InstalledVersion = {
scannedVersions.push(existingVersion); version,
} else { path: filePath,
try { downloadDate: new Date().toISOString(),
const detectedVersion = filename: file,
await this.getVersionFromBinary(filePath); };
const version = detectedVersion || 'unknown';
const newVersion: InstalledVersion = { scannedVersions.push(newVersion);
version, } catch (error) {
path: filePath, console.warn(`Could not detect version for ${file}:`, error);
type: 'github',
downloadDate: new Date().toISOString(),
filename: file,
};
scannedVersions.push(newVersion);
} catch (error) {
console.warn(`Could not detect version for ${file}:`, error);
}
} }
} }
} }
} }
} catch (error) { } catch (error) {
console.warn('Error scanning install directory:', 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; return scannedVersions;
@ -210,60 +211,87 @@ export class KoboldCppManager {
return configFiles.sort((a, b) => a.name.localeCompare(b.name)); 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> { async getCurrentVersion(): Promise<InstalledVersion | null> {
const versions = await this.getInstalledVersions(); const versions = await this.getInstalledVersions();
const currentVersionString = this.configManager.getCurrentVersion(); const currentBinaryPath = this.configManager.getCurrentKoboldBinary();
if (currentVersionString) { if (currentBinaryPath) {
const found = const found = versions.find((v) => v.path === currentBinaryPath);
versions.find((v) => v.version === currentVersionString) || null; if (found && existsSync(found.path)) {
return found; 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( versions.sort(
(a, b) => (a, b) =>
new Date(b.downloadDate).getTime() - new Date(b.downloadDate).getTime() -
new Date(a.downloadDate).getTime() new Date(a.downloadDate).getTime()
)[0] || null; )[0] || null
return fallback; );
} }
async setCurrentVersion(version: string): Promise<boolean> { async setCurrentVersion(version: string): Promise<boolean> {
const versions = await this.getInstalledVersions(); const versions = await this.getInstalledVersions();
const targetVersion = versions.find((v) => v.version === version); const targetVersion = versions.find((v) => v.version === version);
if (!targetVersion || !existsSync(targetVersion.path)) { if (targetVersion && existsSync(targetVersion.path)) {
if (targetVersion) { this.configManager.setCurrentKoboldBinary(targetVersion.path);
const updatedVersions = versions.filter((v) => v.version !== version); return true;
this.configManager.set(
'installedVersions',
updatedVersions as unknown[]
);
}
return false;
} }
this.configManager.setCurrentVersion(version); return false;
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;
} }
async getVersionFromBinary(binaryPath: string): Promise<string | null> { async getVersionFromBinary(binaryPath: string): Promise<string | null> {
@ -338,48 +366,33 @@ export class KoboldCppManager {
return this.installDir; return this.installDir;
} }
getWindowManager() {
return this.windowManager;
}
async selectInstallDirectory(): Promise<string | null> { async selectInstallDirectory(): Promise<string | null> {
const result = await dialog.showOpenDialog({ const result = await dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory'], properties: ['openDirectory', 'createDirectory'],
title: 'Select KoboldCpp Installation Directory', title: DIALOG_TITLES.SELECT_INSTALL_DIR,
defaultPath: this.installDir, defaultPath: this.installDir,
buttonLabel: 'Select Directory',
}); });
if (!result.canceled && result.filePaths.length > 0) { if (!result.canceled && result.filePaths.length > 0) {
const newPath = join(result.filePaths[0], 'KoboldCpp'); this.installDir = result.filePaths[0];
this.installDir = newPath; this.configManager.setInstallDir(result.filePaths[0]);
this.configManager.setInstallDir(newPath);
return newPath; const mainWindow = this.windowManager.getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('install-dir-changed', result.filePaths[0]);
}
return result.filePaths[0];
} }
return null; 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( async launchKobold(
versionPath: string, versionPath: string,
args: string[] = [], args: string[] = [],
@ -439,7 +452,6 @@ export class KoboldCppManager {
name: string; name: string;
url: string; url: string;
size: number; size: number;
type: 'rocm';
version?: string; version?: string;
} | null> { } | null> {
const platform = process.platform; const platform = process.platform;
@ -451,15 +463,14 @@ export class KoboldCppManager {
const version = latestRelease?.tag_name?.replace(/^v/, '') || 'unknown'; const version = latestRelease?.tag_name?.replace(/^v/, '') || 'unknown';
return { return {
name: 'koboldcpp-linux-x64-rocm', name: ROCM.BINARY_NAME,
url: 'https://koboldai.org/cpplinuxrocm', url: ROCM.DOWNLOAD_URL,
size: 1024 * 1024 * 1024, size: ROCM.SIZE_BYTES,
type: 'rocm',
version, version,
}; };
} }
async downloadROCm(): Promise<{ async downloadROCm(onProgress?: (progress: number) => void): Promise<{
success: boolean; success: boolean;
path?: string; path?: string;
error?: string; error?: string;
@ -469,11 +480,11 @@ export class KoboldCppManager {
if (platform !== 'linux') { if (platform !== 'linux') {
return { return {
success: false, 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) { if (!response.ok) {
return { return {
success: false, 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); const writer = createWriteStream(filePath);
response.body?.pipeTo( try {
new WritableStream({ while (true) {
write(chunk) { const { done, value } = await reader.read();
writer.write(chunk);
},
close() {
writer.end();
},
})
);
await new Promise<void>((resolve, reject) => { if (done) break;
writer.on('finish', resolve);
writer.on('error', reject); 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 { return {
success: true, 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( async launchKoboldCpp(
args: string[] = [] args: string[] = [],
configFilePath?: string
): Promise<{ success: boolean; pid?: number; error?: string }> { ): Promise<{ success: boolean; pid?: number; error?: string }> {
try { try {
if (this.koboldProcess) {
this.stopKoboldCpp();
}
const currentVersion = await this.getCurrentVersion(); const currentVersion = await this.getCurrentVersion();
if (!currentVersion || !existsSync(currentVersion.path)) { if (!currentVersion || !existsSync(currentVersion.path)) {
return { return {
success: false, success: false,
error: 'KoboldCpp not found or no version selected', error: 'KoboldCpp not found',
}; };
} }
const child = spawn(currentVersion.path, args, { const finalArgs = [...args]; // Start with the provided arguments
detached: true,
stdio: 'ignore', 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); this.koboldProcess = child;
child.unref();
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 }; return { success: true, pid: child.pid };
} catch (error) { } catch (error) {
return { success: false, error: (error as Error).message }; 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'; import { join } from 'path';
export class WindowManager { export class WindowManager {
private mainWindow: BrowserWindow | null = null; private mainWindow: BrowserWindow | null = null;
private tray: Tray | null = null;
private isQuitting = false;
createMainWindow(): BrowserWindow { createMainWindow(): BrowserWindow {
this.mainWindow = new BrowserWindow({ this.mainWindow = new BrowserWindow({
@ -28,6 +30,14 @@ export class WindowManager {
this.mainWindow = null; this.mainWindow = null;
}); });
this.mainWindow.on('close', (event) => {
if (this.tray && !this.isQuitting) {
event.preventDefault();
this.mainWindow?.hide();
}
});
this.createSystemTray();
this.setupContextMenu(); this.setupContextMenu();
return this.mainWindow; return this.mainWindow;
} }
@ -36,10 +46,60 @@ export class WindowManager {
return this.mainWindow; 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() { private setupContextMenu() {
if (!this.mainWindow) return; if (!this.mainWindow) return;
this.mainWindow.webContents.on('context-menu', (_event, params) => { this.mainWindow.webContents.on('context-menu', (_event, params) => {
const hasLinkURL = !!params.linkURL;
const menu = Menu.buildFromTemplate([ const menu = Menu.buildFromTemplate([
{ {
label: 'Inspect Element', label: 'Inspect Element',
@ -53,10 +113,10 @@ export class WindowManager {
{ label: 'Paste', role: 'paste' }, { label: 'Paste', role: 'paste' },
{ type: 'separator' }, { type: 'separator' },
{ label: 'Select All', role: 'selectAll' }, { label: 'Select All', role: 'selectAll' },
{ type: 'separator' }, ...(hasLinkURL ? [{ type: 'separator' as const }] : []),
{ {
label: 'Open Link in Browser', label: 'Open Link in Browser',
visible: !!params.linkURL, visible: hasLinkURL,
click: () => { click: () => {
if (params.linkURL) { if (params.linkURL) {
shell.openExternal(params.linkURL); shell.openExternal(params.linkURL);
@ -78,6 +138,7 @@ export class WindowManager {
label: 'Quit', label: 'Quit',
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q', accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
click: () => { click: () => {
this.isQuitting = true;
app.quit(); 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 { export class GitHubService {
private lastApiCall = 0; private lastApiCall = 0;
@ -13,16 +14,14 @@ export class GitHubService {
} }
try { try {
const response = await fetch( const response = await fetch(GITHUB_API.LATEST_RELEASE_URL);
'https://api.github.com/repos/LostRuins/koboldcpp/releases/latest'
);
if (!response.ok) { if (!response.ok) {
if (response.status === 403) { if (response.status === 403) {
console.warn( 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}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
@ -32,7 +31,7 @@ export class GitHubService {
return this.cachedRelease; return this.cachedRelease;
} catch (error) { } catch (error) {
console.error('Error fetching latest release:', error); console.error('Error fetching latest release:', error);
return this.cachedRelease || this.getFallbackRelease(); return this.cachedRelease;
} }
} }
@ -47,16 +46,14 @@ export class GitHubService {
} }
try { try {
const response = await fetch( const response = await fetch(GITHUB_API.ALL_RELEASES_URL);
'https://api.github.com/repos/LostRuins/koboldcpp/releases'
);
if (!response.ok) { if (!response.ok) {
if (response.status === 403) { if (response.status === 403) {
console.warn('GitHub API rate limit reached, using cached data'); console.warn(
return this.cachedReleases.length > 0 'GitHub API rate limit reached, using cached data if available'
? this.cachedReleases );
: [this.getFallbackRelease()]; return this.cachedReleases;
} }
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
@ -66,32 +63,7 @@ export class GitHubService {
return this.cachedReleases; return this.cachedReleases;
} catch (error) { } catch (error) {
console.error('Error fetching releases:', error); console.error('Error fetching releases:', error);
return this.cachedReleases.length > 0 return this.cachedReleases;
? this.cachedReleases
: [this.getFallbackRelease()];
} }
} }
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 { shell, app } from 'electron';
import { KoboldCppManager } from '../managers/KoboldCppManager'; import { KoboldCppManager } from '@/main/managers/KoboldCppManager';
import { ConfigManager } from '../managers/ConfigManager'; import { ConfigManager } from '@/main/managers/ConfigManager';
import { GitHubService } from '../services/GitHubService'; import { GitHubService } from '@/main/services/GitHubService';
import { GPUService } from '../services/GPUService'; import { GPUService } from '@/main/services/GPUService';
export class IPCHandlers { export class IPCHandlers {
private koboldManager: KoboldCppManager; private koboldManager: KoboldCppManager;
@ -32,11 +32,43 @@ export class IPCHandlers {
this.githubService.getAllReleases() this.githubService.getAllReleases()
); );
ipcMain.handle( ipcMain.handle('kobold:checkForUpdates', async () => {
'kobold:downloadRelease', const latest = await this.githubService.getLatestRelease();
async (_event, asset, onProgress) => return latest;
this.koboldManager.downloadRelease(asset, onProgress) });
);
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', () => ipcMain.handle('kobold:getInstalledVersions', () =>
this.koboldManager.getInstalledVersions() this.koboldManager.getInstalledVersions()
@ -74,12 +106,6 @@ export class IPCHandlers {
this.koboldManager.selectInstallDirectory() 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:detectGPU', () => this.gpuService.detectGPU());
ipcMain.handle('kobold:getPlatform', () => ({ ipcMain.handle('kobold:getPlatform', () => ({
@ -93,20 +119,20 @@ export class IPCHandlers {
ipcMain.handle('kobold:downloadROCm', async () => { ipcMain.handle('kobold:downloadROCm', async () => {
try { 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) { } catch (error) {
return { success: false, error: (error as Error).message }; 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', () => ipcMain.handle('kobold:getInstalledVersion', () =>
this.koboldManager.getInstalledVersion() this.koboldManager.getInstalledVersion()
); );
@ -115,20 +141,58 @@ export class IPCHandlers {
this.koboldManager.getVersionFromBinary(binaryPath) this.koboldManager.getVersionFromBinary(binaryPath)
); );
ipcMain.handle('kobold:checkForUpdates', () =>
this.koboldManager.checkForUpdates()
);
ipcMain.handle('kobold:getLatestReleaseWithStatus', () => ipcMain.handle('kobold:getLatestReleaseWithStatus', () =>
this.koboldManager.getLatestReleaseWithDownloadStatus() this.koboldManager.getLatestReleaseWithDownloadStatus()
); );
ipcMain.handle('kobold:launchKoboldCpp', (_event, args) => ipcMain.handle('kobold:launchKoboldCpp', (_event, args, configFilePath) =>
this.koboldManager.launchKoboldCpp(args) this.koboldManager.launchKoboldCpp(args, configFilePath)
); );
ipcMain.handle('kobold:openInstallDialog', () => ipcMain.handle('kobold:stopKoboldCpp', () =>
this.koboldManager.openInstallDialog() 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)); ipcMain.handle('config:get', (_event, key) => this.configManager.get(key));

View file

@ -1,5 +1,10 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'; 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 = { const koboldAPI: KoboldAPI = {
isInstalled: () => ipcRenderer.invoke('kobold:isInstalled'), isInstalled: () => ipcRenderer.invoke('kobold:isInstalled'),
@ -10,7 +15,6 @@ const koboldAPI: KoboldAPI = {
ipcRenderer.invoke('kobold:setCurrentVersion', version), ipcRenderer.invoke('kobold:setCurrentVersion', version),
getVersionFromBinary: (binaryPath: string) => getVersionFromBinary: (binaryPath: string) =>
ipcRenderer.invoke('kobold:getVersionFromBinary', binaryPath), ipcRenderer.invoke('kobold:getVersionFromBinary', binaryPath),
checkForUpdates: () => ipcRenderer.invoke('kobold:checkForUpdates'),
getLatestReleaseWithStatus: () => getLatestReleaseWithStatus: () =>
ipcRenderer.invoke('kobold:getLatestReleaseWithStatus'), ipcRenderer.invoke('kobold:getLatestReleaseWithStatus'),
getROCmDownload: () => ipcRenderer.invoke('kobold:getROCmDownload'), getROCmDownload: () => ipcRenderer.invoke('kobold:getROCmDownload'),
@ -24,12 +28,19 @@ const koboldAPI: KoboldAPI = {
ipcRenderer.invoke('kobold:selectInstallDirectory'), ipcRenderer.invoke('kobold:selectInstallDirectory'),
downloadRelease: (asset) => downloadRelease: (asset) =>
ipcRenderer.invoke('kobold: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'), openInstallDialog: () => ipcRenderer.invoke('kobold:openInstallDialog'),
checkForUpdates: () => ipcRenderer.invoke('kobold:checkForUpdates'),
getConfigFiles: () => ipcRenderer.invoke('kobold:getConfigFiles'), getConfigFiles: () => ipcRenderer.invoke('kobold:getConfigFiles'),
getSelectedConfig: () => ipcRenderer.invoke('kobold:getSelectedConfig'), getSelectedConfig: () => ipcRenderer.invoke('kobold:getSelectedConfig'),
setSelectedConfig: (configName: string) => setSelectedConfig: (configName: string) =>
ipcRenderer.invoke('kobold:setSelectedConfig', configName), 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) => { onDownloadProgress: (callback) => {
ipcRenderer.on( ipcRenderer.on(
'download-progress', 'download-progress',
@ -42,6 +53,22 @@ const koboldAPI: KoboldAPI = {
(_: IpcRendererEvent, updateInfo: UpdateInfo) => callback(updateInfo) (_: 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) => { removeAllListeners: (channel) => {
ipcRenderer.removeAllListeners(channel); ipcRenderer.removeAllListeners(channel);
}, },
@ -52,7 +79,20 @@ const appAPI: AppAPI = {
openExternal: (url) => ipcRenderer.invoke('app:openExternal', url), 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', { contextBridge.exposeInMainWorld('electronAPI', {
kobold: koboldAPI, kobold: koboldAPI,
app: appAPI, app: appAPI,
config: configAPI,
}); });

View file

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

View file

@ -7,31 +7,57 @@ import {
Stack, Stack,
Group, Group,
ActionIcon, ActionIcon,
Select, Switch,
Slider,
TextInput,
NumberInput,
Tooltip,
Checkbox,
} from '@mantine/core'; } from '@mantine/core';
import { IconFile, IconRefresh } from '@tabler/icons-react'; import { RotateCcw, File, Info } from 'lucide-react';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { ConfigFileSelect } from '@/components/ConfigFileSelect';
import { useLaunchConfig } from '@/hooks/useLaunchConfig';
import type { ConfigFile } from '@/types';
interface ConfigFile { interface LaunchScreenProps {
name: string; onLaunch: () => void;
path: string;
size: number;
} }
export const LaunchScreen = () => { export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
const [configFiles, setConfigFiles] = useState<ConfigFile[]>([]); const [configFiles, setConfigFiles] = useState<ConfigFile[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedFile, setSelectedFile] = useState<string | null>(null); const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [, setInstallDir] = useState<string>(''); 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 () => { const loadConfigFiles = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
const [files, currentDir, savedConfig] = await Promise.all([ const [files, currentDir, savedConfig] = await Promise.all([
window.electronAPI.kobold.getConfigFiles(), window.electronAPI.kobold.getConfigFiles(),
window.electronAPI.kobold.getCurrentInstallDir(), window.electronAPI.kobold.getCurrentInstallDir(),
window.electronAPI.kobold.getSelectedConfig(), window.electronAPI.kobold.getSelectedConfig(),
]); ]);
setConfigFiles(files); setConfigFiles(files);
setInstallDir(currentDir); setInstallDir(currentDir);
@ -40,73 +66,78 @@ export const LaunchScreen = () => {
} else if (files.length > 0 && !selectedFile) { } else if (files.length > 0 && !selectedFile) {
setSelectedFile(files[0].name); setSelectedFile(files[0].name);
} }
await loadSavedSettings();
const currentSelectedFile = await loadConfigFromFile(files, savedConfig);
if (currentSelectedFile && !selectedFile) {
setSelectedFile(currentSelectedFile);
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [selectedFile]); }, [selectedFile, loadSavedSettings, loadConfigFromFile]);
const handleFileSelection = async (fileName: string) => { const handleFileSelection = async (fileName: string) => {
setSelectedFile(fileName); setSelectedFile(fileName);
await window.electronAPI.kobold.setSelectedConfig(fileName); await window.electronAPI.kobold.setSelectedConfig(fileName);
const selectedConfig = configFiles.find((f) => f.name === fileName);
if (selectedConfig) {
await parseAndApplyConfigFile(selectedConfig.path);
}
}; };
useEffect(() => { useEffect(() => {
void loadConfigFiles(); void loadConfigFiles();
const handleInstallDirChange = () => {
void loadConfigFiles();
};
const cleanup = window.electronAPI.kobold.onInstallDirChanged(
handleInstallDirChange
);
return cleanup;
}, [loadConfigFiles]); }, [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 () => { const handleLaunch = async () => {
if (!selectedFile) return;
try { try {
const selectedConfig = configFiles.find((f) => f.name === selectedFile); const selectedConfig = selectedFile
if (selectedConfig) { ? configFiles.find((f) => f.name === selectedFile)
const result = await window.electronAPI.kobold.launchKoboldCpp([ : null;
selectedConfig.path,
]); const args: string[] = [];
if (result.success) {
// Launch successful if (modelPath) {
} else { args.push('--model', modelPath);
console.error('Launch failed:', result.error); }
}
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) { } catch (error) {
console.error('Error launching KoboldCpp:', error); console.error('Error launching KoboldCpp:', error);
@ -114,10 +145,10 @@ export const LaunchScreen = () => {
}; };
return ( return (
<Container size="md" py="xl"> <Container size="sm" py="xl">
<Card withBorder radius="md" shadow="sm"> <Card withBorder radius="md" shadow="sm">
<Stack gap="lg" align="center"> <Stack gap="lg">
<Title order={2}>Launch Configuration</Title> <Title order={3}>Launch Configuration</Title>
<Card withBorder radius="md" w="100%"> <Card withBorder radius="md" w="100%">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
@ -128,11 +159,198 @@ export const LaunchScreen = () => {
loading={loading} loading={loading}
size="sm" size="sm"
> >
<IconRefresh size={16} /> <RotateCcw size={16} />
</ActionIcon> </ActionIcon>
</Group> </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> </Card>
<Group gap="md" justify="center"> <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>; checkForUpdates: () => Promise<UpdateInfo | null>;
getLatestReleaseWithStatus: () => Promise<ReleaseWithStatus | null>; getLatestReleaseWithStatus: () => Promise<ReleaseWithStatus | null>;
launchKoboldCpp: ( launchKoboldCpp: (
args?: string[] args?: string[],
configFilePath?: string
) => Promise<{ success: boolean; pid?: number; error?: string }>; ) => Promise<{ success: boolean; pid?: number; error?: string }>;
openInstallDialog: () => Promise<{ success: boolean; path?: string }>; openInstallDialog: () => Promise<{ success: boolean; path?: string }>;
getConfigFiles: () => Promise< getConfigFiles: () => Promise<
@ -81,8 +82,19 @@ export interface KoboldAPI {
>; >;
getSelectedConfig: () => Promise<string | null>; getSelectedConfig: () => Promise<string | null>;
setSelectedConfig: (configName: string) => Promise<boolean>; 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; onDownloadProgress: (callback: (progress: number) => void) => void;
onUpdateAvailable: (callback: (updateInfo: UpdateInfo) => void) => void; onUpdateAvailable: (callback: (updateInfo: UpdateInfo) => void) => void;
onInstallDirChanged: (callback: (newPath: string) => void) => () => void;
onKoboldOutput: (callback: (data: string) => void) => () => void;
removeAllListeners: (channel: string) => void; removeAllListeners: (channel: string) => void;
} }
@ -91,11 +103,21 @@ export interface AppAPI {
openExternal: (url: string) => Promise<void>; 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 { declare global {
interface Window { interface Window {
electronAPI: { electronAPI: {
kobold: KoboldAPI; kobold: KoboldAPI;
app: AppAPI; 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 => { export const getAssetDescription = (assetName: string): string => {
const name = assetName.toLowerCase(); const name = assetName.toLowerCase();
if (name.includes('rocm')) { if (name.includes(ASSET_SUFFIXES.ROCM)) {
return 'Optimized for AMD GPUs with ROCm support.'; 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.'; return 'Meant for old PCs that cannot normally run the standard build.';
} }
if (name.endsWith('nocuda')) { if (name.endsWith(ASSET_SUFFIXES.NOCUDA)) {
return 'Standard build with NVIDIA CUDA removed for minimal file size.'; return 'Standard build with NVIDIA CUDA support removed for minimal file size.';
} }
return "Standard build that's ideal for most cases."; return "Standard build that's ideal for most cases.";
@ -22,15 +24,15 @@ export const isAssetRecommended = (
): boolean => { ): boolean => {
const name = assetName.toLowerCase(); const name = assetName.toLowerCase();
if (hasAMDGPU && name.includes('rocm')) { if (hasAMDGPU && name.includes(ASSET_SUFFIXES.ROCM)) {
return true; return true;
} }
return ( return (
!hasAMDGPU && !hasAMDGPU &&
!name.includes('rocm') && !name.includes(ASSET_SUFFIXES.ROCM) &&
!name.endsWith('oldpc') && !name.endsWith(ASSET_SUFFIXES.OLDPC) &&
!name.endsWith('nocuda') !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, "strict": true,
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"skipLibCheck": true "skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}, },
"include": ["src/main/**/*", "src/preload/**/*"], "include": ["src/main/**/*", "src/preload/**/*"],
"exclude": ["node_modules", "dist", "dist-electron"] "exclude": ["node_modules", "dist", "dist-electron"]