better dummy icon, fix close to tray, implement launch tabs for more configurations, kobold lite iframe view, UX improvements and bug fixes

This commit is contained in:
lone-cloud 2025-08-14 01:25:29 -07:00
parent ab6ec3ffe7
commit 105e5cfbf7
33 changed files with 1658 additions and 970 deletions

View file

@ -16,4 +16,4 @@
- Use proper TypeScript types (avoid `any` when possible) - Use proper TypeScript types (avoid `any` when possible)
- Follow the ESLint configuration (includes SonarJS and security rules) - Follow the ESLint configuration (includes SonarJS and security rules)
- Never create tests, docs or github workflows - Never create tests, docs or github workflows
- don't log anything to console - Stop asking me to run the "dev" script to test changes

View file

@ -22,6 +22,10 @@ A koboldcpp manager.
npm run dev npm run dev
``` ```
### Linux Wayland support
Additional configurations have been written to help with ideal Wayland support, but as per current Electron guidelines, the user should set `ELECTRON_OZONE_PLATFORM_HINT` to `wayland` in their environment variable according to the [Electron Environment Variables documentation](https://www.electronjs.org/docs/latest/api/environment-variables#electron_ozone_platform_hint-linux).
### Future considerations ### Future considerations
It would make a lot of sense to transition this project to Tauri from Electron. The app size should drop from ~80MB to ~10MB; however, users on obsolete OSes (with outdated WebViews) will very likely encounter issues. In addition, I would need to learn Rust to rewrite the BE (Electron main code), but at least we can re-use all the React code. The app would be much smaller, faster and memory efficient, but not work for some users. I think it's a worthy tradeoff. It would make a lot of sense to transition this project to Tauri from Electron. The app size should drop from ~80MB to ~10MB; however, users on obsolete OSes (with outdated WebViews) will very likely encounter issues. In addition, I would need to learn Rust to rewrite the BE (Electron main code), but at least we can re-use all the React code. The app would be much smaller, faster and memory efficient, but not work for some users. I think it's a worthy tradeoff.

View file

@ -0,0 +1,9 @@
[Desktop Entry]
Name=FriendlyKobold
Comment=A modern Electron shell for KoboldCpp
Exec=friendly-kobold %U
Icon=/home/eggy/Projects/friendly-kobold/assets/icon_512.png
Type=Application
Categories=Development;TextEditor;
StartupWMClass=FriendlyKobold
StartupNotify=true

BIN
assets/icon_256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
assets/icon_512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View file

@ -205,6 +205,7 @@
"vite", "vite",
"vitejs", "vitejs",
"vitest", "vitest",
"wayland",
"webContents", "webContents",
"webkit", "webkit",
"webp", "webp",

View file

@ -32,6 +32,7 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
input: './index.html', input: './index.html',
}, },
chunkSizeWarningLimit: 1000,
}, },
resolve: { resolve: {
alias: { alias: {

View file

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' http://localhost:*; script-src 'self' 'unsafe-inline' 'unsafe-eval';" content="default-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:*; script-src 'self' 'unsafe-inline'; frame-src 'self' http://localhost:*;"
/> />
<title>Friendly Kobold</title> <title>Friendly Kobold</title>
</head> </head>

528
package-lock.json generated
View file

@ -11,8 +11,7 @@
"dependencies": { "dependencies": {
"@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", "jiti": "^2.5.1",
"@mantine/notifications": "^8.2.4",
"lucide-react": "^0.539.0", "lucide-react": "^0.539.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1" "react-dom": "^19.1.1"
@ -26,7 +25,7 @@
"@typescript-eslint/eslint-plugin": "^8.39.1", "@typescript-eslint/eslint-plugin": "^8.39.1",
"@typescript-eslint/parser": "^8.39.1", "@typescript-eslint/parser": "^8.39.1",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"concurrently": "^9.2.0", "cross-env": "^10.0.0",
"cspell": "^9.2.0", "cspell": "^9.2.0",
"electron": "^37.2.6", "electron": "^37.2.6",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
@ -39,7 +38,6 @@
"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",
@ -1078,6 +1076,23 @@
"electron-fuses": "dist/bin.js" "electron-fuses": "dist/bin.js"
} }
}, },
"node_modules/@electron/fuses/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/@electron/fuses/node_modules/fs-extra": { "node_modules/@electron/fuses/node_modules/fs-extra": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@ -1228,41 +1243,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@electron/node-gyp/node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=8"
}
},
"node_modules/@electron/node-gyp/node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
"dev": true,
"license": "ISC",
"dependencies": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
"minipass": "^5.0.0",
"minizlib": "^2.1.1",
"mkdirp": "^1.0.3",
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@electron/node-gyp/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true,
"license": "ISC"
},
"node_modules/@electron/notarize": { "node_modules/@electron/notarize": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz",
@ -1419,6 +1399,23 @@
"node": ">=12.13.0" "node": ">=12.13.0"
} }
}, },
"node_modules/@electron/rebuild/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/@electron/rebuild/node_modules/fs-extra": { "node_modules/@electron/rebuild/node_modules/fs-extra": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
@ -1447,34 +1444,6 @@
"graceful-fs": "^4.1.6" "graceful-fs": "^4.1.6"
} }
}, },
"node_modules/@electron/rebuild/node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=8"
}
},
"node_modules/@electron/rebuild/node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
"dev": true,
"license": "ISC",
"dependencies": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
"minipass": "^5.0.0",
"minizlib": "^2.1.1",
"mkdirp": "^1.0.3",
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@electron/rebuild/node_modules/universalify": { "node_modules/@electron/rebuild/node_modules/universalify": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@ -1485,13 +1454,6 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/@electron/rebuild/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true,
"license": "ISC"
},
"node_modules/@electron/universal": { "node_modules/@electron/universal": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz",
@ -1663,6 +1625,13 @@
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.9", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
@ -2681,31 +2650,7 @@
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.2.4.tgz", "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.2.4.tgz",
"integrity": "sha512-ZXQ0uk6UtJa6Gl+ZWMBh4wC7UqCAoWWvau1TOoe05sBrkyGcXSVrTfoCVzjEQaB/h8VEOUWLGtJokkiMQKcMzA==", "integrity": "sha512-ZXQ0uk6UtJa6Gl+ZWMBh4wC7UqCAoWWvau1TOoe05sBrkyGcXSVrTfoCVzjEQaB/h8VEOUWLGtJokkiMQKcMzA==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peer": true,
"react": "^18.x || ^19.x"
}
},
"node_modules/@mantine/notifications": {
"version": "8.2.4",
"resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.2.4.tgz",
"integrity": "sha512-CPyYM1Y9oXxlJl5zTJN0mgJGZh8ZrhdIsA4ZktnpmJMKvGHWQdmtzTcPDu4gwzDNdANsN0f9DtMSp68kNiD1xA==",
"license": "MIT",
"dependencies": {
"@mantine/store": "8.2.4",
"react-transition-group": "4.4.5"
},
"peerDependencies": {
"@mantine/core": "8.2.4",
"@mantine/hooks": "8.2.4",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/store": {
"version": "8.2.4",
"resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.2.4.tgz",
"integrity": "sha512-NYbhSy6UkVXsCDDHau+ZmGuuLgQ1laNINhKRHYabRvH5aSuU9drbgIlraNgzoF/+LeoTSQ8LylsdWNQRq0hqqA==",
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"react": "^18.x || ^19.x" "react": "^18.x || ^19.x"
} }
@ -3867,34 +3812,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/app-builder-lib/node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=8"
}
},
"node_modules/app-builder-lib/node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
"dev": true,
"license": "ISC",
"dependencies": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
"minipass": "^5.0.0",
"minizlib": "^2.1.1",
"mkdirp": "^1.0.3",
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/app-builder-lib/node_modules/universalify": { "node_modules/app-builder-lib/node_modules/universalify": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@ -3905,13 +3822,6 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/app-builder-lib/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true,
"license": "ISC"
},
"node_modules/argparse": { "node_modules/argparse": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@ -4382,6 +4292,23 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/builder-util/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/builder-util/node_modules/fs-extra": { "node_modules/builder-util/node_modules/fs-extra": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
@ -4527,41 +4454,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/cacache/node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
"dev": true,
"license": "ISC",
"dependencies": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
"minipass": "^5.0.0",
"minizlib": "^2.1.1",
"mkdirp": "^1.0.3",
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/cacache/node_modules/tar/node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=8"
}
},
"node_modules/cacache/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true,
"license": "ISC"
},
"node_modules/cacheable-lookup": { "node_modules/cacheable-lookup": {
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
@ -4651,9 +4543,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001734", "version": "1.0.30001735",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001734.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001735.tgz",
"integrity": "sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==", "integrity": "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -4672,17 +4564,13 @@
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": { "engines": {
"node": ">=10" "node": "^12.17.0 || ^14.13 || >=16.0.0"
}, },
"funding": { "funding": {
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
@ -4704,32 +4592,6 @@
"url": "https://github.com/chalk/chalk-template?sponsor=1" "url": "https://github.com/chalk/chalk-template?sponsor=1"
} }
}, },
"node_modules/chalk-template/node_modules/chalk": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz",
"integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/chownr": { "node_modules/chownr": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
@ -5009,32 +4871,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/concurrently": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz",
"integrity": "sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2",
"lodash": "^4.17.21",
"rxjs": "^7.8.1",
"shell-quote": "^1.8.1",
"supports-color": "^8.1.1",
"tree-kill": "^1.2.2",
"yargs": "^17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/config-file-ts": { "node_modules/config-file-ts": {
"version": "0.2.8-rc1", "version": "0.2.8-rc1",
"resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.8-rc1.tgz", "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.8-rc1.tgz",
@ -5126,6 +4962,24 @@
"buffer": "^5.1.0" "buffer": "^5.1.0"
} }
}, },
"node_modules/cross-env": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.0.0.tgz",
"integrity": "sha512-aU8qlEK/nHYtVuN4p7UQgAwVljzMg8hB4YK5ThRqD2l/ziSnryncPNn7bMLt5cFYsKVKBh8HqLqyCoTupEUu7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -5324,19 +5178,6 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/cspell/node_modules/chalk": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz",
"integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@ -5684,16 +5525,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.6.1", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@ -5819,6 +5650,23 @@
"electron-winstaller": "5.4.0" "electron-winstaller": "5.4.0"
} }
}, },
"node_modules/electron-builder/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/electron-builder/node_modules/fs-extra": { "node_modules/electron-builder/node_modules/fs-extra": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
@ -5874,6 +5722,23 @@
"mime": "^2.5.2" "mime": "^2.5.2"
} }
}, },
"node_modules/electron-publish/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/electron-publish/node_modules/fs-extra": { "node_modules/electron-publish/node_modules/fs-extra": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
@ -6683,6 +6548,23 @@
"concat-map": "0.0.1" "concat-map": "0.0.1"
} }
}, },
"node_modules/eslint/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/eslint/node_modules/eslint-visitor-keys": { "node_modules/eslint/node_modules/eslint-visitor-keys": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
@ -8487,7 +8369,6 @@
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
"integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==",
"dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
@ -8690,19 +8571,6 @@
"url": "https://opencollective.com/lint-staged" "url": "https://opencollective.com/lint-staged"
} }
}, },
"node_modules/lint-staged/node_modules/chalk": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz",
"integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/listr2": { "node_modules/listr2": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.1.tgz", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.1.tgz",
@ -8853,6 +8721,23 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/log-symbols/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/log-update": { "node_modules/log-update": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
@ -8923,6 +8808,7 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "js-tokens": "^3.0.0 || ^4.0.0"
@ -9456,6 +9342,7 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -9670,6 +9557,23 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/ora/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/ora/node_modules/cli-cursor": { "node_modules/ora/node_modules/cli-cursor": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@ -10105,6 +10009,7 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
@ -10307,22 +10212,6 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/read-binary-file-arch": { "node_modules/read-binary-file-arch": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
@ -10951,19 +10840,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@ -11512,19 +11388,16 @@
} }
}, },
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "8.1.1", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"has-flag": "^4.0.0" "has-flag": "^4.0.0"
}, },
"engines": { "engines": {
"node": ">=10" "node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
} }
}, },
"node_modules/supports-preserve-symlinks-flag": { "node_modules/supports-preserve-symlinks-flag": {
@ -11561,6 +11434,41 @@
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
"dev": true,
"license": "ISC",
"dependencies": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
"minipass": "^5.0.0",
"minizlib": "^2.1.1",
"mkdirp": "^1.0.3",
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/tar/node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=8"
}
},
"node_modules/tar/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true,
"license": "ISC"
},
"node_modules/temp": { "node_modules/temp": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz",
@ -11724,16 +11632,6 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/truncate-utf8-bytes": { "node_modules/truncate-utf8-bytes": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",

View file

@ -1,6 +1,6 @@
{ {
"name": "friendly-kobold", "name": "friendly-kobold",
"productName": "friendly-kobold", "productName": "Friendly Kobold",
"version": "0.1.0", "version": "0.1.0",
"description": "A modern Electron shell for KoboldCpp", "description": "A modern Electron shell for KoboldCpp",
"main": "out/main/index.js", "main": "out/main/index.js",
@ -8,10 +8,10 @@
"scripts": { "scripts": {
"dev": "electron-vite dev", "dev": "electron-vite dev",
"build": "electron-vite build && electron-builder", "build": "electron-vite build && electron-builder",
"build:analyze": "ANALYZE=true electron-vite build", "build:analyze": "cross-env ANALYZE=true electron-vite build && npx open-cli dist/stats.html",
"build:electron": "electron-vite build", "build:electron": "electron-vite build",
"analyze": "npm run build:analyze", "analyze": "npm run build:analyze",
"analyze:server": "ANALYZE=server electron-vite build", "analyze:server": "cross-env ANALYZE=server electron-vite build && npx open-cli dist/stats.html",
"preview": "electron-vite preview", "preview": "electron-vite preview",
"preb": "electron-vite build", "preb": "electron-vite build",
"start": "electron .", "start": "electron .",
@ -54,7 +54,7 @@
"@typescript-eslint/eslint-plugin": "^8.39.1", "@typescript-eslint/eslint-plugin": "^8.39.1",
"@typescript-eslint/parser": "^8.39.1", "@typescript-eslint/parser": "^8.39.1",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"concurrently": "^9.2.0", "cross-env": "^10.0.0",
"cspell": "^9.2.0", "cspell": "^9.2.0",
"electron": "^37.2.6", "electron": "^37.2.6",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
@ -67,7 +67,6 @@
"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",
@ -78,16 +77,16 @@
"dependencies": { "dependencies": {
"@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", "jiti": "^2.5.1",
"@mantine/notifications": "^8.2.4",
"lucide-react": "^0.539.0", "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.friendly-kobold.app",
"productName": "FriendlyKobold", "productName": "Friendly Kobold",
"compression": "maximum", "compression": "maximum",
"icon": "assets/icon_512.png",
"directories": { "directories": {
"output": "release" "output": "release"
}, },
@ -118,7 +117,6 @@
{ {
"target": "dmg", "target": "dmg",
"arch": [ "arch": [
"x64",
"arm64" "arm64"
] ]
} }
@ -135,6 +133,15 @@
] ]
}, },
"linux": { "linux": {
"category": "Development",
"desktop": {
"entry": {
"Name": "Friendly Kobold",
"Comment": "A modern Electron shell for KoboldCpp",
"Categories": "Development;Utility;",
"StartupWMClass": "Friendly Kobold"
}
},
"target": [ "target": [
{ {
"target": "AppImage", "target": "AppImage",

View file

@ -1,73 +1,28 @@
import { useState, useEffect, ReactNode } from 'react'; import { useState, useEffect } from 'react';
import { import {
AppShell, AppShell,
Group, Group,
ActionIcon, ActionIcon,
Tooltip, Tooltip,
rem, rem,
Transition,
Loader, Loader,
Center, Center,
Stack, Stack,
Text, Text,
Button, Button,
Select,
useMantineColorScheme, useMantineColorScheme,
} from '@mantine/core'; } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { Settings, ArrowLeft } from 'lucide-react'; 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 { InterfaceScreen } from '@/screens/InterfaceScreen';
import { UpdateDialog } from '@/components/UpdateDialog'; import { UpdateDialog } from '@/components/UpdateDialog';
import { SettingsModal } from '@/components/SettingsModal'; import { SettingsModal } from '@/components/SettingsModal';
import { useNotifications } from '@/hooks/useNotifications'; import { ScreenTransition } from '@/components/ScreenTransition';
import type { UpdateInfo } from '@/types'; import type { UpdateInfo } from '@/types';
type Screen = 'download' | 'launch' | 'terminal'; type Screen = 'download' | 'launch' | 'interface';
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 | null>(null); const [currentScreen, setCurrentScreen] = useState<Screen | null>(null);
@ -75,14 +30,27 @@ export const App = () => {
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 [hasInitialized, setHasInitialized] = useState(false);
const [activeInterfaceTab, setActiveInterfaceTab] = useState<string | null>(
'terminal'
);
const { colorScheme } = useMantineColorScheme(); const { colorScheme } = useMantineColorScheme();
const notify = useNotifications();
useEffect(() => { useEffect(() => {
const checkInstallation = async () => { const checkInstallation = async () => {
try { try {
const installed = await window.electronAPI.kobold.isInstalled(); const startTime = Date.now();
setCurrentScreen(installed ? 'launch' : 'download'); const installedVersions =
await window.electronAPI.kobold.getInstalledVersions(false);
const elapsed = Date.now() - startTime;
const minDelay = 500;
if (elapsed < minDelay) {
await new Promise((resolve) =>
setTimeout(resolve, minDelay - elapsed)
);
}
setCurrentScreen(installedVersions.length > 0 ? 'launch' : 'download');
setHasInitialized(true); setHasInitialized(true);
} catch (error) { } catch (error) {
console.error('Error checking installation:', error); console.error('Error checking installation:', error);
@ -109,18 +77,13 @@ export const App = () => {
}, []); }, []);
const handleDownloadComplete = () => { const handleDownloadComplete = () => {
notify.success(
'Download Complete',
'KoboldCpp has been successfully installed'
);
setTimeout(() => { setTimeout(() => {
setCurrentScreen('launch'); setCurrentScreen('launch');
}, 100); }, 100);
}; };
const handleLaunch = () => { const handleLaunch = () => {
setCurrentScreen('terminal'); setCurrentScreen('interface');
notify.success('Launch Started', 'KoboldCpp is starting up...');
}; };
const handleBackToLaunch = () => { const handleBackToLaunch = () => {
@ -142,7 +105,6 @@ export const App = () => {
await window.electronAPI.kobold.stopKoboldCpp(); await window.electronAPI.kobold.stopKoboldCpp();
} catch (error) { } catch (error) {
console.error('Error stopping KoboldCpp:', error); console.error('Error stopping KoboldCpp:', error);
notify.error('Stop Failed', 'Failed to stop KoboldCpp process');
} }
handleBackToLaunch(); handleBackToLaunch();
@ -159,25 +121,20 @@ export const App = () => {
return ( return (
<> <>
<Notifications
position="bottom-right"
zIndex={1000}
containerWidth={320}
/>
<AppShell header={{ height: 60 }} padding="md"> <AppShell header={{ height: 60 }} padding="md">
<AppShell.Header <AppShell.Header
style={{ style={{
borderBottom: `1px solid var(--mantine-color-${colorScheme === 'dark' ? 'dark-4' : 'gray-3'})`, borderBottom: `1px solid var(--mantine-color-${colorScheme === 'dark' ? 'dark-4' : 'gray-3'})`,
backdropFilter: 'blur(10px)',
background: background:
colorScheme === 'dark' colorScheme === 'dark'
? 'rgba(26, 27, 30, 0.8)' ? 'var(--mantine-color-dark-7)'
: 'rgba(255, 255, 255, 0.8)', : 'var(--mantine-color-white)',
transition: 'all 200ms ease', transition: 'all 200ms ease',
}} }}
> >
<Group h="100%" px="md" justify="space-between"> <Group h="100%" px="md" justify="space-between" align="center">
{currentScreen === 'terminal' && ( <div style={{ minWidth: '100px' }}>
{currentScreen === 'interface' && (
<Button <Button
variant="light" variant="light"
color="red" color="red"
@ -187,8 +144,39 @@ export const App = () => {
Eject Eject
</Button> </Button>
)} )}
</div>
<Group ml="auto"> {currentScreen === 'interface' && (
<Select
value={activeInterfaceTab}
onChange={setActiveInterfaceTab}
data={[
{ value: 'chat', label: 'KoboldAI Lite' },
{ value: 'terminal', label: 'Terminal' },
]}
placeholder="Select view"
styles={{
input: {
minWidth: '150px',
textAlign: 'center',
border: 'none',
backgroundColor: 'transparent',
fontWeight: 500,
},
dropdown: {
minWidth: '150px',
},
}}
/>
)}
<div
style={{
minWidth: '100px',
display: 'flex',
justifyContent: 'flex-end',
}}
>
<Tooltip label="Settings" position="bottom"> <Tooltip label="Settings" position="bottom">
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
@ -203,7 +191,7 @@ export const App = () => {
<Settings style={{ width: rem(20), height: rem(20) }} /> <Settings style={{ width: rem(20), height: rem(20) }} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</Group> </div>
</Group> </Group>
</AppShell.Header> </AppShell.Header>
<AppShell.Main <AppShell.Main
@ -239,10 +227,13 @@ export const App = () => {
</ScreenTransition> </ScreenTransition>
<ScreenTransition <ScreenTransition
isActive={currentScreen === 'terminal'} isActive={currentScreen === 'interface'}
shouldAnimate={hasInitialized} shouldAnimate={hasInitialized}
> >
<TerminalScreen /> <InterfaceScreen
activeTab={activeInterfaceTab}
onTabChange={setActiveInterfaceTab}
/>
</ScreenTransition> </ScreenTransition>
</> </>
)} )}

View file

@ -0,0 +1,36 @@
import { ReactNode } from 'react';
import { Transition } from '@mantine/core';
interface ScreenTransitionProps {
isActive: boolean;
shouldAnimate: boolean;
children: ReactNode;
}
export const ScreenTransition = ({
isActive,
shouldAnimate,
children,
}: ScreenTransitionProps) => (
<Transition
mounted={isActive}
transition="fade"
duration={shouldAnimate ? 100 : 0}
timingFunction="ease-in-out"
>
{(styles) => (
<div
style={{
...styles,
position: isActive ? 'static' : 'absolute',
width: '100%',
top: 0,
left: 0,
zIndex: isActive ? 1 : 0,
}}
>
{children}
</div>
)}
</Transition>
);

View file

@ -0,0 +1,51 @@
import { useRef } from 'react';
import { Box, Text, Stack } from '@mantine/core';
interface ChatTabProps {
serverUrl?: string;
isServerReady?: boolean;
}
export const ChatTab = ({ serverUrl, isServerReady }: ChatTabProps) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
if (!isServerReady || !serverUrl) {
return (
<Box
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Stack align="center" gap="md">
<Text c="dimmed" size="lg">
Waiting for KoboldCpp server to start...
</Text>
<Text c="dimmed" size="sm">
The chat interface will load automatically when ready
</Text>
</Stack>
</Box>
);
}
return (
<Box style={{ width: '100%', height: '100%', overflow: 'hidden' }}>
<iframe
ref={iframeRef}
src={serverUrl}
title="KoboldAI Lite Interface"
style={{
width: '100%',
height: '100%',
border: 'none',
borderRadius: 'inherit',
}}
allow="clipboard-read; clipboard-write"
/>
</Box>
);
};

View file

@ -0,0 +1,173 @@
import { useState, useEffect, useRef } from 'react';
import { Box, ScrollArea, Text, ActionIcon } from '@mantine/core';
import { ChevronDown } from 'lucide-react';
interface TerminalTabProps {
onServerReady?: (serverUrl: string) => void;
serverOnly?: boolean;
}
export const TerminalTab = ({
onServerReady,
serverOnly,
}: TerminalTabProps) => {
const [terminalContent, setTerminalContent] = useState<string>('');
const [isUserScrolling, setIsUserScrolling] = useState<boolean>(false);
const [shouldAutoScroll, setShouldAutoScroll] = useState<boolean>(true);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const lastScrollTop = useRef<number>(0);
const handleScroll = ({ y }: { y: number }) => {
if (!viewportRef.current) return;
const { scrollHeight, clientHeight } = viewportRef.current;
const isAtBottomNow = y + clientHeight >= scrollHeight - 10;
if (y < lastScrollTop.current) {
setIsUserScrolling(true);
setShouldAutoScroll(false);
} else if (isAtBottomNow) {
setIsUserScrolling(false);
setShouldAutoScroll(true);
}
lastScrollTop.current = y;
};
useEffect(() => {
if (shouldAutoScroll && !isUserScrolling && viewportRef.current) {
const viewport = viewportRef.current;
viewport.scrollTop = viewport.scrollHeight;
}
}, [terminalContent, shouldAutoScroll, isUserScrolling]);
useEffect(() => {
const cleanup = window.electronAPI.kobold.onKoboldOutput((data: string) => {
setTerminalContent((prev) => {
const lines = prev.split('\n');
const newData = data.toString();
if (
!serverOnly &&
onServerReady &&
newData.includes('Please connect to custom endpoint at ')
) {
const match = newData.match(
/Please connect to custom endpoint at (http:\/\/[^\s]+)/
);
if (match) {
const serverUrl = match[1];
setTimeout(() => onServerReady(serverUrl), 1500);
}
}
if (newData.includes('\r')) {
const parts = newData.split('\r');
for (let i = 0; i < parts.length; i++) {
if (i === 0) {
if (lines.length > 0) {
lines[lines.length - 1] += parts[i];
} else {
lines.push(parts[i]);
}
} else {
if (lines.length > 0) {
lines[lines.length - 1] = parts[i];
} else {
lines.push(parts[i]);
}
}
}
return lines.join('\n');
} else {
return prev + newData;
}
});
});
return cleanup;
}, [onServerReady, serverOnly]);
const scrollToBottom = () => {
if (viewportRef.current) {
const viewport = viewportRef.current;
viewport.scrollTop = viewport.scrollHeight;
setShouldAutoScroll(true);
setIsUserScrolling(false);
}
};
return (
<Box
style={{
height: '80vh',
display: 'flex',
flexDirection: 'column',
backgroundColor: 'var(--mantine-color-dark-8)',
borderRadius: 'inherit',
position: 'relative',
}}
>
<ScrollArea
ref={scrollAreaRef}
viewportRef={viewportRef}
onScrollPositionChange={handleScroll}
style={{
flex: 1,
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace',
fontSize: '14px',
lineHeight: 1.4,
}}
scrollbarSize={8}
offsetScrollbars={false}
>
<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>
{isUserScrolling && !shouldAutoScroll && (
<ActionIcon
variant="filled"
color="blue"
size="lg"
radius="xl"
onClick={scrollToBottom}
style={{
position: 'absolute',
bottom: '20px',
right: '20px',
zIndex: 10,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
}}
aria-label="Scroll to bottom"
>
<ChevronDown size={20} />
</ActionIcon>
)}
</Box>
);
};

View file

@ -0,0 +1,89 @@
import {
Stack,
Text,
Group,
TextInput,
ActionIcon,
Tooltip,
Switch,
} from '@mantine/core';
import { Info } from 'lucide-react';
interface AdvancedTabProps {
additionalArguments: string;
serverOnly: boolean;
onAdditionalArgumentsChange: (args: string) => void;
onServerOnlyChange: (serverOnly: boolean) => void;
}
export const AdvancedTab = ({
additionalArguments,
serverOnly,
onAdditionalArgumentsChange,
onServerOnlyChange,
}: AdvancedTabProps) => (
<Stack gap="lg">
<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
color="dark"
styles={{
tooltip: {
backgroundColor: 'var(--mantine-color-dark-6)',
color: 'var(--mantine-color-gray-0)',
border: '1px solid var(--mantine-color-dark-4)',
},
}}
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
</Group>
<TextInput
placeholder="Additional command line arguments"
value={additionalArguments}
onChange={(event) =>
onAdditionalArgumentsChange(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
color="dark"
styles={{
tooltip: {
backgroundColor: 'var(--mantine-color-dark-6)',
color: 'var(--mantine-color-gray-0)',
border: '1px solid var(--mantine-color-dark-4)',
},
}}
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
</Group>
<Switch
checked={serverOnly}
onChange={(event) => onServerOnlyChange(event.currentTarget.checked)}
/>
</div>
</Stack>
);

View file

@ -0,0 +1,58 @@
import { Stack, Text, Group, Button, ActionIcon, Menu } from '@mantine/core';
import { RotateCcw, Save, Settings2 } from 'lucide-react';
import { ConfigFileSelect } from '@/components/ConfigFileSelect';
import type { ConfigFile } from '@/types';
interface ConfigurationManagerProps {
configFiles: ConfigFile[];
selectedFile: string | null;
onFileSelection: (fileName: string) => void;
onRefresh: () => void;
onSaveAsNew: () => void;
onUpdateCurrent: () => void;
}
export const ConfigurationManager = ({
configFiles,
selectedFile,
onFileSelection,
onRefresh,
onSaveAsNew,
onUpdateCurrent,
}: ConfigurationManagerProps) => (
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={500}>Configuration File</Text>
<Group gap="xs">
<Menu>
<Menu.Target>
<Button variant="light" leftSection={<Save size={16} />}>
Save
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<Save size={16} />} onClick={onSaveAsNew}>
Save as new configuration
</Menu.Item>
<Menu.Item
leftSection={<Settings2 size={16} />}
disabled={!selectedFile}
onClick={onUpdateCurrent}
>
Update current configuration
</Menu.Item>
</Menu.Dropdown>
</Menu>
<ActionIcon variant="light" onClick={onRefresh} size="lg">
<RotateCcw size={16} />
</ActionIcon>
</Group>
</Group>
<ConfigFileSelect
configFiles={configFiles}
selectedFile={selectedFile}
onFileSelection={onFileSelection}
/>
</Stack>
);

View file

@ -0,0 +1,197 @@
import {
Stack,
Text,
Group,
TextInput,
Button,
ActionIcon,
Tooltip,
Checkbox,
Slider,
} from '@mantine/core';
import { File, Info, Search } from 'lucide-react';
interface GeneralTabProps {
modelPath: string;
gpuLayers: number;
autoGpuLayers: boolean;
contextSize: number;
onModelPathChange: (path: string) => void;
onSelectModelFile: () => void;
onGpuLayersChange: (layers: number) => void;
onAutoGpuLayersChange: (auto: boolean) => void;
onContextSizeChange: (size: number) => void;
}
export const GeneralTab = ({
modelPath,
gpuLayers,
autoGpuLayers,
contextSize,
onModelPathChange,
onSelectModelFile,
onGpuLayersChange,
onAutoGpuLayersChange,
onContextSizeChange,
}: GeneralTabProps) => (
<Stack gap="lg">
<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) => onModelPathChange(event.currentTarget.value)}
style={{ flex: 1 }}
required
/>
<Button
onClick={onSelectModelFile}
variant="light"
leftSection={<File size={16} />}
>
Browse
</Button>
<Button
onClick={() => {
window.electronAPI.app.openExternal(
'https://huggingface.co/models?pipeline_tag=text-generation&library=gguf&sort=trending'
);
}}
variant="outline"
leftSection={<Search size={16} />}
>
Search HF
</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
color="dark"
styles={{
tooltip: {
backgroundColor: 'var(--mantine-color-dark-6)',
color: 'var(--mantine-color-gray-0)',
border: '1px solid var(--mantine-color-dark-4)',
},
}}
>
<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) =>
onAutoGpuLayersChange(event.currentTarget.checked)
}
size="sm"
/>
<Tooltip
label="Automatically try to allocate the GPU layers based on available VRAM."
multiline
w={300}
withArrow
color="dark"
styles={{
tooltip: {
backgroundColor: 'var(--mantine-color-dark-6)',
color: 'var(--mantine-color-gray-0)',
border: '1px solid var(--mantine-color-dark-4)',
},
}}
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
</Group>
<TextInput
value={gpuLayers.toString()}
onChange={(event) =>
onGpuLayersChange(Number(event.target.value) || 0)
}
type="number"
min={0}
max={100}
step={1}
size="sm"
w={80}
disabled={autoGpuLayers}
/>
</Group>
</Group>
<Slider
value={gpuLayers}
min={0}
max={100}
step={1}
onChange={onGpuLayersChange}
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
color="dark"
styles={{
tooltip: {
backgroundColor: 'var(--mantine-color-dark-6)',
color: 'var(--mantine-color-gray-0)',
border: '1px solid var(--mantine-color-dark-4)',
},
}}
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
</Group>
<TextInput
value={contextSize?.toString() || ''}
onChange={(event) =>
onContextSizeChange(Number(event.target.value) || 256)
}
type="number"
min={256}
max={131072}
step={256}
size="sm"
w={100}
/>
</Group>
<Slider
value={contextSize}
min={256}
max={131072}
step={1}
onChange={onContextSizeChange}
/>
</div>
</Stack>
);

View file

@ -0,0 +1,48 @@
import { Title, Text, Group, Button } from '@mantine/core';
interface LaunchHeaderProps {
selectedFile: string | null;
hasUnsavedChanges: boolean;
modelPath: string;
isLaunching: boolean;
onLaunch: () => void;
}
export const LaunchHeader = ({
selectedFile,
hasUnsavedChanges,
modelPath,
isLaunching,
onLaunch,
}: LaunchHeaderProps) => (
<Group justify="space-between" align="center">
<div>
<Title order={3}>Launch Configuration</Title>
<Text size="sm" c="dimmed">
{selectedFile
? `Using: ${selectedFile}`
: 'No configuration file selected'}
{hasUnsavedChanges && (
<Text span c="orange">
{' '}
Unsaved changes
</Text>
)}
</Text>
</div>
<Button
radius="md"
disabled={!modelPath || isLaunching}
onClick={onLaunch}
loading={isLaunching}
size="lg"
variant="filled"
>
{isLaunching
? 'Launching...'
: modelPath
? 'Launch KoboldCpp'
: 'Select a model file to launch'}
</Button>
</Group>
);

View file

@ -0,0 +1,96 @@
import {
Stack,
Text,
TextInput,
Group,
ActionIcon,
Tooltip,
} from '@mantine/core';
import { Info } from 'lucide-react';
interface NetworkTabProps {
port: number;
host: string;
onPortChange: (port: number) => void;
onHostChange: (host: string) => void;
}
export const NetworkTab = ({
port,
host,
onPortChange,
onHostChange,
}: NetworkTabProps) => (
<Stack gap="lg">
{/* Port */}
<div>
<Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}>
Port
</Text>
<Tooltip
label="The port number on which KoboldCpp will listen for connections. Default is 5001."
multiline
w={300}
withArrow
color="dark"
styles={{
tooltip: {
backgroundColor: 'var(--mantine-color-dark-6)',
color: 'var(--mantine-color-gray-0)',
border: '1px solid var(--mantine-color-dark-4)',
},
}}
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
</Group>
<TextInput
placeholder="5001"
value={port.toString()}
onChange={(event) =>
onPortChange(Number(event.currentTarget.value) || 5001)
}
type="number"
min={1}
max={65535}
w={120}
/>
</div>
{/* Host */}
<div>
<Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}>
Host
</Text>
<Tooltip
label="The hostname or IP address on which KoboldCpp will bind its webserver to."
multiline
w={300}
withArrow
color="dark"
styles={{
tooltip: {
backgroundColor: 'var(--mantine-color-dark-6)',
color: 'var(--mantine-color-gray-0)',
border: '1px solid var(--mantine-color-dark-4)',
},
}}
>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Tooltip>
</Group>
<TextInput
placeholder="localhost"
value={host}
onChange={(event) => onHostChange(event.currentTarget.value)}
style={{ maxWidth: 200 }}
/>
</div>
</Stack>
);

View file

@ -0,0 +1,42 @@
import { Modal, Stack, TextInput, Group, Button } from '@mantine/core';
import { Save } from 'lucide-react';
interface SaveConfigModalProps {
opened: boolean;
onClose: () => void;
configName: string;
onConfigNameChange: (name: string) => void;
onSave: () => void;
}
export const SaveConfigModal = ({
opened,
onClose,
configName,
onConfigNameChange,
onSave,
}: SaveConfigModalProps) => (
<Modal opened={opened} onClose={onClose} title="Save Configuration" size="sm">
<Stack gap="md">
<TextInput
label="Configuration Name"
placeholder="Enter a name for this configuration"
value={configName}
onChange={(event) => onConfigNameChange(event.currentTarget.value)}
data-autofocus
/>
<Group justify="flex-end" gap="sm">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
disabled={!configName.trim()}
leftSection={<Save size={16} />}
onClick={onSave}
>
Save Configuration
</Button>
</Group>
</Stack>
</Modal>
);

View file

@ -12,7 +12,7 @@ import { Folder, FolderOpen } from 'lucide-react';
export const GeneralTab = () => { export const GeneralTab = () => {
const [installDir, setInstallDir] = useState<string>(''); const [installDir, setInstallDir] = useState<string>('');
const [minimizeToTray, setMinimizeToTray] = useState<boolean>(true); const [minimizeToTray, setMinimizeToTray] = useState<boolean | null>(null);
useEffect(() => { useEffect(() => {
loadCurrentInstallDir(); loadCurrentInstallDir();
@ -33,13 +33,16 @@ export const GeneralTab = () => {
const trayEnabled = (await window.electronAPI.config.get( const trayEnabled = (await window.electronAPI.config.get(
'minimizeToTray' 'minimizeToTray'
)) as boolean; )) as boolean;
setMinimizeToTray(trayEnabled !== false); setMinimizeToTray(trayEnabled === true);
} catch (error) { } catch (error) {
console.error('Failed to load tray settings:', error); console.error('Failed to load tray settings:', error);
setMinimizeToTray(false);
} }
}; };
const handleTraySettingChange = async (enabled: boolean) => { const handleTraySettingChange = async (enabled: boolean) => {
if (minimizeToTray === null) return;
try { try {
setMinimizeToTray(enabled); setMinimizeToTray(enabled);
await window.electronAPI.config.set('minimizeToTray', enabled); await window.electronAPI.config.set('minimizeToTray', enabled);
@ -98,11 +101,12 @@ export const GeneralTab = () => {
Choose what happens when you close or minimize the window Choose what happens when you close or minimize the window
</Text> </Text>
<Switch <Switch
checked={minimizeToTray} checked={minimizeToTray ?? false}
onChange={(event) => onChange={(event) =>
handleTraySettingChange(event.currentTarget.checked) handleTraySettingChange(event.currentTarget.checked)
} }
label="Minimize to system tray" label="Minimize to system tray"
disabled={minimizeToTray === null}
/> />
</div> </div>
</Stack> </Stack>

View file

@ -8,6 +8,8 @@ export const useLaunchConfig = () => {
const [contextSize, setContextSize] = useState<number>(2048); const [contextSize, setContextSize] = useState<number>(2048);
const [modelPath, setModelPath] = useState<string>(''); const [modelPath, setModelPath] = useState<string>('');
const [additionalArguments, setAdditionalArguments] = useState<string>(''); const [additionalArguments, setAdditionalArguments] = useState<string>('');
const [port, setPort] = useState<number>(5001);
const [host, setHost] = useState<string>('localhost');
const parseAndApplyConfigFile = useCallback(async (configPath: string) => { const parseAndApplyConfigFile = useCallback(async (configPath: string) => {
const configData = const configData =
@ -28,9 +30,23 @@ export const useLaunchConfig = () => {
if (typeof configData.model_param === 'string') { if (typeof configData.model_param === 'string') {
setModelPath(configData.model_param); setModelPath(configData.model_param);
} }
if (typeof configData.port === 'number') {
setPort(configData.port);
} else {
setPort(5001);
}
if (typeof configData.host === 'string') {
setHost(configData.host);
} else {
setHost('localhost');
}
} else { } else {
setGpuLayers(0); setGpuLayers(0);
setContextSize(2048); setContextSize(2048);
setPort(5001);
setHost('localhost');
} }
}, []); }, []);
@ -41,6 +57,8 @@ export const useLaunchConfig = () => {
setModelPath(''); // Model path comes from config file, not saved settings setModelPath(''); // Model path comes from config file, not saved settings
setGpuLayers(0); setGpuLayers(0);
setContextSize(2048); setContextSize(2048);
setPort(5001);
setHost('localhost');
}, []); }, []);
const loadConfigFromFile = useCallback( const loadConfigFromFile = useCallback(
@ -110,6 +128,14 @@ export const useLaunchConfig = () => {
setAutoGpuLayers(checked); setAutoGpuLayers(checked);
}, []); }, []);
const handlePortChange = useCallback((value: number) => {
setPort(value);
}, []);
const handleHostChange = useCallback((value: string) => {
setHost(value);
}, []);
return { return {
serverOnly, serverOnly,
gpuLayers, gpuLayers,
@ -117,6 +143,8 @@ export const useLaunchConfig = () => {
contextSize, contextSize,
modelPath, modelPath,
additionalArguments, additionalArguments,
port,
host,
parseAndApplyConfigFile, parseAndApplyConfigFile,
loadSavedSettings, loadSavedSettings,
@ -128,5 +156,7 @@ export const useLaunchConfig = () => {
handleModelPathChange, handleModelPathChange,
handleSelectModelFile, handleSelectModelFile,
handleAdditionalArgumentsChange, handleAdditionalArgumentsChange,
handlePortChange,
handleHostChange,
}; };
}; };

View file

@ -1,91 +0,0 @@
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

@ -21,6 +21,8 @@ class FriendlyKoboldApp {
constructor() { constructor() {
this.configManager = new ConfigManager(this.getConfigPath()); this.configManager = new ConfigManager(this.getConfigPath());
this.ensureInstallDirectory();
this.windowManager = new WindowManager(this.configManager); this.windowManager = new WindowManager(this.configManager);
this.githubService = new GitHubService(); this.githubService = new GitHubService();
this.gpuService = new GPUService(); this.gpuService = new GPUService();
@ -35,8 +37,6 @@ class FriendlyKoboldApp {
this.githubService, this.githubService,
this.gpuService this.gpuService
); );
this.ensureInstallDirectory();
} }
private getConfigPath() { private getConfigPath() {
@ -71,6 +71,13 @@ class FriendlyKoboldApp {
} }
async initialize(): Promise<void> { async initialize(): Promise<void> {
if (process.platform === 'linux') {
if (process.env.ELECTRON_OZONE_PLATFORM_HINT === 'wayland') {
app.commandLine.appendSwitch('enable-features', 'UseOzonePlatform');
app.commandLine.appendSwitch('ozone-platform', 'wayland');
}
}
await app.whenReady(); await app.whenReady();
this.windowManager.setupApplicationMenu(); this.windowManager.setupApplicationMenu();
@ -81,21 +88,17 @@ class FriendlyKoboldApp {
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
return; return;
} }
// On non-macOS platforms, quit the app when all windows are closed
app.quit(); app.quit();
}); });
app.on('before-quit', async (event) => { app.on('before-quit', async (event) => {
// Prevent immediate quit to allow cleanup
event.preventDefault(); event.preventDefault();
// Clean up KoboldCpp process
await this.koboldManager.cleanup(); await this.koboldManager.cleanup();
// Clean up window manager
this.windowManager.cleanup(); this.windowManager.cleanup();
// Now actually quit
app.exit(0); app.exit(0);
}); });

View file

@ -12,7 +12,7 @@ import { dialog } from 'electron';
import { GitHubService } from '@/main/services/GitHubService'; import { GitHubService } from '@/main/services/GitHubService';
import { ConfigManager } from '@/main/managers/ConfigManager'; import { ConfigManager } from '@/main/managers/ConfigManager';
import { WindowManager } from '@/main/managers/WindowManager'; import { WindowManager } from '@/main/managers/WindowManager';
import { APP_NAME, DIALOG_TITLES, ROCM } from '@/constants/app'; import { DIALOG_TITLES, ROCM } from '@/constants/app';
interface GitHubAsset { interface GitHubAsset {
name: string; name: string;
@ -66,9 +66,7 @@ export class KoboldCppManager {
this.configManager = configManager; this.configManager = configManager;
this.githubService = githubService; this.githubService = githubService;
this.windowManager = windowManager; this.windowManager = windowManager;
this.installDir = this.installDir = this.configManager.getInstallDir() || '';
this.configManager.getInstallDir() ||
join(process.env.HOME || process.env.USERPROFILE || '.', APP_NAME);
} }
async downloadRelease( async downloadRelease(
@ -134,18 +132,41 @@ export class KoboldCppManager {
return filePath; return filePath;
} }
async getInstalledVersions(): Promise<InstalledVersion[]> { async getInstalledVersions(
includeVersions = true
): Promise<InstalledVersion[]> {
try { try {
if (!existsSync(this.installDir)) { if (!existsSync(this.installDir)) {
return []; return [];
} }
const files = readdirSync(this.installDir); const files = readdirSync(this.installDir);
const koboldFiles = files.filter( const koboldFiles = files.filter((file) => {
(file) => const filePath = join(this.installDir, file);
statSync(join(this.installDir, file)).isFile() && try {
file.startsWith('koboldcpp') const stats = statSync(filePath);
);
if (stats.isFile() && file.startsWith('koboldcpp')) {
if (process.platform !== 'win32') {
const isExecutable = (stats.mode & parseInt('111', 8)) !== 0;
return isExecutable;
} else {
return true;
}
}
return false;
} catch {
return false;
}
});
if (!includeVersions) {
return koboldFiles.map((file) => ({
version: 'unknown',
path: join(this.installDir, file),
filename: file,
}));
}
const versionPromises = koboldFiles.map(async (file) => { const versionPromises = koboldFiles.map(async (file) => {
const filePath = join(this.installDir, file); const filePath = join(this.installDir, file);
@ -174,49 +195,6 @@ export class KoboldCppManager {
return []; return [];
} }
} }
async isInstalled(): Promise<boolean> {
try {
if (!existsSync(this.installDir)) {
return false;
}
const files = readdirSync(this.installDir);
for (const file of files) {
const filePath = join(this.installDir, file);
try {
const stats = statSync(filePath);
// Check if it's a file (not directory) and starts with "koboldcpp"
if (stats.isFile() && file.startsWith('koboldcpp')) {
// On Unix-like systems, check if file is executable
if (process.platform !== 'win32') {
// Check if the file has execute permission (owner, group, or other)
const isExecutable = (stats.mode & parseInt('111', 8)) !== 0;
if (isExecutable) {
return true;
}
} else {
// On Windows, if it's a file starting with koboldcpp, consider it valid
// (Windows doesn't use Unix-style executable permissions)
return true;
}
}
} catch {
// Skip files we can't stat (permission issues, etc.)
continue;
}
}
return false;
} catch (error) {
console.warn('Error checking installation:', error);
return false;
}
}
async getConfigFiles(): Promise< async getConfigFiles(): Promise<
Array<{ name: string; path: string; size: number }> Array<{ name: string; path: string; size: number }>
> { > {
@ -313,12 +291,10 @@ export class KoboldCppManager {
}; };
} catch (error) { } catch (error) {
console.warn('Failed to get current version info:', error); console.warn('Failed to get current version info:', error);
// Clear invalid binary path
this.configManager.setCurrentKoboldBinary(''); this.configManager.setCurrentKoboldBinary('');
} }
} }
// If no current binary is set or it's invalid, try to find the first available one
const versions = await this.getInstalledVersions(); const versions = await this.getInstalledVersions();
const firstVersion = versions[0]; const firstVersion = versions[0];
@ -400,7 +376,7 @@ export class KoboldCppManager {
try { try {
process.kill('SIGTERM'); process.kill('SIGTERM');
} catch { } catch {
// Process might already be dead // ignore
} }
resolve(null); resolve(null);
}, 10000); }, 10000);
@ -578,7 +554,6 @@ export class KoboldCppManager {
reader.releaseLock(); reader.releaseLock();
} }
// Make the binary executable on Unix-like systems (Linux/macOS)
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
try { try {
chmodSync(filePath, 0o755); chmodSync(filePath, 0o755);
@ -693,7 +668,7 @@ export class KoboldCppManager {
}; };
} }
const finalArgs = [...args]; // Start with the provided arguments const finalArgs = [...args];
if (configFilePath && existsSync(configFilePath)) { if (configFilePath && existsSync(configFilePath)) {
finalArgs.push('--config', configFilePath); finalArgs.push('--config', configFilePath);
@ -701,36 +676,54 @@ export class KoboldCppManager {
const child = spawn(currentVersion.path, finalArgs, { const child = spawn(currentVersion.path, finalArgs, {
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
detached: false, detached: true,
}); });
this.koboldProcess = child; this.koboldProcess = child;
child.unref();
const mainWindow = this.windowManager.getMainWindow(); const mainWindow = this.windowManager.getMainWindow();
if (mainWindow) { if (mainWindow && !mainWindow.isDestroyed()) {
const commandLine = `$ ${currentVersion.path} ${finalArgs.join(' ')}\n${'─'.repeat(60)}\n`;
setTimeout(() => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('kobold-output', commandLine);
}
}, 200);
child.stdout?.on('data', (data) => { child.stdout?.on('data', (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
const output = data.toString(); const output = data.toString();
mainWindow.webContents.send('kobold-output', output); mainWindow.webContents.send('kobold-output', output);
}
}); });
child.stderr?.on('data', (data) => { child.stderr?.on('data', (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
const output = data.toString(); const output = data.toString();
mainWindow.webContents.send('kobold-output', output); mainWindow.webContents.send('kobold-output', output);
}
}); });
child.on('exit', (code, signal) => { child.on('exit', (code, signal) => {
if (mainWindow && !mainWindow.isDestroyed()) {
const exitMessage = signal const exitMessage = signal
? `\nProcess terminated with signal ${signal}\n` ? `\nProcess terminated with signal ${signal}\n`
: `\nProcess exited with code ${code}\n`; : `\nProcess exited with code ${code}\n`;
mainWindow.webContents.send('kobold-output', exitMessage); mainWindow.webContents.send('kobold-output', exitMessage);
}
this.koboldProcess = null; this.koboldProcess = null;
}); });
child.on('error', (error) => { child.on('error', (error) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send( mainWindow.webContents.send(
'kobold-output', 'kobold-output',
`\nProcess error: ${error.message}\n` `\nProcess error: ${error.message}\n`
); );
}
this.koboldProcess = null; this.koboldProcess = null;
}); });
} }
@ -744,14 +737,27 @@ export class KoboldCppManager {
stopKoboldCpp(): void { stopKoboldCpp(): void {
if (this.koboldProcess) { if (this.koboldProcess) {
try { try {
// Try graceful termination first // For detached processes, we need to kill the process group
if (process.platform !== 'win32') {
// On Unix-like systems, kill the process group
process.kill(-this.koboldProcess.pid!, 'SIGTERM');
} else {
// On Windows, just kill the process
this.koboldProcess.kill('SIGTERM'); this.koboldProcess.kill('SIGTERM');
}
// Force kill after 5 seconds if still running
setTimeout(() => { setTimeout(() => {
if (this.koboldProcess && !this.koboldProcess.killed) { if (this.koboldProcess && !this.koboldProcess.killed) {
try {
if (process.platform !== 'win32') {
process.kill(-this.koboldProcess.pid!, 'SIGKILL');
} else {
this.koboldProcess.kill('SIGKILL'); this.koboldProcess.kill('SIGKILL');
} }
} catch (error) {
console.warn('Error force-killing KoboldCpp process:', error);
}
}
}, 5000); }, 5000);
this.koboldProcess = null; this.koboldProcess = null;
@ -762,7 +768,6 @@ export class KoboldCppManager {
} }
} }
// Method to handle app termination - ensures process cleanup
async cleanup(): Promise<void> { async cleanup(): Promise<void> {
if (this.koboldProcess) { if (this.koboldProcess) {
return new Promise((resolve) => { return new Promise((resolve) => {
@ -771,26 +776,42 @@ export class KoboldCppManager {
return; return;
} }
// Set up cleanup timeout
const cleanup = () => { const cleanup = () => {
this.koboldProcess = null; this.koboldProcess = null;
resolve(); resolve();
}; };
// Listen for process exit
this.koboldProcess.once('exit', cleanup); this.koboldProcess.once('exit', cleanup);
this.koboldProcess.once('error', cleanup); this.koboldProcess.once('error', cleanup);
// Try graceful shutdown try {
// For detached processes, we need to handle cleanup differently
if (process.platform !== 'win32') {
// On Unix-like systems, kill the process group
process.kill(-this.koboldProcess.pid!, 'SIGTERM');
} else {
// On Windows, just kill the process
this.koboldProcess.kill('SIGTERM'); this.koboldProcess.kill('SIGTERM');
}
// Force kill after 3 seconds
setTimeout(() => { setTimeout(() => {
if (this.koboldProcess && !this.koboldProcess.killed) { if (this.koboldProcess && !this.koboldProcess.killed) {
try {
if (process.platform !== 'win32') {
process.kill(-this.koboldProcess.pid!, 'SIGKILL');
} else {
this.koboldProcess.kill('SIGKILL'); this.koboldProcess.kill('SIGKILL');
} }
} catch {
// Ignore errors on force kill
}
}
cleanup(); cleanup();
}, 3000); }, 3000);
} catch (error) {
console.warn('Error during cleanup:', error);
cleanup();
}
}); });
} }
} }

View file

@ -1,4 +1,14 @@
import { BrowserWindow, app, Menu, shell, Tray, nativeImage } from 'electron'; import {
BrowserWindow,
app,
Menu,
shell,
Tray,
nativeImage,
dialog,
clipboard,
} from 'electron';
import * as os from 'os';
import { join } from 'path'; import { join } from 'path';
import { ConfigManager } from './ConfigManager'; import { ConfigManager } from './ConfigManager';
@ -16,7 +26,7 @@ export class WindowManager {
this.mainWindow = new BrowserWindow({ this.mainWindow = new BrowserWindow({
width: 1000, width: 1000,
height: 600, height: 600,
icon: join(process.cwd(), 'assets', 'icon.png'), icon: join(app.getAppPath(), 'assets', 'icon.png'),
webPreferences: { webPreferences: {
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
@ -36,10 +46,38 @@ export class WindowManager {
this.mainWindow = null; this.mainWindow = null;
}); });
// Allow navigation to localhost URLs for iframe content
this.mainWindow.webContents.on('will-navigate', (event, navigationUrl) => {
const url = new URL(navigationUrl);
// Only allow navigation to localhost or the app's origin
if (
url.hostname !== 'localhost' &&
url.hostname !== '127.0.0.1' &&
!navigationUrl.startsWith('file://')
) {
event.preventDefault();
}
});
// Handle iframe navigation permissions
this.mainWindow.webContents.setWindowOpenHandler(({ url }) => {
const parsedUrl = new URL(url);
// Allow localhost URLs to open in the same window/iframe
if (
parsedUrl.hostname === 'localhost' ||
parsedUrl.hostname === '127.0.0.1'
) {
return { action: 'allow' };
}
// For other URLs, open in external browser
shell.openExternal(url);
return { action: 'deny' };
});
this.mainWindow.on('close', async (event) => { this.mainWindow.on('close', async (event) => {
if (!this.isQuitting) { if (!this.isQuitting) {
const minimizeToTray = const minimizeToTray =
this.configManager.get('minimizeToTray') !== false; this.configManager.get('minimizeToTray') === true;
if (minimizeToTray) { if (minimizeToTray) {
event.preventDefault(); event.preventDefault();
@ -53,7 +91,7 @@ export class WindowManager {
}); });
this.mainWindow.on('minimize', () => { this.mainWindow.on('minimize', () => {
const minimizeToTray = this.configManager.get('minimizeToTray') !== false; const minimizeToTray = this.configManager.get('minimizeToTray') === true;
if (minimizeToTray) { if (minimizeToTray) {
this.mainWindow?.hide(); this.mainWindow?.hide();
@ -73,7 +111,7 @@ export class WindowManager {
} }
private createSystemTray() { private createSystemTray() {
const iconPath = join(process.cwd(), 'assets', 'icon.png'); const iconPath = join(app.getAppPath(), 'assets', 'icon.png');
this.tray = new Tray(nativeImage.createFromPath(iconPath)); this.tray = new Tray(nativeImage.createFromPath(iconPath));
this.tray.setToolTip('Friendly Kobold'); this.tray.setToolTip('Friendly Kobold');
@ -111,10 +149,17 @@ export class WindowManager {
} }
public cleanup() { public cleanup() {
this.tray?.destroy(); if (this.tray) {
this.tray.removeAllListeners();
this.tray.destroy();
this.tray = null; this.tray = null;
} }
if (this.mainWindow) {
this.mainWindow.removeAllListeners();
}
}
private setupContextMenu() { private setupContextMenu() {
if (!this.mainWindow) return; if (!this.mainWindow) return;
@ -217,6 +262,23 @@ export class WindowManager {
{ label: 'Close', accelerator: 'CmdOrCtrl+W', role: 'close' }, { label: 'Close', accelerator: 'CmdOrCtrl+W', role: 'close' },
], ],
}, },
{
label: 'Help',
submenu: [
{
label: 'About',
click: async () => {
await this.showAboutDialog();
},
},
{
label: 'KoboldCpp Wiki',
click: () => {
shell.openExternal('https://github.com/LostRuins/koboldcpp/wiki');
},
},
],
},
]; ];
const menu = Menu.buildFromTemplate( const menu = Menu.buildFromTemplate(
@ -224,4 +286,33 @@ export class WindowManager {
); );
Menu.setApplicationMenu(menu); Menu.setApplicationMenu(menu);
} }
private async showAboutDialog() {
const packagePath = join(app.getAppPath(), 'package.json');
const packageInfo = require(packagePath);
const electronVersion = process.versions.electron;
const chromeVersion = process.versions.chrome;
const nodeVersion = process.versions.node;
const v8Version = process.versions.v8;
const osInfo = `${process.platform} ${process.arch} ${os.release()}`;
const aboutText = `Version: ${packageInfo.version}
Electron: ${electronVersion}
Chromium: ${chromeVersion}
Node.js: ${nodeVersion}
V8: ${v8Version}
OS: ${osInfo}`;
const response = await dialog.showMessageBox(this.mainWindow!, {
type: 'info',
message: 'Friendly Kobold',
detail: aboutText,
buttons: ['Copy', 'OK'],
defaultId: 1,
});
if (response.response === 0) {
clipboard.writeText(aboutText);
}
}
} }

View file

@ -70,12 +70,10 @@ export class IPCHandlers {
} }
}); });
ipcMain.handle('kobold:getInstalledVersions', () => ipcMain.handle(
this.koboldManager.getInstalledVersions() 'kobold:getInstalledVersions',
); (_, includeVersions?: boolean) =>
this.koboldManager.getInstalledVersions(includeVersions)
ipcMain.handle('kobold:isInstalled', () =>
this.koboldManager.isInstalled()
); );
ipcMain.handle('kobold:getConfigFiles', () => ipcMain.handle('kobold:getConfigFiles', () =>
@ -160,15 +158,15 @@ export class IPCHandlers {
const result = await dialog.showMessageBox(mainWindow, { const result = await dialog.showMessageBox(mainWindow, {
type: 'warning', type: 'warning',
title: 'Confirm Eject', title: 'Confirm Eject',
message: 'Are you sure you want to stop KoboldCpp?', message: 'Are you sure you want to eject KoboldCpp?',
detail: detail:
'This will terminate the running process and return to the launch screen.', 'This will terminate the running process and return to the launch screen.',
buttons: ['Cancel', 'Stop KoboldCpp'], buttons: ['Cancel', 'Eject'],
defaultId: 0, defaultId: 0,
cancelId: 0, cancelId: 0,
}); });
return result.response === 1; // Returns true if user clicked "Stop KoboldCpp" return result.response === 1;
}); });
ipcMain.handle('kobold:parseConfigFile', (_event, filePath) => ipcMain.handle('kobold:parseConfigFile', (_event, filePath) =>

View file

@ -7,9 +7,9 @@ import type {
} from '@/types/electron'; } from '@/types/electron';
const koboldAPI: KoboldAPI = { const koboldAPI: KoboldAPI = {
isInstalled: () => ipcRenderer.invoke('kobold:isInstalled'),
getInstalledVersion: () => ipcRenderer.invoke('kobold:getInstalledVersion'), getInstalledVersion: () => ipcRenderer.invoke('kobold:getInstalledVersion'),
getInstalledVersions: () => ipcRenderer.invoke('kobold:getInstalledVersions'), getInstalledVersions: (includeVersions?: boolean) =>
ipcRenderer.invoke('kobold:getInstalledVersions', includeVersions),
getCurrentVersion: () => ipcRenderer.invoke('kobold:getCurrentVersion'), getCurrentVersion: () => ipcRenderer.invoke('kobold:getCurrentVersion'),
setCurrentVersion: (version: string) => setCurrentVersion: (version: string) =>
ipcRenderer.invoke('kobold:setCurrentVersion', version), ipcRenderer.invoke('kobold:setCurrentVersion', version),

View file

@ -1,6 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Card, Text, Title, Loader, Stack, Container } from '@mantine/core'; import { Card, Text, Title, Loader, Stack, Container } from '@mantine/core';
import { useNotifications } from '@/hooks/useNotifications';
import { DownloadOptionCard } from '@/components/DownloadOptionCard'; import { DownloadOptionCard } from '@/components/DownloadOptionCard';
import { import {
getPlatformDisplayName, getPlatformDisplayName,
@ -20,7 +19,6 @@ interface DownloadScreenProps {
} }
export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => { export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
const notify = useNotifications();
const [latestRelease, setLatestRelease] = useState<GitHubRelease | null>( const [latestRelease, setLatestRelease] = useState<GitHubRelease | null>(
null null
); );
@ -74,18 +72,16 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
); );
setFilteredAssets(filtered); setFilteredAssets(filtered);
} else { } else {
notify.error( console.error(
'Error',
'GitHub API is currently unavailable. Please try again later.' 'GitHub API is currently unavailable. Please try again later.'
); );
} }
} catch (err) { } catch (err) {
notify.error('Error', 'Failed to load release information'); console.error('Failed to load release information:', err);
console.error('Error loading release:', err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [notify]); }, []);
useEffect(() => { useEffect(() => {
loadLatestReleaseAndPlatform(); loadLatestReleaseAndPlatform();
@ -97,7 +93,8 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
return () => { return () => {
window.electronAPI.kobold.removeAllListeners('download-progress'); window.electronAPI.kobold.removeAllListeners('download-progress');
}; };
}, [loadLatestReleaseAndPlatform]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleDownload = async (type: 'asset' | 'rocm' = 'asset') => { const handleDownload = async (type: 'asset' | 'rocm' = 'asset') => {
if (type === 'asset' && !selectedAsset) return; if (type === 'asset' && !selectedAsset) return;
@ -115,16 +112,12 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
if (result.success) { if (result.success) {
onDownloadComplete(); onDownloadComplete();
} else { } else {
notify.error( console.error(
'Download Failed', 'Download Failed',
result.error || `${type === 'rocm' ? 'ROCm' : ''} Download failed` result.error || `${type === 'rocm' ? 'ROCm' : ''} Download failed`
); );
} }
} catch (err) { } catch (err) {
notify.error(
'Download Failed',
`${type === 'rocm' ? 'ROCm' : ''} Download failed`
);
console.error(`${type === 'rocm' ? 'ROCm' : ''} Download error:`, err); console.error(`${type === 'rocm' ? 'ROCm' : ''} Download error:`, err);
} finally { } finally {
setDownloading(false); setDownloading(false);
@ -160,7 +153,7 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
}; };
return ( return (
<Container size="sm" py="xl"> <Container size="sm">
<Stack gap="xl"> <Stack gap="xl">
<Card withBorder radius="md" shadow="sm"> <Card withBorder radius="md" shadow="sm">
<Stack gap="lg"> <Stack gap="lg">

View file

@ -0,0 +1,86 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Container } from '@mantine/core';
import { ChatTab } from '@/components/interface/ChatTab';
import { TerminalTab } from '@/components/interface/TerminalTab';
interface InterfaceScreenProps {
activeTab?: string | null;
onTabChange?: (tab: string | null) => void;
}
export const InterfaceScreen = ({
activeTab,
onTabChange,
}: InterfaceScreenProps) => {
const [serverOnly, setServerOnly] = useState<boolean>(false);
const [serverUrl, setServerUrl] = useState<string>('');
const [isServerReady, setIsServerReady] = useState<boolean>(false);
const handleServerReady = useCallback(
(url: string) => {
setServerUrl(url);
setIsServerReady(true);
if (!serverOnly && onTabChange) {
onTabChange('chat');
}
},
[serverOnly, onTabChange]
);
useEffect(() => {
const loadServerOnlySetting = async () => {
try {
const serverOnlyValue = await window.electronAPI.config.getServerOnly();
setServerOnly(serverOnlyValue);
if (serverOnlyValue && onTabChange) {
onTabChange('terminal');
}
} catch (error) {
console.error('Failed to load server-only setting:', error);
}
};
loadServerOnlySetting();
}, [onTabChange]);
return (
<Container size="l" style={{ height: '85vh' }}>
<Card
withBorder
radius="md"
p="0"
style={{ height: 'calc(90vh - 32px)' }}
>
{serverOnly ? (
<TerminalTab
onServerReady={handleServerReady}
serverOnly={serverOnly}
/>
) : (
<div style={{ height: '100%' }}>
<div
style={{
height: '100%',
display: activeTab === 'chat' ? 'block' : 'none',
}}
>
<ChatTab serverUrl={serverUrl} isServerReady={isServerReady} />
</div>
<div
style={{
display: activeTab === 'terminal' ? 'block' : 'none',
}}
>
<TerminalTab
onServerReady={handleServerReady}
serverOnly={false}
/>
</div>
</div>
)}
</Card>
</Container>
);
};

View file

@ -1,23 +1,20 @@
import { import {
Button,
Card, Card,
Text,
Title,
Container, Container,
Stack, Stack,
Tabs,
Text,
Title,
Group, Group,
ActionIcon, Button,
Switch,
Slider,
TextInput,
NumberInput,
Tooltip,
Checkbox,
} from '@mantine/core'; } from '@mantine/core';
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 { useLaunchConfig } from '@/hooks/useLaunchConfig';
import { ConfigurationManager } from '@/components/launch/ConfigurationManager';
import { GeneralTab } from '@/components/launch/GeneralTab';
import { AdvancedTab } from '@/components/launch/AdvancedTab';
import { NetworkTab } from '@/components/launch/NetworkTab';
import { SaveConfigModal } from '@/components/launch/SaveConfigModal';
import type { ConfigFile } from '@/types'; import type { ConfigFile } from '@/types';
interface LaunchScreenProps { interface LaunchScreenProps {
@ -28,6 +25,11 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
const [configFiles, setConfigFiles] = useState<ConfigFile[]>([]); const [configFiles, setConfigFiles] = useState<ConfigFile[]>([]);
const [selectedFile, setSelectedFile] = useState<string | null>(null); const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [, setInstallDir] = useState<string>(''); const [, setInstallDir] = useState<string>('');
const [isLaunching, setIsLaunching] = useState(false);
const [activeTab, setActiveTab] = useState<string | null>('general');
const [saveModalOpened, setSaveModalOpened] = useState(false);
const [newConfigName, setNewConfigName] = useState('');
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const { const {
serverOnly, serverOnly,
gpuLayers, gpuLayers,
@ -35,6 +37,8 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
contextSize, contextSize,
modelPath, modelPath,
additionalArguments, additionalArguments,
port,
host,
parseAndApplyConfigFile, parseAndApplyConfigFile,
loadSavedSettings, loadSavedSettings,
loadConfigFromFile, loadConfigFromFile,
@ -45,6 +49,8 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
handleModelPathChange, handleModelPathChange,
handleSelectModelFile, handleSelectModelFile,
handleAdditionalArgumentsChange, handleAdditionalArgumentsChange,
handlePortChange,
handleHostChange,
} = useLaunchConfig(); } = useLaunchConfig();
const loadConfigFiles = useCallback(async () => { const loadConfigFiles = useCallback(async () => {
@ -79,6 +85,50 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
if (selectedConfig) { if (selectedConfig) {
await parseAndApplyConfigFile(selectedConfig.path); await parseAndApplyConfigFile(selectedConfig.path);
} }
// Reset unsaved changes when loading a new config
setHasUnsavedChanges(false);
};
// Wrapper functions to track changes
const handleModelPathChangeWithTracking = (path: string) => {
handleModelPathChange(path);
setHasUnsavedChanges(true);
};
const handleGpuLayersChangeWithTracking = (layers: number) => {
handleGpuLayersChange(layers);
setHasUnsavedChanges(true);
};
const handleAutoGpuLayersChangeWithTracking = (auto: boolean) => {
handleAutoGpuLayersChange(auto);
setHasUnsavedChanges(true);
};
const handleContextSizeChangeWithTracking = (size: number) => {
handleContextSizeChangeWithStep(size);
setHasUnsavedChanges(true);
};
const handleAdditionalArgumentsChangeWithTracking = (args: string) => {
handleAdditionalArgumentsChange(args);
setHasUnsavedChanges(true);
};
const handleServerOnlyChangeWithTracking = (serverOnly: boolean) => {
handleServerOnlyChange(serverOnly);
setHasUnsavedChanges(true);
};
const handlePortChangeWithTracking = (port: number) => {
handlePortChange(port);
setHasUnsavedChanges(true);
};
const handleHostChangeWithTracking = (host: string) => {
handleHostChange(host);
setHasUnsavedChanges(true);
}; };
useEffect(() => { useEffect(() => {
@ -96,6 +146,12 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
}, [loadConfigFiles]); }, [loadConfigFiles]);
const handleLaunch = async () => { const handleLaunch = async () => {
if (isLaunching || !modelPath) {
return;
}
setIsLaunching(true);
try { try {
const selectedConfig = selectedFile const selectedConfig = selectedFile
? configFiles.find((f) => f.name === selectedFile) ? configFiles.find((f) => f.name === selectedFile)
@ -103,9 +159,7 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
const args: string[] = []; const args: string[] = [];
if (modelPath) {
args.push('--model', modelPath); args.push('--model', modelPath);
}
if (autoGpuLayers) { if (autoGpuLayers) {
args.push('--gpulayers', '-1'); args.push('--gpulayers', '-1');
@ -117,6 +171,14 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
args.push('--contextsize', contextSize.toString()); args.push('--contextsize', contextSize.toString());
} }
if (port !== 5001) {
args.push('--port', port.toString());
}
if (host !== 'localhost') {
args.push('--host', host);
}
if (additionalArguments.trim()) { if (additionalArguments.trim()) {
const additionalArgs = additionalArguments.trim().split(/\s+/); const additionalArgs = additionalArguments.trim().split(/\s+/);
args.push(...additionalArgs); args.push(...additionalArgs);
@ -128,230 +190,136 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
); );
if (result.success) { if (result.success) {
setTimeout(() => {
onLaunch(); onLaunch();
}, 100);
} else { } else {
console.error('Launch failed:', result.error); console.error('Launch failed:', result.error);
} }
} catch (error) { } catch (error) {
console.error('Error launching KoboldCpp:', error); console.error('Error launching KoboldCpp:', error);
} finally {
setIsLaunching(false);
} }
}; };
return ( return (
<Container size="sm" py="xl"> <Container size="sm">
<Card withBorder radius="md" shadow="sm">
<Stack gap="lg"> <Stack gap="lg">
<Card withBorder radius="md" shadow="sm">
<Group justify="space-between" align="center">
<div>
<Title order={3}>Launch Configuration</Title> <Title order={3}>Launch Configuration</Title>
<Text size="sm" c="dimmed">
<Card withBorder radius="md" w="100%"> {selectedFile
<Group justify="space-between" mb="md"> ? `Using: ${selectedFile}`
<Text fw={500}>Select Configuration</Text> : 'No configuration file selected'}
<ActionIcon variant="light" onClick={loadConfigFiles} size="sm"> {hasUnsavedChanges && (
<RotateCcw size={16} /> <Text span c="orange">
</ActionIcon> {' '}
Unsaved changes
</Text>
)}
</Text>
</div>
<Button
radius="md"
disabled={!modelPath || isLaunching}
onClick={handleLaunch}
loading={isLaunching}
size="lg"
variant="filled"
>
{isLaunching
? 'Launching...'
: modelPath
? 'Launch KoboldCpp'
: 'Select a model file to launch'}
</Button>
</Group> </Group>
</Card>
<ConfigFileSelect <Card withBorder radius="md">
<ConfigurationManager
configFiles={configFiles} configFiles={configFiles}
selectedFile={selectedFile} selectedFile={selectedFile}
onFileSelection={handleFileSelection} onFileSelection={handleFileSelection}
onRefresh={loadConfigFiles}
onSaveAsNew={() => setSaveModalOpened(true)}
onUpdateCurrent={() => {
// TODO: Implement update current configuration
}}
/> />
</Card> </Card>
<Card withBorder radius="md" w="100%"> <Card withBorder radius="md">
<Text fw={500} mb="md"> <Tabs value={activeTab} onChange={setActiveTab}>
Launch Settings <Tabs.List>
</Text> <Tabs.Tab value="general">General</Tabs.Tab>
<Tabs.Tab value="advanced">Advanced</Tabs.Tab>
<Tabs.Tab value="network">Network</Tabs.Tab>
<Tabs.Tab value="image" disabled>
Image Generation
</Tabs.Tab>
</Tabs.List>
<Stack gap="l"> <Tabs.Panel value="general" pt="md">
<div> <GeneralTab
<Text size="sm" fw={500} mb="xs"> modelPath={modelPath}
Model File gpuLayers={gpuLayers}
</Text> autoGpuLayers={autoGpuLayers}
<Group gap="xs"> contextSize={contextSize}
<TextInput onModelPathChange={handleModelPathChangeWithTracking}
placeholder="Select a .gguf model file" onSelectModelFile={handleSelectModelFile}
value={modelPath} onGpuLayersChange={handleGpuLayersChangeWithTracking}
onChange={(event) => onAutoGpuLayersChange={handleAutoGpuLayersChangeWithTracking}
handleModelPathChange(event.currentTarget.value) onContextSizeChange={handleContextSizeChangeWithTracking}
}
style={{ flex: 1 }}
/> />
<Button </Tabs.Panel>
onClick={handleSelectModelFile}
variant="light"
leftSection={<File size={16} />}
>
Browse
</Button>
</Group>
</div>
<div> <Tabs.Panel value="advanced" pt="md">
<Group justify="space-between" align="center" mb="xs"> <AdvancedTab
<Group gap="xs" align="center"> additionalArguments={additionalArguments}
<Text size="sm" fw={500}> serverOnly={serverOnly}
GPU Layers onAdditionalArgumentsChange={
</Text> handleAdditionalArgumentsChangeWithTracking
<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" onServerOnlyChange={handleServerOnlyChangeWithTracking}
/> />
<Tooltip </Tabs.Panel>
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> <Tabs.Panel value="network" pt="md">
<Group justify="space-between" align="center" mb="xs"> <NetworkTab
<Group gap="xs" align="center"> port={port}
<Text size="sm" fw={500}> host={host}
Context Size onPortChange={handlePortChangeWithTracking}
</Text> onHostChange={handleHostChangeWithTracking}
<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> </Tabs.Panel>
<Slider
value={contextSize}
min={256}
max={131072}
step={1}
onChange={handleContextSizeChangeWithStep}
/>
</div>
<div> <Tabs.Panel value="image" pt="md">
<Group gap="xs" align="center" mb="xs"> <Stack gap="lg" align="center" py="xl">
<Text size="sm" fw={500}> <Text c="dimmed" ta="center">
Additional arguments Image generation configuration will be available in a future
update.
</Text> </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> </Stack>
</Tabs.Panel>
</Tabs>
</Card> </Card>
<Group gap="md" justify="center"> <SaveConfigModal
<Button opened={saveModalOpened}
radius="md" onClose={() => setSaveModalOpened(false)}
disabled={!selectedFile} configName={newConfigName}
onClick={handleLaunch} onConfigNameChange={setNewConfigName}
size="lg" onSave={() => {
> // TODO: Implement save configuration
{selectedFile ? 'Launch' : 'Select a configuration file'} setSaveModalOpened(false);
</Button> setNewConfigName('');
</Group> }}
/>
</Stack> </Stack>
</Card>
</Container> </Container>
); );
}; };

View file

@ -1,117 +0,0 @@
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(() => {
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

@ -44,9 +44,10 @@ interface ROCmDownload {
} }
export interface KoboldAPI { export interface KoboldAPI {
isInstalled: () => Promise<boolean>;
getInstalledVersion: () => Promise<string | undefined>; getInstalledVersion: () => Promise<string | undefined>;
getInstalledVersions: () => Promise<InstalledVersion[]>; getInstalledVersions: (
includeVersions?: boolean
) => Promise<InstalledVersion[]>;
getCurrentVersion: () => Promise<InstalledVersion | null>; getCurrentVersion: () => Promise<InstalledVersion | null>;
setCurrentVersion: (version: string) => Promise<boolean>; setCurrentVersion: (version: string) => Promise<boolean>;
getVersionFromBinary: (binaryPath: string) => Promise<string | null>; getVersionFromBinary: (binaryPath: string) => Promise<string | null>;