Compare commits

...

28 commits

Author SHA1 Message Date
d8d896beaa chore: restore github badge, remove stars badge 2026-05-05 17:58:22 -07:00
b85baf75f5 new release 2026-05-05 16:52:58 -07:00
aa4d00b736 fix: reduce Windows hardware detection hangs
- Replace siCpu() with synchronous os.cpus() — no PowerShell needed
- Replace siMem() with os.totalmem() in detectSystemMemory
- Add 3s timeout to siMemLayout() and siGraphics() so they don't
  block indefinitely behind monitoring's PowerShell queue
- Slow memory polling to 3s on Windows (was 1s) to free up the
  shared PowerShell session for hardware detection queries
- Split SystemTab loading: render version info immediately,
  hardware info fills in as background tasks complete
2026-05-05 16:38:54 -07:00
76a06f0c0a WIP: fixes from windows testing 2026-05-05 15:19:14 -07:00
d163b9bd67 clean up all flatpack changes 2026-05-05 13:52:17 -07:00
343f9810d7 flatpak: update manifest for Flathub reviewer feedback
- reduce sandbox permissions: --device=dri, --filesystem=home
- remove --socket=pulseaudio and --talk-name=org.freedesktop.Flatpak
- add aarch64 support for uv binary
- bundle .desktop and .metainfo.xml in AppImage (extraFiles)
- update manifest to install from squashfs paths
- trim release history to 5 entries in metainfo.xml
- bump to v1.24.1
2026-05-05 13:06:17 -07:00
b9b2069fd4 feat: detect Flatpak installation and display in system info
- Add getFlatpakAppId/isFlatpakInstallation to dependencies.ts (reads FLATPAK_ID env)
- Include flatpakAppId in getVersionInfo and SystemVersionInfo type
- Show '(Flatpak)' suffix in software version display, matching AUR pattern
- Disable auto-update for Flatpak installs (managed by flatpak update)
- Add local test manifest (app.lonecloud.gerbil.yml) and fp:dev script
2026-05-05 12:23:33 -07:00
249eb07f26 flatpak: rename to dev.lonecloud.gerbil, polish metainfo, bump uv to 0.11.9 2026-05-05 10:38:30 -07:00
84ed1d3655 flatpak: prepare Flathub submission
- Switch to zypak-wrapper, remove --no-sandbox and --allow=devel
- Fix GPU crash: strip host libs from LD_LIBRARY_PATH, keep only ROCm
- Use --filesystem=host instead of granular filesystem permissions
- Bundle uv 0.11.8 (process.env.PATH in Electron doesn't see wrapper PATH)
- Add complete releases block (1.0.0 through 1.24.0)
- Fix OARS content rating (empty = no objectionable content)
2026-05-05 00:06:00 -07:00
github-actions[bot]
064361d11f chore: update flatpak manifest for v1.24.0 2026-05-04 20:14:00 +00:00
75ba344fd7 fix(rocm): augment PATH for rocminfo/hipcc, add Vulkan fallback detection
Electron main process doesn't inherit shell PATH, so bare rocminfo/hipcc
calls failed. Add ROCM_EXEC_OPTIONS that prepends /opt/rocm/bin to PATH.

Add Vulkan fallback in detectROCm: when rocminfo returns empty (e.g. inside
Flatpak sandbox where KFD ioctls fail) but hipcc --version succeeds, fall
back to AMD GPUs sourced from Vulkan detection.

Refactor driver version extraction into getDriverVersion() helper.
Remove redundant hipccCommand ternary (both branches were identical).

fix(flatpak): pull AppImage from GitHub releases instead of local path
2026-05-03 12:12:29 -07:00
6bab3cbf0b chore(flatpak): update AppImage path to 1.24.0 2026-05-02 23:59:39 -07:00
03db414867 feat: launcher improvements, flatpak GPU fix, proxy host header, openwebui openssl, ui cleanup, electron bump 2026-05-02 23:53:43 -07:00
28f8cf68c3 preparing for flatpak builds, minor UI touch-ups, dep upgrades 2026-04-29 01:34:49 -07:00
9ec73381df make the core content slider narrower to reduce empty space, more consistent kcpp update download functionality 2026-04-25 08:45:25 -07:00
6146e9cb95 feat: a11y, keyboard nav, analysis caching, launch shortcut, visual polish 2026-04-24 22:48:11 -07:00
e96cd94cb5 fix: escape href in terminal linkifier, use app.isPackaged for isDevelopment 2026-04-24 22:48:04 -07:00
de61e6a3fb expand copilot context, dep upgrades 2026-04-21 17:07:43 -07:00
3181f80e60 updates for latest kcpp release 2026-04-20 16:32:10 -07:00
0929e30f82 update readme, deps and instructions 2026-04-18 22:24:09 -07:00
f568194ba7 retry new kcpp version checks if internet is unavailable, better logging for audio issues 2026-04-18 13:24:06 -07:00
204e203d5f minor readme updates, dep upgrades 2026-04-17 19:02:24 -07:00
6356387e89 dont animate Switch 2026-04-12 20:13:31 -07:00
f5f78b62fe UI updates based on impeccable passes 2026-04-12 15:35:33 -07:00
6f9fe85068 update deps 2026-04-10 18:31:54 -07:00
871d3cfcd1 upgrade oxc and fix new lint issues 2026-04-06 23:01:56 -07:00
bd67ef4d82 upgrade to latest mantine, improve auto GPU layer detection 2026-04-06 09:28:29 -07:00
ec44a78457 add jinja UI controls, smooth download ETA, filter new kcpp spam, fix right-click in popups 2026-04-05 22:18:28 -07:00
79 changed files with 2439 additions and 2108 deletions

View file

@ -1,7 +1,230 @@
# Copilot Instructions for Gerbil # Copilot Instructions for Gerbil
- **NEVER use console.\* calls** - they are blocked by ESLint. Use `logError()` from `@/utils/node/logging` (main process) or `window.electronAPI.logs.logError()` (renderer) ## Hard Rules
- Always use absolute imports: `import { X } from '@/components/X'`
- Never add explicit return types to functions - rely on TypeScript inference - **NEVER use `console.*`** — blocked by oxlint. Use `logError()` from `@/utils/node/logging` (main process) or `window.electronAPI.logs.logError()` (renderer)
- Never create tests, docs, or GitHub workflows - **Always use absolute imports**: `import { X } from '@/components/X'`
- Move helper functions out of component files into separate utility files - **Never add explicit return types** — rely on TypeScript inference
- **Never create tests, docs, or GitHub workflows**
- **Never add comments** — code should be self-explanatory; no inline comments, no block comments
- **Move helper functions** out of component files into `src/utils/`
## What Gerbil Is
Gerbil is an Electron desktop app that acts as a launcher and GUI for [KoboldCpp](https://github.com/LostRuins/koboldcpp). It is **not** a new LLM backend — it wraps KoboldCpp and makes it usable without touching the terminal.
**The problem it solves**: KoboldCpp is an excellent all-in-one local LLM backend (text gen, image gen, multimodal, agents) but its own launcher UI is bad. Gerbil replaces and significantly improves that launcher.
**Gerbil vs KoboldCpp's launcher**: Gerbil adds auto binary download, GPU auto-detection (CUDA/ROCm/Vulkan/Metal), image gen presets (FLUX, Chroma, Z-Image, Qwen), HuggingFace model search/download, SillyTavern and OpenWebUI auto-launch, config save/load, real-time system monitoring, Cloudflare tunnel support, and a proper desktop experience.
## User Base
- People who want to run LLMs locally with real control over the backend
- SillyTavern users (roleplay/character AI) — Gerbil auto-launches ST alongside KoboldCpp
- Image generation users — Gerbil has first-class image gen with 4 presets
- Power users who want GPU acceleration configured correctly without guesswork
## Stack
Electron + React + Zustand + Mantine + TypeScript + pnpm + oxlint. No test framework.
## Validation
Always run after making changes:
```sh
pnpm check # oxlint + oxfmt (lint + format check)
```
Fix lint/format issues with `pnpm fix`. No test suite exists.
## Gerbil's Role: KoboldCpp Orchestrator
Gerbil is a manager and orchestrator for KoboldCpp. It doesn't implement LLM inference — it configures, launches, monitors, and wraps KoboldCpp. KoboldCpp releases monthly updates that frequently add new CLI flags and capabilities.
**How KoboldCpp flags surface in Gerbil:**
1. **Promoted to UI** — High-value flags get a proper control (checkbox, slider, file picker) in the Launch screen tabs. These live in `launchConfig` store and are passed as CLI args to KoboldCpp at launch. They must be added to `UI_COVERED_ARGS` in `CommandLineArgumentsModal.tsx` so they're not duplicated in the modal.
2. **Available in the arguments modal** — Everything else is accessible via `CommandLineArgumentsModal` (`src/components/screens/Launch/CommandLineArgumentsModal.tsx`). This modal contains a hardcoded `COMMAND_LINE_ARGUMENTS` array with every KoboldCpp flag, its description, type, category, and aliases. Users can search and add any flag to the "Additional Arguments" field. These are stored as `additionalArguments` in `KoboldConfig`.
**When KoboldCpp adds new flags:** Add entries to `COMMAND_LINE_ARGUMENTS` in `CommandLineArgumentsModal.tsx`. If a flag deserves first-class UI treatment, also wire it into the launch config store, the relevant Launch tab, and add it to `UI_COVERED_ARGS`.
**`UI_COVERED_ARGS`** is a `Set<string>` at the top of `CommandLineArgumentsModal.tsx` — it lists all flags already exposed by the UI so they're filtered out of the modal. Always keep this in sync when promoting a flag to the UI.
## App Structure
**Screen flow**: Welcome → Download → Launch (tabs: General/Performance/Advanced/Image Gen/Network/Config) → Interface (tabs: Terminal/Chat-Text/Chat-Image)
**Supported GPUs**: CUDA, ROCm (via YellowRoseCx fork), Vulkan, Metal (macOS), CPU fallback
**Frontends**: KoboldAI Lite, llama.cpp (embedded in KoboldCpp), SillyTavern (localhost:3000, needs Node.js), OpenWebUI (localhost:8080, needs uv)
**Image gen presets**: FLUX.1-dev, Chroma-unlocked, Z-Image-Turbo, Qwen2.5-VL-7B (image edit)
**CLI mode**: headless binary execution — requires prior GUI setup to configure binary path
## Source Layout
```
src/
├── main/ # Electron main process (Node.js)
│ ├── index.ts # Entry: routes to CLI or GUI mode
│ ├── gui.ts # GUI init: window, tray, IPC, lifecycle
│ ├── cli.ts # Headless mode: spawn binary, pipe stdio
│ ├── ipc.ts # All ipcMain.handle/on registrations
│ └── modules/ # Feature domains
│ ├── config.ts # Settings file (~/.config/Gerbil/config.json)
│ ├── hardware.ts # GPU/CPU detection
│ ├── monitoring.ts # Real-time CPU/GPU/RAM metrics
│ ├── tray.ts # System tray icon & menu
│ ├── window.ts # Main window creation & lifecycle
│ ├── auto-updater.ts # Electron auto-updater
│ ├── dependencies.ts # Check npm/uv/npx availability
│ ├── sillytavern.ts # Auto-launch SillyTavern
│ ├── openwebui.ts # Auto-launch OpenWebUI
│ └── koboldcpp/ # KoboldCpp-specific
│ ├── acceleration.ts # GPU acceleration detection
│ ├── analyze.ts # GGUF model analysis
│ ├── backend.ts # Installed binary management
│ ├── config.ts # Config file save/load
│ ├── download.ts # Binary download from GitHub
│ ├── launcher/ # Spawn KoboldCpp + frontends
│ ├── model-download.ts # Local model file detection
│ ├── proxy.ts # Reverse proxy for KoboldCpp
│ └── tunnel.ts # Cloudflare tunnel management
├── preload/
│ └── index.ts # contextBridge: exposes electronAPI to renderer
├── components/
│ ├── App/ # App shell: routing, titlebar, statusbar, modals
│ ├── screens/ # Full-screen views (Welcome, Download, Launch, Interface)
│ ├── settings/ # Settings modal tabs (General, Appearance, Backends, etc.)
│ ├── Notepad/ # Floating notepad widget
│ └── *.tsx # Reusable Mantine wrappers (Select, Modal, Switch, etc.)
├── stores/ # Zustand state
│ ├── launchConfig.ts # KoboldCpp launch parameters (model, GPU layers, flags)
│ ├── preferences.ts # UI prefs (theme, frontend choice, monitoring)
│ ├── koboldBackends.ts # Download state + installed backend versions
│ └── notepad.ts # Notepad state (tabs, content, position)
├── hooks/ # Custom React hooks
├── utils/
│ ├── *.ts # Renderer-safe utilities (format, platform, version, etc.)
│ └── node/ # Main-process-only utilities (fs, gpu, vram, logging, etc.)
├── types/
│ ├── index.d.ts # Core types: Screen, InterfaceTab, Acceleration, ModelParamType, etc.
│ ├── electron.d.ts # window.electronAPI interface (all renderer-accessible APIs)
│ ├── ipc.d.ts # IPC channel names + payload types
│ └── hardware.d.ts # GPU/CPU type definitions
└── constants/
├── index.ts # App-wide constants: URLs, dimensions, defaults, signals
├── imageModelPresets.ts # Image gen presets (FLUX, Chroma, Z-Image, Qwen)
└── notepad.ts # Notepad defaults
```
## IPC Pattern
Main ↔ renderer communicate through a typed bridge. **Never call Node APIs directly from renderer.**
**Define** the channel in `src/types/ipc.d.ts`:
```typescript
export type IPCChannel = 'my-channel' | ...
export interface IPCChannelPayloads { 'my-channel': [arg: string] }
```
**Handle** in `src/main/ipc.ts`:
```typescript
ipcMain.handle('domain:action', async (_event, arg: string) => { ... })
ipcMain.on('domain:action', (_event, arg: string) => { ... }) // fire-and-forget
```
**Expose** in `src/preload/index.ts` via `contextBridge.exposeInMainWorld('electronAPI', { ... })`:
```typescript
myDomain: {
doThing: (arg: string) => ipcRenderer.invoke('domain:action', arg),
onEvent: (cb: (data: string) => void) => {
ipcRenderer.on('my-channel', (_e, data) => cb(data))
return () => ipcRenderer.removeAllListeners('my-channel') // cleanup
}
}
```
**Type** it in `src/types/electron.d.ts` so renderer gets full type-checking on `window.electronAPI`.
**Use** in renderer:
```typescript
const result = await window.electronAPI.myDomain.doThing('arg')
const cleanup = window.electronAPI.myDomain.onEvent((data) => { ... })
useEffect(() => cleanup, [])
```
Channel naming: `domain:action` (e.g., `kobold:launchKoboldCpp`, `config:set`).
## Zustand Store Pattern
```typescript
// src/stores/myStore.ts
interface MyStore {
value: string;
setValue: (v: string) => void;
loadFromConfig: () => Promise<void>; // if persisted
}
export const useMyStore = create<MyStore>((set) => ({
value: '',
setValue: (v) => {
set({ value: v });
window.electronAPI.config.set('myKey', v); // persist if needed
},
loadFromConfig: async () => {
const saved = await window.electronAPI.config.get('myKey');
if (saved) set({ value: saved });
},
}));
```
Stores load persisted state on init: `void useMyStore.getState().loadFromConfig()` called in `App/index.tsx`.
## Component Conventions
- Reusable components live in `src/components/` and wrap Mantine with sensible defaults (see `Select.tsx`, `Modal.tsx`, `Switch.tsx`)
- Screen components live in `src/components/screens/`
- Settings tabs live in `src/components/settings/`
- All imports from Mantine: `import { Button } from '@mantine/core'`
## Key Types to Know
```typescript
type Screen = 'welcome' | 'download' | 'launch' | 'interface'
type InterfaceTab = 'terminal' | 'chat-text' | 'chat-image'
type Acceleration = 'cpu' | 'cuda' | 'rocm' | 'vulkan' | ...
type ModelParamType = 'model' | 'sdmodel' | 'sdt5xxl' | 'sdvae' | ...
type FrontendPreference = 'koboldcpp' | 'llamacpp' | 'sillytavern' | 'openwebui'
```
## Key Constants (`src/constants/index.ts`)
- `SERVER_READY_SIGNALS` — strings that signal KoboldCpp/SillyTavern/OpenWebUI are ready
- `SILLYTAVERN`, `OPENWEBUI` — host/port/URL constants
- `GITHUB_API` — KoboldCpp release API URLs
- `DEFAULT_CONTEXT_SIZE`, `DEFAULT_AUTO_GPU_LAYERS` — launch defaults
## Utilities Reference
| File | What it exports |
| ----------------------- | --------------------------------------------------------------------------------- |
| `utils/format.ts` | `formatBytes`, `formatDeviceName`, `formatDate` |
| `utils/platform.ts` | `filterAssetsByPlatform`, `isAssetCompatibleWithPlatform` |
| `utils/version.ts` | `compareVersions`, `getDisplayNameFromPath` |
| `utils/validation.ts` | `getInputValidationState` (path validation) |
| `utils/terminal.ts` | `handleTerminalOutput`, `processTerminalContent` (ANSI handling) |
| `utils/interface.ts` | `getDefaultInterfaceTab`, `getAvailableInterfaceOptions`, `getTunnelInterfaceUrl` |
| `utils/logger.ts` | `logError`, `withRetry`, `safeExecute` |
| `utils/node/logging.ts` | Main process `logError` |
| `utils/node/fs.ts` | `readJsonFile`, `ensureDir`, `pathExists` |
| `utils/node/gpu.ts` | GPU capability detection |
| `utils/node/vram.ts` | `calculateOptimalGpuLayers` |
| `utils/node/path.ts` | `getConfigDir`, `openUrl` |

2
.gitignore vendored
View file

@ -3,6 +3,8 @@ dist/
dist-electron/ dist-electron/
out/ out/
release/ release/
.agents/
.impeccable.md
*.log *.log
.DS_Store .DS_Store
Thumbs.db Thumbs.db

2
.nvmrc
View file

@ -1 +1 @@
24.14.1 24.15.0

View file

@ -4,21 +4,24 @@
# Gerbil # Gerbil
**The simplest way to run Large Language Models on your own hardware** **Local text and image generation, on your own hardware**
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)](https://github.com/lone-cloud/gerbil/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)](https://github.com/lone-cloud/gerbil/releases)
[![GitHub stars](https://img.shields.io/github/stars/lone-cloud/gerbil)](https://github.com/lone-cloud/gerbil/stargazers)
[![AUR version](https://img.shields.io/aur/version/gerbil)](https://aur.archlinux.org/packages/gerbil) [![AUR version](https://img.shields.io/aur/version/gerbil)](https://aur.archlinux.org/packages/gerbil)
[Download](https://github.com/lone-cloud/gerbil/releases/latest) • [Features](#features) • [Screenshots](#demo--screenshots) • [Installation](#installation) <div style="display: flex; flex-wrap: wrap; justify-content: center; gap: 12px;">
<a href="https://github.com/lone-cloud/gerbil/releases"><img src="assets/badges/github-badge.png" alt="Get it on GitHub" height="50" /></a>
</div>
<br>
[Download](https://github.com/lone-cloud/gerbil/releases/latest) • [Screenshots](#demo--screenshots) • [Installation](#installation)
</div> </div>
<!-- markdownlint-enable MD033 --> <!-- markdownlint-enable MD033 -->
Gerbil provides a graphical interface for running Large Language Models locally. It handles the technical complexity of managing backends, model downloads, and hardware acceleration - letting you focus on using AI rather than configuring it.
## Features ## Features
- **Run LLMs locally** - Powered by [KoboldCpp](https://github.com/LostRuins/koboldcpp), a fork of [llama.cpp](https://github.com/ggml-org/llama.cpp) - **Run LLMs locally** - Powered by [KoboldCpp](https://github.com/LostRuins/koboldcpp), a fork of [llama.cpp](https://github.com/ggml-org/llama.cpp)
@ -29,14 +32,14 @@ Gerbil provides a graphical interface for running Large Language Models locally.
- **Integrated HuggingFace search** - Browse models, view model cards, and download GGUF files directly from the app - **Integrated HuggingFace search** - Browse models, view model cards, and download GGUF files directly from the app
- **SillyTavern integration** - Launch SillyTavern for advanced character interactions (requires [Node.js](https://nodejs.org/)) - **SillyTavern integration** - Launch SillyTavern for advanced character interactions (requires [Node.js](https://nodejs.org/))
- **OpenWebUI integration** - Launch OpenWebUI for a modern web-based chat interface (requires [uv](https://docs.astral.sh/uv/getting-started/installation/)) - **OpenWebUI integration** - Launch OpenWebUI for a modern web-based chat interface (requires [uv](https://docs.astral.sh/uv/getting-started/installation/))
- **Privacy-focused** - Everything runs locally with no external data transmission or telemetry - **Privacy-focused** - No telemetry, no external servers, no accounts
## Quick Start ## Quick Start
1. **[Download Gerbil](https://github.com/lone-cloud/gerbil/releases/latest)** for your platform 1. **[Download Gerbil](https://github.com/lone-cloud/gerbil/releases/latest)** for your platform
2. **Launch the app** - No installation needed for portable versions 2. **Launch the app** - No installation needed for portable versions
3. **Download a model** - Use the default model, use the built-in HuggingFace search by clicking on the looking glass icon, or import your own 3. **Download a model** - Use the default model, use the built-in HuggingFace search by clicking on the looking glass icon, or import your own
4. **Start generating** - Text and image generation is supported 4. **Start chatting** - Text and image generation both work out of the box
## Installation ## Installation
@ -132,14 +135,12 @@ https://github.com/user-attachments/assets/9e7ecfb3-3576-443c-8cef-a14e06ab5b60
## Advanced Configuration ## Advanced Configuration
Gerbil provides access to 80 KoboldCpp command line arguments through a built-in modal. While the GUI covers common settings, the command line arguments modal exposes 70+ additional advanced options. The Launch screen's Advanced tab exposes 80+ KoboldCpp arguments with descriptions, defaults, and examples, for when you need more than the GUI covers.
<div align="center"> <div align="center">
<img src="assets/screenshot-cli-args.webp" alt="Command Line Arguments Modal" width="800"> <img src="assets/screenshot-cli-args.webp" alt="Command Line Arguments Modal" width="800">
</div> </div>
Access the modal from the Launch screen's Advanced tab -"Additional Arguments" field to browse all available options with descriptions, default values, and usage examples organized by category.
## CLI Mode ## CLI Mode
The `--cli` argument allows you to run Gerbil in the terminal without the UI. This will run the same backend that the GUI was using. The `--cli` argument allows you to run Gerbil in the terminal without the UI. This will run the same backend that the GUI was using.

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

View file

@ -28,6 +28,9 @@ export default defineConfig({
renderer: { renderer: {
root: '.', root: '.',
publicDir: 'src/assets', publicDir: 'src/assets',
server: {
port: 5173,
},
build: { build: {
outDir: 'dist', outDir: 'dist',
rollupOptions: { rollupOptions: {
@ -51,8 +54,5 @@ export default defineConfig({
template: process.env.ANALYZE === 'server' ? 'network' : 'treemap', template: process.env.ANALYZE === 'server' ? 'network' : 'treemap',
}), }),
].filter(Boolean), ].filter(Boolean),
server: {
port: 5173,
},
}, },
}); });

View file

@ -1,11 +1,13 @@
{ {
"name": "gerbil", "name": "gerbil",
"version": "1.21.2", "version": "1.24.2",
"description": "Run Large Language Models locally", "description": "Run Large Language Models locally",
"keywords": [ "keywords": [
"ai", "ai",
"desktop",
"electron", "electron",
"koboldcpp", "gguf",
"image-generation",
"language-model", "language-model",
"llm", "llm",
"local-ai", "local-ai",
@ -39,31 +41,32 @@
}, },
"devDependencies": { "devDependencies": {
"@codemirror/commands": "^6.10.3", "@codemirror/commands": "^6.10.3",
"@codemirror/search": "^6.6.0", "@codemirror/search": "^6.7.0",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.0", "@codemirror/view": "^6.41.1",
"@fontsource/inter": "^5.2.8", "@fontsource/barlow-semi-condensed": "^5.2.7",
"@huggingface/gguf": "^0.4.1", "@fontsource/geist": "^5.2.8",
"@mantine/core": "^8.3.18", "@huggingface/gguf": "^0.4.2",
"@mantine/hooks": "^8.3.18", "@mantine/core": "^9.1.1",
"@mantine/hooks": "^9.1.1",
"@types/mime-types": "^3.0.1", "@types/mime-types": "^3.0.1",
"@types/node": "^25.5.2", "@types/node": "^25.6.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/yauzl": "^2.10.3", "@types/yauzl": "^2.10.3",
"@uiw/react-codemirror": "^4.25.9", "@uiw/react-codemirror": "^4.25.9",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"electron": "^41.1.1", "electron": "^41.5.0",
"electron-builder": "^26.8.1", "electron-builder": "^26.8.1",
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
"jiti": "^2.6.1", "jiti": "^2.7.0",
"lucide-react": "^1.7.0", "lucide-react": "^1.14.0",
"oxfmt": "^0.43.0", "oxfmt": "^0.48.0",
"oxlint": "^1.58.0", "oxlint": "^1.63.0",
"oxlint-tsgolint": "^0.19.0", "oxlint-tsgolint": "^0.22.1",
"react": "^19.2.4", "react": "^19.2.5",
"react-dom": "^19.2.4", "react-dom": "^19.2.5",
"react-error-boundary": "^6.1.1", "react-error-boundary": "^6.1.1",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@ -71,14 +74,14 @@
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"rollup-plugin-visualizer": "^7.0.1", "rollup-plugin-visualizer": "^7.0.1",
"typescript": "^6.0.2", "typescript": "^6.0.3",
"vite": "^8.0.3", "vite": "^8.0.10",
"zustand": "^5.0.12" "zustand": "^5.0.13"
}, },
"engines": { "engines": {
"node": ">=24.0.0" "node": ">=24.0.0"
}, },
"packageManager": "pnpm@10.33.0", "packageManager": "pnpm@10.33.3",
"build": { "build": {
"appId": "com.gerbil.app", "appId": "com.gerbil.app",
"productName": "Gerbil", "productName": "Gerbil",

1916
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,18 @@
import { ActionIcon, Button, Tooltip } from '@mantine/core'; import { ActionIcon, Button, Tooltip } from '@mantine/core';
import { Activity } from 'lucide-react'; import { Activity } from 'lucide-react';
const BADGE_STYLE = {
borderRadius: '0.75rem',
fontSize: '0.7rem',
fontWeight: 500,
fontVariantNumeric: 'tabular-nums',
height: 'auto',
margin: '0.125rem 0',
minWidth: '5rem',
padding: '0.25rem 0.5rem',
textAlign: 'center',
} as const;
interface PerformanceBadgeProps { interface PerformanceBadgeProps {
label?: string; label?: string;
value?: string; value?: string;
@ -25,7 +37,12 @@ export const PerformanceBadge = ({
if (iconOnly) { if (iconOnly) {
return ( return (
<Tooltip label={tooltipLabel} position="top"> <Tooltip label={tooltipLabel} position="top">
<ActionIcon size="sm" variant="subtle" onClick={() => void handlePerformanceClick()}> <ActionIcon
size="sm"
variant="subtle"
aria-label={tooltipLabel}
onClick={() => void handlePerformanceClick()}
>
<Activity size="1.125rem" /> <Activity size="1.125rem" />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@ -36,17 +53,9 @@ export const PerformanceBadge = ({
<Tooltip label={tooltipLabel} position="top"> <Tooltip label={tooltipLabel} position="top">
<Button <Button
size="xs" size="xs"
variant="light" variant="subtle"
style={{ style={BADGE_STYLE}
borderRadius: '0.75rem', aria-label={tooltipLabel}
fontSize: '0.7em',
fontWeight: 500,
height: 'auto',
margin: '0.125rem 0',
minWidth: '5rem',
padding: '0.25rem 0.5rem',
textAlign: 'center',
}}
onClick={() => void handlePerformanceClick()} onClick={() => void handlePerformanceClick()}
> >
{label}: {value} {label}: {value}

View file

@ -27,7 +27,7 @@ export const AppRouter = ({
const isInterfaceScreen = currentScreen === 'interface'; const isInterfaceScreen = currentScreen === 'interface';
return ( return (
<> <div style={{ display: 'contents' }}>
<ScreenTransition isActive={currentScreen === 'welcome'} shouldAnimate={hasInitialized}> <ScreenTransition isActive={currentScreen === 'welcome'} shouldAnimate={hasInitialized}>
<WelcomeScreen onGetStarted={onWelcomeComplete} /> <WelcomeScreen onGetStarted={onWelcomeComplete} />
</ScreenTransition> </ScreenTransition>
@ -43,6 +43,6 @@ export const AppRouter = ({
<ScreenTransition isActive={isInterfaceScreen} shouldAnimate={hasInitialized}> <ScreenTransition isActive={isInterfaceScreen} shouldAnimate={hasInitialized}>
<InterfaceScreen activeTab={activeInterfaceTab} isServerReady={isServerReady} /> <InterfaceScreen activeTab={activeInterfaceTab} isServerReady={isServerReady} />
</ScreenTransition> </ScreenTransition>
</> </div>
); );
}; };

View file

@ -13,12 +13,14 @@ export const ScreenTransition = ({ isActive, shouldAnimate, children }: ScreenTr
transition="fade" transition="fade"
duration={shouldAnimate ? 100 : 0} duration={shouldAnimate ? 100 : 0}
timingFunction="ease-out" timingFunction="ease-out"
keepMounted={false}
> >
{(styles) => ( {(styles) => (
<div <div
style={{ style={{
...styles, ...styles,
}} }}
inert={!isActive ? true : undefined}
> >
{children} {children}
</div> </div>

View file

@ -2,6 +2,7 @@ import { ActionIcon, AppShell, CopyButton, Group, Tooltip } from '@mantine/core'
import { Check, Globe, NotepadText } from 'lucide-react'; import { Check, Globe, NotepadText } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { STATUSBAR_HEIGHT } from '@/constants';
import type { CpuMetrics, GpuMetrics, MemoryMetrics } from '@/main/modules/monitoring'; import type { CpuMetrics, GpuMetrics, MemoryMetrics } from '@/main/modules/monitoring';
import { useLaunchConfigStore } from '@/stores/launchConfig'; import { useLaunchConfigStore } from '@/stores/launchConfig';
import { useNotepadStore } from '@/stores/notepad'; import { useNotepadStore } from '@/stores/notepad';
@ -15,12 +16,8 @@ export const StatusBar = () => {
const [memoryMetrics, setMemoryMetrics] = useState<MemoryMetrics | null>(null); const [memoryMetrics, setMemoryMetrics] = useState<MemoryMetrics | null>(null);
const [gpuMetrics, setGpuMetrics] = useState<GpuMetrics | null>(null); const [gpuMetrics, setGpuMetrics] = useState<GpuMetrics | null>(null);
const [tunnelBaseUrl, setTunnelBaseUrl] = useState<string | null>(null); const [tunnelBaseUrl, setTunnelBaseUrl] = useState<string | null>(null);
const { const { systemMonitoringEnabled, frontendPreference, imageGenerationFrontendPreference } =
resolvedColorScheme: colorScheme, usePreferencesStore();
systemMonitoringEnabled,
frontendPreference,
imageGenerationFrontendPreference,
} = usePreferencesStore();
const { isVisible, setVisible } = useNotepadStore(); const { isVisible, setVisible } = useNotepadStore();
const { isImageGenerationMode } = useLaunchConfigStore(); const { isImageGenerationMode } = useLaunchConfigStore();
@ -40,7 +37,7 @@ export const StatusBar = () => {
useEffect(() => { useEffect(() => {
if (!systemMonitoringEnabled) { if (!systemMonitoringEnabled) {
return; return undefined;
} }
let isMounted = true; let isMounted = true;
@ -90,23 +87,23 @@ export const StatusBar = () => {
const displayGpuMetrics = systemMonitoringEnabled ? gpuMetrics : null; const displayGpuMetrics = systemMonitoringEnabled ? gpuMetrics : null;
return ( return (
<AppShell.Footer <AppShell.Footer>
style={{
borderTop: '1px solid var(--mantine-color-default-border)',
}}
>
<Group <Group
px="xs" px="xs"
gap="xs" gap="xs"
justify="space-between" justify="space-between"
h="100%" h="100%"
bg={colorScheme === 'dark' ? 'dark.6' : 'gray.1'} style={{
backgroundColor: 'var(--gerbil-surface-secondary)',
}}
> >
<Group gap="xs"> <Group gap="xs">
<Tooltip label="Notepad" disabled={isVisible}> <Tooltip label="Notepad" disabled={isVisible}>
<ActionIcon <ActionIcon
variant={isVisible ? 'filled' : 'subtle'} variant={isVisible ? 'filled' : 'subtle'}
size="sm" size={STATUSBAR_HEIGHT}
aria-label={isVisible ? 'Hide notepad' : 'Show notepad'}
aria-pressed={isVisible}
onClick={() => setVisible(!isVisible)} onClick={() => setVisible(!isVisible)}
> >
<NotepadText size="1.25rem" /> <NotepadText size="1.25rem" />
@ -118,8 +115,9 @@ export const StatusBar = () => {
<Tooltip label={copied ? 'Copied!' : 'Copy Tunnel URL'} position="top"> <Tooltip label={copied ? 'Copied!' : 'Copy Tunnel URL'} position="top">
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
size="sm" size={STATUSBAR_HEIGHT}
color={copied ? 'teal' : undefined} color={copied ? 'teal' : undefined}
aria-label={copied ? 'Copied!' : 'Copy tunnel URL'}
onClick={copy} onClick={copy}
> >
{copied ? <Check size="1.25rem" /> : <Globe size="1.25rem" />} {copied ? <Check size="1.25rem" /> : <Globe size="1.25rem" />}

View file

@ -33,11 +33,7 @@ const renderOption = ({ option }: { option: SelectOption }) => (
); );
export const TitleBar = ({ currentScreen, currentTab, onEject, onTabChange }: TitleBarProps) => { export const TitleBar = ({ currentScreen, currentTab, onEject, onTabChange }: TitleBarProps) => {
const { const { frontendPreference, imageGenerationFrontendPreference } = usePreferencesStore();
resolvedColorScheme: colorScheme,
frontendPreference,
imageGenerationFrontendPreference,
} = usePreferencesStore();
const { handleLogoClick, getLogoStyles } = useLogoClickSounds(); const { handleLogoClick, getLogoStyles } = useLogoClickSounds();
const [isMaximized, setIsMaximized] = useState(false); const [isMaximized, setIsMaximized] = useState(false);
const [isSelectOpen, setIsSelectOpen] = useState(false); const [isSelectOpen, setIsSelectOpen] = useState(false);
@ -69,13 +65,12 @@ export const TitleBar = ({ currentScreen, currentTab, onEject, onTabChange }: Ti
}, []); }, []);
return ( return (
<AppShell.Header style={{ border: 'none', display: 'flex', flexDirection: 'column' }}> <AppShell.Header>
<Box <Box
style={{ style={{
WebkitAppRegion: isSelectOpen ? 'no-drag' : 'drag', WebkitAppRegion: isSelectOpen ? 'no-drag' : 'drag',
alignItems: 'center', alignItems: 'center',
backgroundColor: backgroundColor: 'var(--gerbil-surface-secondary)',
colorScheme === 'dark' ? 'var(--mantine-color-dark-6)' : 'var(--mantine-color-gray-1)',
borderBottom: '1px solid var(--mantine-color-default-border)', borderBottom: '1px solid var(--mantine-color-default-border)',
display: 'flex', display: 'flex',
height: TITLEBAR_HEIGHT, height: TITLEBAR_HEIGHT,
@ -154,9 +149,7 @@ export const TitleBar = ({ currentScreen, currentTab, onEject, onTabChange }: Ti
<Box <Box
style={{ style={{
backgroundColor: backgroundColor:
colorScheme === 'dark' 'light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3))',
? 'var(--mantine-color-dark-3)'
: 'var(--mantine-color-gray-4)',
height: '1.25rem', height: '1.25rem',
margin: '0 0.25rem', margin: '0 0.25rem',
width: '0.1rem', width: '0.1rem',
@ -182,9 +175,9 @@ export const TitleBar = ({ currentScreen, currentTab, onEject, onTabChange }: Ti
label: 'Close window', label: 'Close window',
onClick: () => void window.electronAPI.app.closeWindow(), onClick: () => void window.electronAPI.app.closeWindow(),
}, },
].map((button, index) => ( ].map((button) => (
<ActionIcon <ActionIcon
key={index} key={button.label}
variant="subtle" variant="subtle"
size={TITLEBAR_HEIGHT} size={TITLEBAR_HEIGHT}
onClick={button.onClick} onClick={button.onClick}

View file

@ -54,7 +54,7 @@ export const UpdateAvailableModal = ({
closeOnEscape={!isDownloading && !isUpdating} closeOnEscape={!isDownloading && !isUpdating}
> >
<Stack gap="md"> <Stack gap="md">
<Card withBorder radius="md" p="md" bd="2px solid orange"> <Card withBorder radius="md" p="md" bd="2px solid var(--mantine-color-orange-5)">
<Stack gap="xs"> <Stack gap="xs">
<Group gap="md" align="center"> <Group gap="md" align="center">
<div> <div>

View file

@ -72,7 +72,6 @@ export const UpdateButton = () => {
color={color} color={color}
size={TITLEBAR_HEIGHT} size={TITLEBAR_HEIGHT}
aria-label={label} aria-label={label}
tabIndex={-1}
onClick={onClick} onClick={onClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
style={{ style={{

View file

@ -9,7 +9,7 @@ import { StatusBar } from '@/components/App/StatusBar';
import { TitleBar } from '@/components/App/TitleBar'; import { TitleBar } from '@/components/App/TitleBar';
import { UpdateAvailableModal } from '@/components/App/UpdateAvailableModal'; import { UpdateAvailableModal } from '@/components/App/UpdateAvailableModal';
import { NotepadContainer } from '@/components/Notepad/Container'; import { NotepadContainer } from '@/components/Notepad/Container';
import { STATUSBAR_HEIGHT, TITLEBAR_HEIGHT } from '@/constants'; import { SERVER_READY_DELAY_MS, STATUSBAR_HEIGHT, TITLEBAR_HEIGHT } from '@/constants';
import { useUpdateChecker } from '@/hooks/useUpdateChecker'; import { useUpdateChecker } from '@/hooks/useUpdateChecker';
import { useKoboldBackendsStore } from '@/stores/koboldBackends'; import { useKoboldBackendsStore } from '@/stores/koboldBackends';
import { useLaunchConfigStore } from '@/stores/launchConfig'; import { useLaunchConfigStore } from '@/stores/launchConfig';
@ -53,7 +53,7 @@ export const App = () => {
setTimeout(() => { setTimeout(() => {
setIsServerReady(true); setIsServerReady(true);
setActiveInterfaceTab(defaultInterfaceTab); setActiveInterfaceTab(defaultInterfaceTab);
}, 3000); }, SERVER_READY_DELAY_MS);
}); });
return cleanup; return cleanup;
@ -143,7 +143,7 @@ export const App = () => {
useEffect(() => { useEffect(() => {
if (loadingRemote || !hasInitialized) { if (loadingRemote || !hasInitialized) {
return; return undefined;
} }
const runUpdateCheck = async () => { const runUpdateCheck = async () => {

View file

@ -3,7 +3,6 @@ import { Download, Trash2 } from 'lucide-react';
import type { MouseEvent } from 'react'; import type { MouseEvent } from 'react';
import { useKoboldBackendsStore } from '@/stores/koboldBackends'; import { useKoboldBackendsStore } from '@/stores/koboldBackends';
import { usePreferencesStore } from '@/stores/preferences';
import type { BackendInfo } from '@/types'; import type { BackendInfo } from '@/types';
import { isWindowsROCmBuild, pretifyBinName } from '@/utils/assets'; import { isWindowsROCmBuild, pretifyBinName } from '@/utils/assets';
@ -30,7 +29,6 @@ export const DownloadCard = ({
onRedownload, onRedownload,
onDelete, onDelete,
}: DownloadCardProps) => { }: DownloadCardProps) => {
const { resolvedColorScheme: colorScheme } = usePreferencesStore();
const { downloading, downloadProgress } = useKoboldBackendsStore(); const { downloading, downloadProgress } = useKoboldBackendsStore();
const isLoading = downloading === backend.name; const isLoading = downloading === backend.name;
@ -156,8 +154,11 @@ export const DownloadCard = ({
radius="sm" radius="sm"
padding="sm" padding="sm"
{...(backend.isCurrent && { {...(backend.isCurrent && {
bd: `2px solid var(--mantine-color-${colorScheme === 'dark' ? 'blue-4' : 'blue-6'})`, style: {
bg: colorScheme === 'dark' ? 'dark.6' : 'gray.0', border:
'1px solid light-dark(var(--mantine-color-brand-6), var(--mantine-color-brand-4))',
backgroundColor: 'var(--gerbil-surface-secondary)',
},
})} })}
> >
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
@ -167,7 +168,7 @@ export const DownloadCard = ({
{pretifyBinName(backend.name)} {pretifyBinName(backend.name)}
</Text> </Text>
{backend.isCurrent && ( {backend.isCurrent && (
<Badge variant="light" color="blue" size="sm"> <Badge variant="light" color="brand" size="sm">
Current Current
</Badge> </Badge>
)} )}
@ -192,10 +193,10 @@ export const DownloadCard = ({
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
Version {backend.version} Version {backend.version}
{hasVersionMismatch && backend.actualVersion && ( {hasVersionMismatch && backend.actualVersion && (
<span style={{ color: 'var(--mantine-color-red-6)' }}> <Text component="span" c="red">
{' '} {' '}
(actual: {backend.actualVersion}) (actual: {backend.actualVersion})
</span> </Text>
)} )}
</Text> </Text>
)} )}
@ -217,8 +218,8 @@ export const DownloadCard = ({
{isLoading && currentProgress !== undefined && ( {isLoading && currentProgress !== undefined && (
<Stack gap="xs" mt="sm"> <Stack gap="xs" mt="sm">
<Progress value={currentProgress} color="blue" radius="xl" /> <Progress value={currentProgress} color="brand" radius="xl" />
<Text size="xs" c="dimmed" ta="center"> <Text size="xs" c="dimmed" ta="center" aria-live="polite">
{currentProgress === 100 {currentProgress === 100
? '100.0% complete' ? '100.0% complete'
: `${currentProgress.toFixed(1).padStart(5, ' ')}% complete`} : `${currentProgress.toFixed(1).padStart(5, ' ')}% complete`}

View file

@ -9,7 +9,7 @@ interface InfoTooltipProps {
export const InfoTooltip = ({ label, multiline = true, width = 300 }: InfoTooltipProps) => ( export const InfoTooltip = ({ label, multiline = true, width = 300 }: InfoTooltipProps) => (
<Tooltip label={label} multiline={multiline} w={width}> <Tooltip label={label} multiline={multiline} w={width}>
<ActionIcon variant="subtle" size="xs" color="gray"> <ActionIcon variant="subtle" size="md" color="gray" aria-label="More information">
<Info size={14} /> <Info size={14} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>

View file

@ -1,7 +1,7 @@
import { Box, Button, Modal as MantineModal } from '@mantine/core'; import { Box, Button, Modal as MantineModal } from '@mantine/core';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
const TITLEBAR_HEIGHT = '2.5rem'; import { TITLEBAR_HEIGHT } from '@/constants';
const MODAL_STYLES_WITH_TITLEBAR = { const MODAL_STYLES_WITH_TITLEBAR = {
content: { content: {
@ -41,7 +41,8 @@ export const Modal = ({
style={{ style={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
height: '75vh', height: '72vh',
overflow: 'hidden',
padding: 0, padding: 0,
position: 'relative', position: 'relative',
}} }}

View file

@ -5,7 +5,6 @@ import type { MouseEvent } from 'react';
import { NOTEPAD_MIN_HEIGHT, NOTEPAD_MIN_WIDTH } from '@/constants/notepad'; import { NOTEPAD_MIN_HEIGHT, NOTEPAD_MIN_WIDTH } from '@/constants/notepad';
import { useNotepadStore } from '@/stores/notepad'; import { useNotepadStore } from '@/stores/notepad';
import { usePreferencesStore } from '@/stores/preferences';
import { CloseConfirmModal } from './CloseConfirmModal.tsx'; import { CloseConfirmModal } from './CloseConfirmModal.tsx';
import { NotepadEditor } from './Editor.tsx'; import { NotepadEditor } from './Editor.tsx';
@ -23,7 +22,6 @@ export const NotepadContainer = () => {
isVisible, isVisible,
setVisible, setVisible,
} = useNotepadStore(); } = useNotepadStore();
const { resolvedColorScheme } = usePreferencesStore();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [resizeDirection, setResizeDirection] = useState<string | null>(null); const [resizeDirection, setResizeDirection] = useState<string | null>(null);
@ -111,6 +109,8 @@ export const NotepadContainer = () => {
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
}; };
} }
return undefined;
}, [resizeDirection, position, setPosition]); }, [resizeDirection, position, setPosition]);
if (!isLoaded || !isVisible) { if (!isLoaded || !isVisible) {
@ -123,10 +123,7 @@ export const NotepadContainer = () => {
shadow="lg" shadow="lg"
withBorder withBorder
style={{ style={{
backgroundColor: backgroundColor: 'var(--gerbil-surface-secondary)',
resolvedColorScheme === 'dark'
? 'var(--mantine-color-dark-6)'
: 'var(--mantine-color-white)',
bottom: 24, bottom: 24,
cursor: 'default', cursor: 'default',
height: position.height, height: position.height,
@ -197,11 +194,8 @@ export const NotepadContainer = () => {
<Box <Box
style={{ style={{
alignItems: 'center', alignItems: 'center',
borderBottom: `1px solid ${ borderBottom:
resolvedColorScheme === 'dark' '1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))',
? 'var(--mantine-color-dark-4)'
: 'var(--mantine-color-gray-3)'
}`,
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
minHeight: 28, minHeight: 28,
@ -218,13 +212,31 @@ export const NotepadContainer = () => {
paddingRight: 8, paddingRight: 8,
}} }}
> >
<ActionIcon variant="subtle" size="xs" onClick={() => setVisible(false)}> <ActionIcon
variant="subtle"
size="md"
aria-label="Minimize notepad"
onClick={() => setVisible(false)}
>
<Minus size="1rem" /> <Minus size="1rem" />
</ActionIcon> </ActionIcon>
</Box> </Box>
</Box> </Box>
<Box style={{ flex: 1, position: 'relative' }}> <Box
role="tabpanel"
id={
activeTab
? `notepad-panel-${activeTab.title.replace(/[^a-z0-9]/gi, '-').toLowerCase()}`
: undefined
}
aria-labelledby={
activeTab
? `notepad-tab-${activeTab.title.replace(/[^a-z0-9]/gi, '-').toLowerCase()}`
: undefined
}
style={{ flex: 1, position: 'relative' }}
>
{activeTab && <NotepadEditor key={activeTab.title} tab={activeTab} />} {activeTab && <NotepadEditor key={activeTab.title} tab={activeTab} />}
</Box> </Box>
</Box> </Box>

View file

@ -20,24 +20,22 @@ export const NotepadEditor = ({ tab }: NotepadEditorProps) => {
const { saveTabContent, showLineNumbers, setShowLineNumbers } = useNotepadStore(); const { saveTabContent, showLineNumbers, setShowLineNumbers } = useNotepadStore();
const { resolvedColorScheme } = usePreferencesStore(); const { resolvedColorScheme } = usePreferencesStore();
const [content, setContent] = useState(() => tab.content); const [content, setContent] = useState(() => tab.content);
const [saveTimeout, setSaveTimeout] = useState<ReturnType<typeof setTimeout> | null>(null); const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const editorRef = useRef<ReactCodeMirrorRef>(null); const editorRef = useRef<ReactCodeMirrorRef>(null);
const handleContentChange = useCallback( const handleContentChange = useCallback(
(newContent: string) => { (newContent: string) => {
setContent(newContent); setContent(newContent);
if (saveTimeout) { if (saveTimeoutRef.current) {
clearTimeout(saveTimeout); clearTimeout(saveTimeoutRef.current);
} }
const timeout = setTimeout(() => { saveTimeoutRef.current = setTimeout(() => {
void saveTabContent(tab.title, newContent); void saveTabContent(tab.title, newContent);
}, 500); }, 500);
setSaveTimeout(timeout);
}, },
[tab.title, saveTabContent, saveTimeout], [tab.title, saveTabContent],
); );
const handleEditorContextMenu = (e: MouseEvent) => { const handleEditorContextMenu = (e: MouseEvent) => {
@ -56,11 +54,11 @@ export const NotepadEditor = ({ tab }: NotepadEditorProps) => {
useEffect( useEffect(
() => () => { () => () => {
if (saveTimeout) { if (saveTimeoutRef.current) {
clearTimeout(saveTimeout); clearTimeout(saveTimeoutRef.current);
} }
}, },
[saveTimeout], [],
); );
const extensions = [ const extensions = [

View file

@ -3,8 +3,6 @@ import { X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { DragEvent, KeyboardEvent, MouseEvent } from 'react'; import type { DragEvent, KeyboardEvent, MouseEvent } from 'react';
import { usePreferencesStore } from '@/stores/preferences';
interface TabProps { interface TabProps {
title: string; title: string;
index: number; index: number;
@ -34,7 +32,6 @@ export const Tab = ({
showLineNumbers, showLineNumbers,
setShowLineNumbers, setShowLineNumbers,
}: TabProps) => { }: TabProps) => {
const { resolvedColorScheme } = usePreferencesStore();
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editingTitle, setEditingTitle] = useState(title); const [editingTitle, setEditingTitle] = useState(title);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@ -97,28 +94,32 @@ export const Tab = ({
return ( return (
<Box <Box
id={`notepad-tab-${title.replace(/[^a-z0-9]/gi, '-').toLowerCase()}`}
role="tab"
aria-selected={isActive}
aria-controls={`notepad-panel-${title.replace(/[^a-z0-9]/gi, '-').toLowerCase()}`}
tabIndex={isActive ? 0 : -1}
draggable={!isEditing} draggable={!isEditing}
onClick={handleTabClick} onClick={handleTabClick}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
if (!isEditing && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
handleTabClick();
}
}}
onDragStart={(e) => onDragStart(e, index)} onDragStart={(e) => onDragStart(e, index)}
onDragOver={onDragOver} onDragOver={onDragOver}
onDrop={(e) => onDrop(e, index)} onDrop={(e) => onDrop(e, index)}
style={{ style={{
alignItems: 'center', alignItems: 'center',
backgroundColor: isActive backgroundColor: isActive
? resolvedColorScheme === 'dark' ? 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-4))'
? 'var(--mantine-color-dark-4)'
: 'var(--mantine-color-gray-1)'
: isDragOver : isDragOver
? resolvedColorScheme === 'dark' ? 'light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5))'
? 'var(--mantine-color-dark-5)'
: 'var(--mantine-color-gray-2)'
: 'transparent', : 'transparent',
borderRight: `1px solid ${ borderRight:
resolvedColorScheme === 'dark' '1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))',
? 'var(--mantine-color-dark-4)'
: 'var(--mantine-color-gray-3)'
}`,
cursor: isEditing ? 'default' : 'pointer', cursor: isEditing ? 'default' : 'pointer',
display: 'flex', display: 'flex',
gap: '0.25rem', gap: '0.25rem',
@ -173,7 +174,7 @@ export const Tab = ({
</Text> </Text>
)} )}
<ActionIcon variant="subtle" size="xs" onClick={onClose}> <ActionIcon variant="subtle" size="md" aria-label="Close tab" onClick={onClose}>
<X size="0.625rem" /> <X size="0.625rem" />
</ActionIcon> </ActionIcon>
</Box> </Box>

View file

@ -1,11 +1,10 @@
import { ActionIcon, Box } from '@mantine/core'; import { ActionIcon, Box } from '@mantine/core';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import type { DragEvent, MouseEvent } from 'react'; import type { DragEvent, KeyboardEvent, MouseEvent } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { Tab } from '@/components/Notepad/Tab'; import { Tab } from '@/components/Notepad/Tab';
import { useNotepadStore } from '@/stores/notepad'; import { useNotepadStore } from '@/stores/notepad';
import { usePreferencesStore } from '@/stores/preferences';
interface NotepadTabsProps { interface NotepadTabsProps {
onCreateNewTab: () => Promise<void>; onCreateNewTab: () => Promise<void>;
@ -27,7 +26,6 @@ export const NotepadTabs = ({ onCreateNewTab, onCloseTab }: NotepadTabsProps) =>
showLineNumbers, showLineNumbers,
setShowLineNumbers, setShowLineNumbers,
} = useNotepadStore(); } = useNotepadStore();
const { resolvedColorScheme } = usePreferencesStore();
const [draggedTabIndex, setDraggedTabIndex] = useState<number | null>(null); const [draggedTabIndex, setDraggedTabIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null); const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
@ -53,6 +51,19 @@ export const NotepadTabs = ({ onCreateNewTab, onCloseTab }: NotepadTabsProps) =>
} }
}; };
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const currentIndex = tabs.findIndex((t) => t.title === activeTabId);
if (e.key === 'ArrowRight') {
e.preventDefault();
const next = (currentIndex + 1) % tabs.length;
setActiveTab(tabs[next].title);
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
const prev = (currentIndex - 1 + tabs.length) % tabs.length;
setActiveTab(tabs[prev].title);
}
};
const handleDragStart = (e: DragEvent, index: number) => { const handleDragStart = (e: DragEvent, index: number) => {
setDraggedTabIndex(index); setDraggedTabIndex(index);
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
@ -79,13 +90,13 @@ export const NotepadTabs = ({ onCreateNewTab, onCloseTab }: NotepadTabsProps) =>
return ( return (
<Box <Box
role="tablist"
aria-label="Notepad tabs"
onContextMenu={handleTabBarContextMenu} onContextMenu={handleTabBarContextMenu}
onKeyDown={handleKeyDown}
style={{ style={{
borderBottom: `1px solid ${ borderBottom:
resolvedColorScheme === 'dark' '1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))',
? 'var(--mantine-color-dark-4)'
: 'var(--mantine-color-gray-3)'
}`,
display: 'flex', display: 'flex',
minHeight: '2rem', minHeight: '2rem',
overflow: 'hidden', overflow: 'hidden',
@ -116,7 +127,8 @@ export const NotepadTabs = ({ onCreateNewTab, onCloseTab }: NotepadTabsProps) =>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
size="xs" size="md"
aria-label="New tab"
onClick={() => void onCreateNewTab()} onClick={() => void onCreateNewTab()}
style={{ style={{
alignSelf: 'center', alignSelf: 'center',

View file

@ -1,4 +1,4 @@
import { Group, List, Tooltip } from '@mantine/core'; import { Box, Group, List, Tooltip } from '@mantine/core';
import { AlertTriangle, Info } from 'lucide-react'; import { AlertTriangle, Info } from 'lucide-react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
@ -29,16 +29,24 @@ export const WarningDisplay = ({ warnings, children }: WarningDisplayProps) => {
warningMessages[0].message warningMessages[0].message
) : ( ) : (
<List size="sm" spacing={4}> <List size="sm" spacing={4}>
{warningMessages.map((warning, index) => ( {warningMessages.map((warning) => (
<List.Item key={index}>{warning.message}</List.Item> <List.Item key={warning.message}>{warning.message}</List.Item>
))} ))}
</List> </List>
) )
} }
multiline multiline
maw={320} maw={320}
>
<Box
component="span"
tabIndex={0}
role="img"
aria-label="Warning"
style={{ display: 'inline-flex', cursor: 'default' }}
> >
<AlertTriangle size={18} color="var(--mantine-color-orange-6)" strokeWidth={2} /> <AlertTriangle size={18} color="var(--mantine-color-orange-6)" strokeWidth={2} />
</Box>
</Tooltip> </Tooltip>
)} )}
{infoMessages.length > 0 && ( {infoMessages.length > 0 && (
@ -48,8 +56,8 @@ export const WarningDisplay = ({ warnings, children }: WarningDisplayProps) => {
infoMessages[0].message infoMessages[0].message
) : ( ) : (
<List size="sm" spacing={4}> <List size="sm" spacing={4}>
{infoMessages.map((info, index) => ( {infoMessages.map((info) => (
<List.Item key={index}>{info.message}</List.Item> <List.Item key={info.message}>{info.message}</List.Item>
))} ))}
</List> </List>
) )
@ -57,7 +65,15 @@ export const WarningDisplay = ({ warnings, children }: WarningDisplayProps) => {
multiline multiline
maw={320} maw={320}
> >
<Info size={18} color="var(--mantine-color-blue-6)" strokeWidth={2} /> <Box
component="span"
tabIndex={0}
role="img"
aria-label="Info"
style={{ display: 'inline-flex', cursor: 'default' }}
>
<Info size={18} color="var(--mantine-color-brand-5)" strokeWidth={2} />
</Box>
</Tooltip> </Tooltip>
)} )}
{children} {children}

View file

@ -1,4 +1,4 @@
import { Card, Container, Loader, Stack, Text, Title } from '@mantine/core'; import { Container, Loader, Stack, Text, Title } from '@mantine/core';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { DownloadCard } from '@/components/DownloadCard'; import { DownloadCard } from '@/components/DownloadCard';
@ -59,13 +59,12 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
return ( return (
<Container size="sm" mt="md"> <Container size="sm" mt="md">
<Card withBorder radius="md" shadow="sm">
<Stack gap="lg"> <Stack gap="lg">
<Title order={3}>Select a Backend</Title> <Title order={3}>Select a Backend</Title>
{loading ? ( {loading ? (
<Stack align="center" gap="md" py="xl"> <Stack align="center" gap="md" py="xl">
<Loader color="blue" /> <Loader color="brand" />
<Text c="dimmed">Preparing download options...</Text> <Text c="dimmed">Preparing download options...</Text>
</Stack> </Stack>
) : ( ) : (
@ -73,8 +72,7 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
{availableDownloads.length > 0 ? ( {availableDownloads.length > 0 ? (
<Stack gap="sm"> <Stack gap="sm">
{availableDownloads.map((download) => { {availableDownloads.map((download) => {
const isDownloading = const isDownloading = Boolean(downloading) && downloadingAsset === download.name;
Boolean(downloading) && downloadingAsset === download.name;
return ( return (
<div key={download.name} ref={isDownloading ? downloadingItemRef : null}> <div key={download.name} ref={isDownloading ? downloadingItemRef : null}>
@ -91,8 +89,7 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
size={formatDownloadSize(download.size, download.url)} size={formatDownloadSize(download.size, download.url)}
description={getAssetDescription(download.name)} description={getAssetDescription(download.name)}
disabled={ disabled={
importing || importing || (Boolean(downloading) && downloadingAsset !== download.name)
(Boolean(downloading) && downloadingAsset !== download.name)
} }
onDownload={(e) => { onDownload={(e) => {
e.stopPropagation(); e.stopPropagation();
@ -118,7 +115,6 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
</> </>
)} )}
</Stack> </Stack>
</Card>
</Container> </Container>
); );
}; };

View file

@ -1,9 +1,8 @@
import { ActionIcon, Box, ScrollArea } from '@mantine/core'; import { ActionIcon, Box, ScrollArea } from '@mantine/core';
import { ChevronDown } from 'lucide-react'; import { ChevronDown } from 'lucide-react';
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { STATUSBAR_HEIGHT, TITLEBAR_HEIGHT } from '@/constants'; import { STATUSBAR_HEIGHT, TITLEBAR_HEIGHT } from '@/constants';
import { usePreferencesStore } from '@/stores/preferences';
import { handleTerminalOutput, processTerminalContent } from '@/utils/terminal'; import { handleTerminalOutput, processTerminalContent } from '@/utils/terminal';
export interface TerminalTabRef { export interface TerminalTabRef {
@ -11,7 +10,6 @@ export interface TerminalTabRef {
} }
export const TerminalTab = forwardRef<TerminalTabRef>((_props, ref) => { export const TerminalTab = forwardRef<TerminalTabRef>((_props, ref) => {
const { resolvedColorScheme: colorScheme } = usePreferencesStore();
const [terminalContent, setTerminalContent] = useState(''); const [terminalContent, setTerminalContent] = useState('');
const [isUserScrolling, setIsUserScrolling] = useState(false); const [isUserScrolling, setIsUserScrolling] = useState(false);
const [shouldAutoScroll, setShouldAutoScroll] = useState(true); const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
@ -19,6 +17,8 @@ export const TerminalTab = forwardRef<TerminalTabRef>((_props, ref) => {
const scrollAreaRef = useRef<HTMLDivElement>(null); const scrollAreaRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null); const viewportRef = useRef<HTMLDivElement>(null);
const lastScrollTop = useRef(0); const lastScrollTop = useRef(0);
const shouldAutoScrollRef = useRef(true);
const isUserScrollingRef = useRef(false);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@ -39,9 +39,13 @@ export const TerminalTab = forwardRef<TerminalTabRef>((_props, ref) => {
if (y < lastScrollTop.current) { if (y < lastScrollTop.current) {
setIsUserScrolling(true); setIsUserScrolling(true);
setShouldAutoScroll(false); setShouldAutoScroll(false);
isUserScrollingRef.current = true;
shouldAutoScrollRef.current = false;
} else if (isAtBottomNow) { } else if (isAtBottomNow) {
setIsUserScrolling(false); setIsUserScrolling(false);
setShouldAutoScroll(true); setShouldAutoScroll(true);
isUserScrollingRef.current = false;
shouldAutoScrollRef.current = true;
} }
lastScrollTop.current = y; lastScrollTop.current = y;
@ -56,9 +60,9 @@ export const TerminalTab = forwardRef<TerminalTabRef>((_props, ref) => {
useEffect(() => { useEffect(() => {
const cleanup = window.electronAPI.kobold.onKoboldOutput((data: string) => { const cleanup = window.electronAPI.kobold.onKoboldOutput((data: string) => {
setTerminalContent((prev) => handleTerminalOutput(prev, data.toString())); setTerminalContent((prev) => handleTerminalOutput(prev, data));
if (shouldAutoScroll && !isUserScrolling && viewportRef.current) { if (shouldAutoScrollRef.current && !isUserScrollingRef.current && viewportRef.current) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (viewportRef.current) { if (viewportRef.current) {
viewportRef.current.scrollTop = viewportRef.current.scrollHeight; viewportRef.current.scrollTop = viewportRef.current.scrollHeight;
@ -68,16 +72,18 @@ export const TerminalTab = forwardRef<TerminalTabRef>((_props, ref) => {
}); });
return cleanup; return cleanup;
}, [shouldAutoScroll, isUserScrolling]); }, []);
const scrollToBottom = () => { const scrollToBottom = useCallback(() => {
if (viewportRef.current) { if (viewportRef.current) {
const viewport = viewportRef.current; const viewport = viewportRef.current;
viewport.scrollTop = viewport.scrollHeight; viewport.scrollTop = viewport.scrollHeight;
setShouldAutoScroll(true); setShouldAutoScroll(true);
setIsUserScrolling(false); setIsUserScrolling(false);
shouldAutoScrollRef.current = true;
isUserScrollingRef.current = false;
} }
}; }, []);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
scrollToBottom, scrollToBottom,
@ -87,9 +93,7 @@ export const TerminalTab = forwardRef<TerminalTabRef>((_props, ref) => {
<Box <Box
style={{ style={{
backgroundColor: backgroundColor:
colorScheme === 'dark' 'light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-filled))',
? 'var(--mantine-color-dark-filled)'
: 'var(--mantine-color-gray-0)',
borderRadius: 'inherit', borderRadius: 'inherit',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@ -101,25 +105,21 @@ export const TerminalTab = forwardRef<TerminalTabRef>((_props, ref) => {
ref={scrollAreaRef} ref={scrollAreaRef}
viewportRef={viewportRef} viewportRef={viewportRef}
onScrollPositionChange={handleScroll} onScrollPositionChange={handleScroll}
style={{ style={{ flex: 1 }}
flex: 1,
fontFamily: 'Monaco, Menlo, Ubuntu Mono, monospace',
fontSize: '0.875em',
lineHeight: 1.4,
}}
scrollbarSize={8} scrollbarSize={8}
offsetScrollbars={false} offsetScrollbars={false}
> >
<Box p="md"> <Box p="md">
<div <div
role="log"
aria-live="polite"
aria-atomic="false"
aria-relevant="additions"
style={{ style={{
color: color: 'light-dark(var(--mantine-color-dark-filled), var(--mantine-color-gray-0))',
colorScheme === 'dark'
? 'var(--mantine-color-gray-0)'
: 'var(--mantine-color-dark-filled)',
fontFamily: fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: '0.875em', fontSize: '0.8125em',
lineHeight: 1.4, lineHeight: 1.4,
margin: 0, margin: 0,
opacity: isVisible ? 1 : 0, opacity: isVisible ? 1 : 0,
@ -137,13 +137,13 @@ export const TerminalTab = forwardRef<TerminalTabRef>((_props, ref) => {
{isUserScrolling && !shouldAutoScroll && ( {isUserScrolling && !shouldAutoScroll && (
<ActionIcon <ActionIcon
variant="filled" variant="filled"
color="blue" color="brand"
size="lg" size="lg"
radius="xl" radius="xl"
onClick={scrollToBottom} onClick={scrollToBottom}
style={{ style={{
bottom: '1.25rem', bottom: '1.25rem',
boxShadow: '0 0.125rem 0.5rem rgba(0, 0, 0, 0.3)', boxShadow: 'var(--gerbil-shadow-sm)',
position: 'absolute', position: 'absolute',
right: '1.25rem', right: '1.25rem',
zIndex: 10, zIndex: 10,

View file

@ -1,6 +1,6 @@
import { ActionIcon, Button, Group, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; import { ActionIcon, Button, Group, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { CheckboxWithTooltip } from '@/components/CheckboxWithTooltip'; import { CheckboxWithTooltip } from '@/components/CheckboxWithTooltip';
import { InfoTooltip } from '@/components/InfoTooltip'; import { InfoTooltip } from '@/components/InfoTooltip';
@ -14,11 +14,17 @@ export const AdvancedTab = () => {
noavx2, noavx2,
failsafe, failsafe,
debugmode, debugmode,
jinja,
jinjatools,
jinjakwargs,
setAdditionalArguments, setAdditionalArguments,
setPreLaunchCommands, setPreLaunchCommands,
setNoavx2, setNoavx2,
setFailsafe, setFailsafe,
setDebugmode, setDebugmode,
setJinja,
setJinjatools,
setJinjakwargs,
} = useLaunchConfigStore(); } = useLaunchConfigStore();
const [commandLineModalOpen, setCommandLineModalOpen] = useState(false); const [commandLineModalOpen, setCommandLineModalOpen] = useState(false);
const [backendSupport, setBackendSupport] = useState<{ const [backendSupport, setBackendSupport] = useState<{
@ -27,11 +33,30 @@ export const AdvancedTab = () => {
} | null>(null); } | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const handleAddArgument = (newArgument: string) => { const handleAddArgument = useCallback(
(newArgument: string) => {
const currentArgs = additionalArguments.trim(); const currentArgs = additionalArguments.trim();
const updatedArgs = currentArgs ? `${currentArgs} ${newArgument}` : newArgument; const updatedArgs = currentArgs ? `${currentArgs} ${newArgument}` : newArgument;
setAdditionalArguments(updatedArgs); setAdditionalArguments(updatedArgs);
}; },
[additionalArguments, setAdditionalArguments],
);
const handleJinjaChange = useCallback(
(val: boolean) => {
setJinja(val);
if (!val) setJinjatools(false);
},
[setJinja, setJinjatools],
);
const handleJinjatoolsChange = useCallback(
(val: boolean) => {
setJinjatools(val);
if (val) setJinja(true);
},
[setJinja, setJinjatools],
);
useEffect(() => { useEffect(() => {
const detectAccelerationSupport = async () => { const detectAccelerationSupport = async () => {
@ -86,11 +111,42 @@ export const AdvancedTab = () => {
label="Debug Mode" label="Debug Mode"
tooltip="Shows additional debug info in the terminal." tooltip="Shows additional debug info in the terminal."
/> />
<CheckboxWithTooltip
checked={jinja}
onChange={handleJinjaChange}
label="Use Jinja"
tooltip="Enables using jinja chat template formatting for chat completions endpoint."
/>
<CheckboxWithTooltip
checked={jinjatools}
onChange={handleJinjatoolsChange}
label="Jinja for Tools"
tooltip="Allows jinja even with tool calls. If unchecked, jinja will be disabled when tools are used."
/>
</SimpleGrid> </SimpleGrid>
</div> </div>
{(jinja || jinjatools) && (
<div> <div>
<Group mb="xs" justify="space-between"> <Group mb="xs">
<Text size="sm" fw={500}>
Jinja Kwargs
</Text>
<InfoTooltip label="Set additional fields for the Jinja JSON template parser, must be a valid JSON object." />
</Group>
<TextInput
placeholder='e.g. {"enable_thinking":true}'
value={jinjakwargs}
onChange={(event) => setJinjakwargs(event.currentTarget.value)}
aria-label="Jinja kwargs"
/>
</div>
)}
<div>
<Group justify="space-between">
<Group> <Group>
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
Additional Arguments Additional Arguments
@ -102,14 +158,16 @@ export const AdvancedTab = () => {
</Button> </Button>
</Group> </Group>
<TextInput <TextInput
mt="xs"
placeholder="Additional command line arguments" placeholder="Additional command line arguments"
value={additionalArguments} value={additionalArguments}
onChange={(event) => setAdditionalArguments(event.currentTarget.value)} onChange={(event) => setAdditionalArguments(event.currentTarget.value)}
aria-label="Additional command line arguments"
/> />
</div> </div>
<div> <div>
<Group mb="xs"> <Group>
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
Pre-Launch Commands Pre-Launch Commands
</Text> </Text>
@ -117,7 +175,7 @@ export const AdvancedTab = () => {
</Group> </Group>
<Stack gap="xs"> <Stack gap="xs">
{preLaunchCommands.map((command, index) => ( {preLaunchCommands.map((command, index) => (
<Group key={index} gap="xs"> <Group key={`cmd-${index}`} gap="xs">
<TextInput <TextInput
placeholder="Enter a shell command" placeholder="Enter a shell command"
value={command} value={command}
@ -127,11 +185,13 @@ export const AdvancedTab = () => {
setPreLaunchCommands(newCommands); setPreLaunchCommands(newCommands);
}} }}
style={{ flex: 1 }} style={{ flex: 1 }}
aria-label={`Pre-launch command ${index + 1}`}
/> />
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="red" color="red"
disabled={preLaunchCommands.length === 1} disabled={preLaunchCommands.length === 1}
aria-label={`Remove command ${index + 1}`}
onClick={() => { onClick={() => {
const newCommands = preLaunchCommands.filter((_, i) => i !== index); const newCommands = preLaunchCommands.filter((_, i) => i !== index);
setPreLaunchCommands(newCommands.length === 0 ? [''] : newCommands); setPreLaunchCommands(newCommands.length === 0 ? [''] : newCommands);

View file

@ -59,6 +59,9 @@ const UI_COVERED_ARGS = new Set([
'--tensor_split', '--tensor_split',
'--debugmode', '--debugmode',
'--lowvram', '--lowvram',
'--jinja',
'--jinja_tools',
'--jinja_kwargs',
'--smartcache', '--smartcache',
'--pipelineparallel', '--pipelineparallel',
'--nopipelineparallel', '--nopipelineparallel',
@ -170,6 +173,15 @@ const COMMAND_LINE_ARGUMENTS = [
flag: '--useswa', flag: '--useswa',
type: 'boolean', type: 'boolean',
}, },
{
category: 'Advanced',
default: 512,
description:
'How much extra to pad the SWA KV cache, extending the SWA context window by the specified number of tokens. Only active when --useswa is enabled.',
flag: '--swapadding',
metavar: '[tokens]',
type: 'int',
},
{ {
category: 'Advanced', category: 'Advanced',
default: '0.0 10000.0', default: '0.0 10000.0',
@ -316,6 +328,26 @@ const COMMAND_LINE_ARGUMENTS = [
metavar: '[max px]', metavar: '[max px]',
type: 'int', type: 'int',
}, },
{
aliases: ['--image-min-tokens'],
category: 'Multimodal',
default: -1,
description:
'Override the minimum tokens for the MMProj vision embedding (default -1, use model default).',
flag: '--visionmintokens',
metavar: '[tokens]',
type: 'int',
},
{
aliases: ['--image-max-tokens'],
category: 'Multimodal',
default: -1,
description:
'Override the maximum tokens for the MMProj vision embedding (default -1, use model default).',
flag: '--visionmaxtokens',
metavar: '[tokens]',
type: 'int',
},
{ {
aliases: ['--model-draft', '-md'], aliases: ['--model-draft', '-md'],
category: 'Speculative Decoding', category: 'Speculative Decoding',
@ -396,30 +428,6 @@ const COMMAND_LINE_ARGUMENTS = [
flag: '--chatcompletionsadapter', flag: '--chatcompletionsadapter',
metavar: '[filename]', metavar: '[filename]',
}, },
{
category: 'Advanced',
description:
'Enables using jinja chat template formatting for chat completions endpoint. Other endpoints are unaffected. Tool calls are done without jinja.',
flag: '--jinja',
type: 'boolean',
},
{
aliases: ['--jinja-tools', '--jinjatools'],
category: 'Advanced',
description:
'Enables using jinja chat template formatting for chat completions endpoint. Other endpoints are unaffected. Tool calls are done with jinja.',
flag: '--jinja_tools',
type: 'boolean',
},
{
aliases: ['--jinja-kwargs', '--jinjakwargs', '--chat-template-kwargs'],
category: 'Advanced',
default: '',
description:
'Set additional fields for Jinja JSON template parser, must be a valid JSON object.',
flag: '--jinja_kwargs',
metavar: '{"parameter":"value",...}',
},
{ {
category: 'Advanced', category: 'Advanced',
description: description:
@ -550,6 +558,14 @@ const COMMAND_LINE_ARGUMENTS = [
flag: '--autoswapmode', flag: '--autoswapmode',
type: 'boolean', type: 'boolean',
}, },
{
category: 'Administration',
default: '',
description:
'Specify a base .kcpps config to apply if no custom base config is selected during a model swap. The config will be merged with the config being loaded.',
flag: '--baseconfig',
metavar: '[filename]',
},
{ {
category: 'Horde Worker', category: 'Horde Worker',
default: '', default: '',
@ -784,6 +800,15 @@ const COMMAND_LINE_ARGUMENTS = [
flag: '--mcpfile', flag: '--mcpfile',
metavar: '[mcp json file]', metavar: '[mcp json file]',
}, },
{
aliases: ['--chat-template-file'],
category: 'Advanced',
default: '',
description:
"Select a custom Jinja chat template file, overwriting the model's built-in Jinja chat template.",
flag: '--jinjatemplate',
metavar: '[filename]',
},
{ {
aliases: ['-dev'], aliases: ['-dev'],
category: 'Advanced', category: 'Advanced',
@ -906,7 +931,11 @@ export const CommandLineArgumentsModal = ({
gap="xs" gap="xs"
p="sm" p="sm"
style={{ style={{
borderLeft: '3px solid var(--mantine-color-blue-4)', background:
'light-dark(var(--mantine-color-brand-0), var(--mantine-color-dark-6))',
borderRadius: 'var(--mantine-radius-sm)',
border:
'1px solid light-dark(var(--mantine-color-brand-2), var(--mantine-color-dark-4))',
}} }}
> >
<Group gap="xs" wrap="wrap" justify="space-between"> <Group gap="xs" wrap="wrap" justify="space-between">
@ -918,7 +947,7 @@ export const CommandLineArgumentsModal = ({
</Code> </Code>
))} ))}
{arg.type && ( {arg.type && (
<Badge size="xs" variant="light" color="blue"> <Badge size="xs" variant="light" color="brand">
{arg.type} {arg.type}
</Badge> </Badge>
)} )}

View file

@ -140,6 +140,7 @@ export const ConfigFileManager = ({
onClick={handleDeleteClick} onClick={handleDeleteClick}
disabled={!selectedFile} disabled={!selectedFile}
color="red" color="red"
aria-label="Delete configuration"
style={{ padding: '0 0.5rem', width: '2.5rem' }} style={{ padding: '0 0.5rem', width: '2.5rem' }}
> >
<Trash2 size={16} /> <Trash2 size={16} />

View file

@ -36,7 +36,7 @@ export const AccelerationSelectItem = ({
discreteDevices.length > 0 && ( discreteDevices.length > 0 && (
<Group gap={4}> <Group gap={4}>
{discreteDevices.slice(0, 2).map((device, index) => ( {discreteDevices.slice(0, 2).map((device, index) => (
<Badge key={index} size="md" variant="light" color="blue"> <Badge key={index} size="md" variant="light" color="brand">
{renderDeviceName(device)} {renderDeviceName(device)}
</Badge> </Badge>
))} ))}

View file

@ -1,5 +1,5 @@
import { Checkbox, Group, Text, TextInput } from '@mantine/core'; import { Group, Switch, Text, TextInput } from '@mantine/core';
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { InfoTooltip } from '@/components/InfoTooltip'; import { InfoTooltip } from '@/components/InfoTooltip';
import { AccelerationSelectItem } from '@/components/screens/Launch/GeneralTab/AccelerationSelectItem'; import { AccelerationSelectItem } from '@/components/screens/Launch/GeneralTab/AccelerationSelectItem';
@ -32,6 +32,7 @@ export const AccelerationSelector = () => {
const loadAccelerations = async () => { const loadAccelerations = async () => {
setIsLoadingAccelerations(true); setIsLoadingAccelerations(true);
try {
const [accelerations, platform] = await Promise.all([ const [accelerations, platform] = await Promise.all([
window.electronAPI.kobold.getAvailableAccelerations(true), window.electronAPI.kobold.getAvailableAccelerations(true),
window.electronAPI.kobold.getPlatform(), window.electronAPI.kobold.getPlatform(),
@ -39,8 +40,13 @@ export const AccelerationSelector = () => {
setAvailableAccelerations(accelerations || []); setAvailableAccelerations(accelerations || []);
setIsMac(platform === 'darwin'); setIsMac(platform === 'darwin');
setIsLoadingAccelerations(false);
hasInitialized.current = true; hasInitialized.current = true;
} catch (error) {
window.electronAPI.logs.logError('Failed to load accelerations', error as Error);
setAvailableAccelerations([]);
} finally {
setIsLoadingAccelerations(false);
}
}; };
if (!hasInitialized.current) { if (!hasInitialized.current) {
@ -129,10 +135,25 @@ export const AccelerationSelector = () => {
setGpuLayers, setGpuLayers,
]); ]);
const renderAccelerationOption = useCallback(
({ option }: { option: { value: string; label: string } }) => {
const accelerationData = availableAccelerations.find((a) => a.value === option.value);
return (
<AccelerationSelectItem
label={accelerationData?.label ?? option.label.split(' (')[0]}
devices={accelerationData?.devices}
disabled={accelerationData?.disabled}
/>
);
},
[availableAccelerations],
);
return ( return (
<div> <div>
<Group justify="space-between" align="flex-start" mb="xs"> <Group justify="space-between" align="flex-start" mb="xs">
<div style={{ flex: 1, marginRight: '1rem' }}> <div style={{ flex: 1, marginRight: 'var(--mantine-spacing-md)' }}>
<Group gap="xs" align="center" mb="xs"> <Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
Acceleration Acceleration
@ -159,17 +180,7 @@ export const AccelerationSelector = () => {
value: a.value, value: a.value,
}))} }))}
disabled={isLoadingAccelerations || availableAccelerations.length === 0} disabled={isLoadingAccelerations || availableAccelerations.length === 0}
renderOption={({ option }) => { renderOption={renderAccelerationOption}
const accelerationData = availableAccelerations.find((a) => a.value === option.value);
return (
<AccelerationSelectItem
label={accelerationData?.label ?? option.label.split(' (')[0]}
devices={accelerationData?.devices}
disabled={accelerationData?.disabled}
/>
);
}}
/> />
</div> </div>
@ -180,16 +191,10 @@ export const AccelerationSelector = () => {
</Text> </Text>
<InfoTooltip label="The number of layers to offload to your GPU's VRAM. When Auto is enabled, this is calculated based on your model size, context size, available VRAM and flash attention settings." /> <InfoTooltip label="The number of layers to offload to your GPU's VRAM. When Auto is enabled, this is calculated based on your model size, context size, available VRAM and flash attention settings." />
</Group> </Group>
<Group gap="lg" align="center"> <Group gap="xs" align="center">
<TextInput <TextInput
value={autoGpuLayers ? '' : gpuLayers.toString()} value={gpuLayers.toString()}
placeholder={ placeholder={isCalculatingLayers ? 'Calculating...' : undefined}
autoGpuLayers
? isCalculatingLayers
? 'Calculating...'
: gpuLayers.toString()
: undefined
}
onChange={(event) => setGpuLayers(Number(event.target.value) || 0)} onChange={(event) => setGpuLayers(Number(event.target.value) || 0)}
type="number" type="number"
min={0} min={0}
@ -198,17 +203,15 @@ export const AccelerationSelector = () => {
size="sm" size="sm"
w={80} w={80}
disabled={autoGpuLayers || (acceleration === 'cpu' && !isMac)} disabled={autoGpuLayers || (acceleration === 'cpu' && !isMac)}
aria-label="GPU layers"
/> />
<Group gap="xs" align="center"> <Switch
<Checkbox
label="Auto" label="Auto"
checked={autoGpuLayers} checked={autoGpuLayers}
onChange={(event) => setAutoGpuLayers(event.currentTarget.checked)} onChange={(event) => setAutoGpuLayers(event.currentTarget.checked)}
size="sm" size="sm"
disabled={acceleration === 'cpu' && !isMac} disabled={acceleration === 'cpu' && !isMac}
/> />
<InfoTooltip label="Automatically calculate optimal GPU layers based on available VRAM. The calculation accounts for model size, context size and flash attention." />
</Group>
</Group> </Group>
</div> </div>
</Group> </Group>

View file

@ -83,7 +83,7 @@ export const GpuDeviceSelector = ({ availableAccelerations }: GpuDeviceSelectorP
return ( return (
<div> <div>
<Group align="flex-start" gap="md"> <Group align="flex-start" gap="md">
<div style={{ flex: 1, marginRight: '1rem' }}> <div style={{ flex: 1, marginRight: 'var(--mantine-spacing-md)' }}>
<Group gap="xs" align="center" mb="xs"> <Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
GPU Device GPU Device
@ -116,6 +116,7 @@ export const GpuDeviceSelector = ({ availableAccelerations }: GpuDeviceSelectorP
value={tensorSplit} value={tensorSplit}
onChange={(event) => setTensorSplit(event.target.value)} onChange={(event) => setTensorSplit(event.target.value)}
size="sm" size="sm"
aria-label="Tensor split"
/> />
</> </>
)} )}

View file

@ -17,7 +17,7 @@ export const GeneralTab = ({ configLoaded = true }: GeneralTabProps) => {
useEffect(() => { useEffect(() => {
if (!isSliding) { if (!isSliding) {
return; return undefined;
} }
const handlePointerUp = () => setIsSliding(false); const handlePointerUp = () => setIsSliding(false);
@ -69,6 +69,7 @@ export const GeneralTab = ({ configLoaded = true }: GeneralTabProps) => {
step={256} step={256}
size="sm" size="sm"
w={100} w={100}
aria-label="Context size"
/> />
</Group> </Group>
<div onPointerDown={() => setIsSliding(true)}> <div onPointerDown={() => setIsSliding(true)}>

View file

@ -31,12 +31,24 @@ export const FilesTable = ({ files, loading, onSelect }: FilesTableProps) => {
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>File</Table.Th> <Table.Th>File</Table.Th>
<Table.Th style={{ width: 120 }}>Size</Table.Th> <Table.Th style={{ width: '20%' }}>Size</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{files.map((file) => ( {files.map((file) => (
<Table.Tr key={file.path} onClick={() => onSelect(file)} style={{ cursor: 'pointer' }}> <Table.Tr
key={file.path}
onClick={() => onSelect(file)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(file);
}
}}
tabIndex={0}
role="button"
style={{ cursor: 'pointer' }}
>
<Table.Td> <Table.Td>
<Text size="sm" lineClamp={1}> <Text size="sm" lineClamp={1}>
{file.path} {file.path}

View file

@ -76,9 +76,10 @@ export const ModelCard = ({ modelId }: ModelCardProps) => {
}} }}
/> />
), ),
img: ({ node: _, ...props }) => ( img: ({ node: _, alt, ...props }) => (
<img <img
{...props} {...props}
alt={alt ?? ''}
style={{ style={{
maxWidth: '100%', maxWidth: '100%',
height: 'auto', height: 'auto',

View file

@ -41,14 +41,35 @@ export const ModelsTable = ({
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>Model</Table.Th> <Table.Th>Model</Table.Th>
<Table.Th style={{ width: 80 }}>Params</Table.Th> <Table.Th style={{ width: '10%' }}>Params</Table.Th>
<Table.Th <Table.Th
style={{ cursor: 'pointer', width: 120 }} style={{ cursor: 'pointer', width: '18%' }}
onClick={() => onSortChange('downloads')} onClick={() => onSortChange('downloads')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSortChange('downloads');
}
}}
tabIndex={0}
role="columnheader"
aria-sort={sortBy === 'downloads' ? 'descending' : 'none'}
> >
Downloads{sortBy === 'downloads' && ' ↓'} Downloads{sortBy === 'downloads' && ' ↓'}
</Table.Th> </Table.Th>
<Table.Th style={{ cursor: 'pointer', width: 90 }} onClick={() => onSortChange('likes')}> <Table.Th
style={{ cursor: 'pointer', width: '12%' }}
onClick={() => onSortChange('likes')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSortChange('likes');
}
}}
tabIndex={0}
role="columnheader"
aria-sort={sortBy === 'likes' ? 'descending' : 'none'}
>
Likes{sortBy === 'likes' && ' ↓'} Likes{sortBy === 'likes' && ' ↓'}
</Table.Th> </Table.Th>
</Table.Tr> </Table.Tr>
@ -64,6 +85,18 @@ export const ModelsTable = ({
onSelect(model); onSelect(model);
} }
}} }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (model.gated) {
void window.electronAPI.app.openExternal(`${HUGGINGFACE_BASE_URL}/${model.id}`);
} else {
onSelect(model);
}
}
}}
tabIndex={0}
role="button"
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
}} }}
@ -97,7 +130,7 @@ export const ModelsTable = ({
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
{model.paramSize && ( {model.paramSize && (
<Badge size="sm" variant="light" color="blue"> <Badge size="sm" variant="light" color="brand">
{model.paramSize} {model.paramSize}
</Badge> </Badge>
)} )}

View file

@ -136,7 +136,7 @@ export const HuggingFaceSearchModal = ({
<Stack gap="md" style={{ height: '100%' }}> <Stack gap="md" style={{ height: '100%' }}>
{selectedModel ? ( {selectedModel ? (
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<ActionIcon variant="subtle" onClick={handleBack}> <ActionIcon variant="subtle" aria-label="Back to search results" onClick={handleBack}>
<ArrowLeft size={18} /> <ArrowLeft size={18} />
</ActionIcon> </ActionIcon>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}> <Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
@ -148,7 +148,11 @@ export const HuggingFaceSearchModal = ({
</Text> </Text>
</Stack> </Stack>
<Tooltip label="Open on Hugging Face"> <Tooltip label="Open on Hugging Face">
<ActionIcon variant="subtle" onClick={handleOpenExternal}> <ActionIcon
variant="subtle"
aria-label="Open on Hugging Face"
onClick={handleOpenExternal}
>
<ExternalLink size={16} /> <ExternalLink size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>

View file

@ -45,6 +45,7 @@ export const ModelFileField = ({
const [modelAnalysis, setModelAnalysis] = useState<ModelAnalysis | null>(null); const [modelAnalysis, setModelAnalysis] = useState<ModelAnalysis | null>(null);
const [analysisLoading, setAnalysisLoading] = useState(false); const [analysisLoading, setAnalysisLoading] = useState(false);
const [analysisError, setAnalysisError] = useState<string>(); const [analysisError, setAnalysisError] = useState<string>();
const [analysisCache, setAnalysisCache] = useState<Map<string, ModelAnalysis>>(new Map());
const [cachedModels, setCachedModels] = useState<CachedModel[]>([]); const [cachedModels, setCachedModels] = useState<CachedModel[]>([]);
const [searchModalOpened, setSearchModalOpened] = useState(false); const [searchModalOpened, setSearchModalOpened] = useState(false);
const combobox = useCombobox(); const combobox = useCombobox();
@ -84,13 +85,21 @@ export const ModelFileField = ({
} }
setAnalysisModalOpened(true); setAnalysisModalOpened(true);
setAnalysisLoading(true);
setAnalysisError(undefined); setAnalysisError(undefined);
const cached = analysisCache.get(value);
if (cached) {
setModelAnalysis(cached);
return;
}
setAnalysisLoading(true);
setModelAnalysis(null); setModelAnalysis(null);
try { try {
const analysis = await window.electronAPI.kobold.analyzeModel(value); const analysis = await window.electronAPI.kobold.analyzeModel(value);
setModelAnalysis(analysis); setModelAnalysis(analysis);
setAnalysisCache((prev) => new Map(prev).set(value, analysis));
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to analyze model'; const errorMessage = error instanceof Error ? error.message : 'Failed to analyze model';
setAnalysisError(errorMessage); setAnalysisError(errorMessage);
@ -140,7 +149,12 @@ export const ModelFileField = ({
</Button> </Button>
{searchParams && ( {searchParams && (
<Tooltip label="Search Hugging Face"> <Tooltip label="Search Hugging Face">
<ActionIcon onClick={() => setSearchModalOpened(true)} variant="outline" size="lg"> <ActionIcon
onClick={() => setSearchModalOpened(true)}
variant="outline"
size="lg"
aria-label="Search Hugging Face"
>
<Search size={16} /> <Search size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@ -156,9 +170,10 @@ export const ModelFileField = ({
<ActionIcon <ActionIcon
onClick={() => void handleAnalyzeModel()} onClick={() => void handleAnalyzeModel()}
variant="light" variant="light"
color="blue" color="brand"
size="lg" size="lg"
disabled={validationState === 'neutral' || validationState === 'invalid'} disabled={validationState === 'neutral' || validationState === 'invalid'}
aria-label="Analyze model"
> >
<Info size={16} /> <Info size={16} />
</ActionIcon> </ActionIcon>

View file

@ -36,6 +36,7 @@ export const NetworkTab = () => {
placeholder="localhost" placeholder="localhost"
value={host} value={host}
onChange={(event) => setHost(event.currentTarget.value)} onChange={(event) => setHost(event.currentTarget.value)}
aria-label="Host"
/> />
</div> </div>
@ -65,7 +66,8 @@ export const NetworkTab = () => {
type="number" type="number"
min={1} min={1}
max={65_535} max={65_535}
w={120} w="6rem"
aria-label="Port"
/> />
</div> </div>
</Group> </Group>

View file

@ -1,4 +1,6 @@
import { Group, NumberInput, Select, SimpleGrid, Stack, Text } from '@mantine/core'; import { Group, NumberInput, Select, SimpleGrid, Stack, Text } from '@mantine/core';
import { useCallback } from 'react';
import type { CSSProperties } from 'react';
import { CheckboxWithTooltip } from '@/components/CheckboxWithTooltip'; import { CheckboxWithTooltip } from '@/components/CheckboxWithTooltip';
import { InfoTooltip } from '@/components/InfoTooltip'; import { InfoTooltip } from '@/components/InfoTooltip';
@ -9,8 +11,11 @@ const KV_QUANT_OPTIONS = [
{ value: '1', label: 'Q8 (8-bit)' }, { value: '1', label: 'Q8 (8-bit)' },
{ value: '2', label: 'Q4 (4-bit)' }, { value: '2', label: 'Q4 (4-bit)' },
{ value: '3', label: 'BF16' }, { value: '3', label: 'BF16' },
{ value: '4', label: 'Q5 (5-bit)' },
]; ];
const FLEX_COL: CSSProperties = { flex: 1, minWidth: 200 };
export const PerformanceTab = () => { export const PerformanceTab = () => {
const { const {
noshift, noshift,
@ -40,13 +45,16 @@ export const PerformanceTab = () => {
const quantkvActive = quantkv > 0; const quantkvActive = quantkv > 0;
const quantkvWithoutFlash = quantkvActive && !flashattention; const quantkvWithoutFlash = quantkvActive && !flashattention;
const handleQuantkvChange = (value: string | null) => { const handleQuantkvChange = useCallback(
(value: string | null) => {
const level = Number(value ?? '0'); const level = Number(value ?? '0');
setQuantkv(level); setQuantkv(level);
if (level > 0) { if (level > 0) {
setFlashattention(true); setFlashattention(true);
} }
}; },
[setQuantkv, setFlashattention],
);
return ( return (
<Stack gap="md"> <Stack gap="md">
@ -59,24 +67,13 @@ export const PerformanceTab = () => {
tooltip="Use Context Shifting to reduce reprocessing and improve performance with long contexts." tooltip="Use Context Shifting to reduce reprocessing and improve performance with long contexts."
/> />
<CheckboxWithTooltip
checked={noshift}
onChange={setNoshift}
label="No Shift"
tooltip="Disable context shifting. May reduce performance but can help with compatibility issues."
/>
<CheckboxWithTooltip <CheckboxWithTooltip
checked={smartcache} checked={smartcache}
onChange={setSmartcache} onChange={setSmartcache}
label="Smart Cache" label="Smart Cache"
tooltip="Enables intelligent context switching by saving KV cache snapshots to RAM. Requires fast forwarding." tooltip="Enables intelligent context switching by saving KV cache snapshots to RAM. Requires fast forwarding."
/> />
</SimpleGrid>
</div>
<div>
<SimpleGrid cols={3} spacing="lg" verticalSpacing="md">
<CheckboxWithTooltip <CheckboxWithTooltip
checked={flashattention} checked={flashattention}
onChange={setFlashattention} onChange={setFlashattention}
@ -135,8 +132,7 @@ export const PerformanceTab = () => {
<div> <div>
<Stack gap="md"> <Stack gap="md">
<Group gap="lg" align="flex-start" wrap="nowrap"> <div>
<div style={{ flex: 1, minWidth: 200 }}>
<Group gap="xs" align="center" mb="xs"> <Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
KV Cache Quantization KV Cache Quantization
@ -149,6 +145,7 @@ export const PerformanceTab = () => {
data={KV_QUANT_OPTIONS} data={KV_QUANT_OPTIONS}
size="sm" size="sm"
allowDeselect={false} allowDeselect={false}
aria-label="KV cache quantization"
/> />
{quantkvWithoutFlash && ( {quantkvWithoutFlash && (
<Text size="xs" c="red" mt={4}> <Text size="xs" c="red" mt={4}>
@ -158,11 +155,8 @@ export const PerformanceTab = () => {
)} )}
</div> </div>
<div style={{ flex: 1, minWidth: 200 }} />
</Group>
<Group gap="lg" align="flex-start" wrap="nowrap"> <Group gap="lg" align="flex-start" wrap="nowrap">
<div style={{ flex: 1, minWidth: 200 }}> <div style={FLEX_COL}>
<Group gap="xs" align="center" mb="xs"> <Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
MoE Experts MoE Experts
@ -176,10 +170,11 @@ export const PerformanceTab = () => {
max={128} max={128}
step={1} step={1}
size="sm" size="sm"
aria-label="MoE experts"
/> />
</div> </div>
<div style={{ flex: 1, minWidth: 200 }}> <div style={FLEX_COL}>
<Group gap="xs" align="center" mb="xs"> <Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
MoE CPU Layers MoE CPU Layers
@ -193,6 +188,7 @@ export const PerformanceTab = () => {
max={999} max={999}
step={1} step={1}
size="sm" size="sm"
aria-label="MoE CPU layers"
/> />
</div> </div>
</Group> </Group>

View file

@ -1,4 +1,4 @@
import { Button, Card, Container, Group, Stack, Tabs } from '@mantine/core'; import { Button, Container, Tabs } from '@mantine/core';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { AdvancedTab } from '@/components/screens/Launch/AdvancedTab'; import { AdvancedTab } from '@/components/screens/Launch/AdvancedTab';
@ -8,7 +8,7 @@ import { ImageGenerationTab } from '@/components/screens/Launch/ImageGenerationT
import { NetworkTab } from '@/components/screens/Launch/NetworkTab'; import { NetworkTab } from '@/components/screens/Launch/NetworkTab';
import { PerformanceTab } from '@/components/screens/Launch/PerformanceTab'; import { PerformanceTab } from '@/components/screens/Launch/PerformanceTab';
import { WarningDisplay } from '@/components/WarningDisplay'; import { WarningDisplay } from '@/components/WarningDisplay';
import { DEFAULT_MODEL_URL } from '@/constants'; import { DEFAULT_MODEL_URL, STATUSBAR_HEIGHT, TITLEBAR_HEIGHT } from '@/constants';
import { useLaunchLogic } from '@/hooks/useLaunchLogic'; import { useLaunchLogic } from '@/hooks/useLaunchLogic';
import { useWarnings } from '@/hooks/useWarnings'; import { useWarnings } from '@/hooks/useWarnings';
import { useLaunchConfigStore } from '@/stores/launchConfig'; import { useLaunchConfigStore } from '@/stores/launchConfig';
@ -68,6 +68,9 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
smartcache, smartcache,
pipelineparallel, pipelineparallel,
quantkv, quantkv,
jinja,
jinjatools,
jinjakwargs,
parseAndApplyConfigFile, parseAndApplyConfigFile,
loadConfigFromFile, loadConfigFromFile,
setModel, setModel,
@ -187,6 +190,9 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
usevulkan: acceleration === 'vulkan', usevulkan: acceleration === 'vulkan',
websearch, websearch,
quantkv, quantkv,
jinja,
jinjatools,
jinjakwargs,
}); });
const handleCreateNewConfig = async (configName: string) => { const handleCreateNewConfig = async (configName: string) => {
@ -301,6 +307,9 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
usemmap, usemmap,
websearch, websearch,
quantkv, quantkv,
jinja,
jinjatools,
jinjakwargs,
}); });
}, [ }, [
handleLaunch, handleLaunch,
@ -342,13 +351,42 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
smartcache, smartcache,
pipelineparallel, pipelineparallel,
quantkv, quantkv,
jinja,
jinjatools,
jinjakwargs,
]); ]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Enter') return;
if ((!model && !sdmodel) || isLaunching) return;
const target = event.target as HTMLElement;
const isInputFocused =
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable;
// Ctrl/Cmd+Enter always launches; bare Enter only when no input is focused
if (isInputFocused && !event.ctrlKey && !event.metaKey) return;
event.preventDefault();
handleLaunchClick();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleLaunchClick, model, sdmodel, isLaunching]);
return ( return (
<Container size="sm" mt="md"> <Container
<Stack gap="md"> size="sm"
<Card withBorder radius="md" shadow="sm" p="lg" style={{ position: 'relative' }}> style={{
<Stack gap="lg"> height: `calc(100svh - ${TITLEBAR_HEIGHT} - ${STATUSBAR_HEIGHT})`,
display: 'flex',
flexDirection: 'column',
paddingTop: 'var(--mantine-spacing-md)',
gap: 'var(--mantine-spacing-lg)',
}}
>
<ConfigFileManager <ConfigFileManager
configFiles={configFiles} configFiles={configFiles}
selectedFile={selectedFile} selectedFile={selectedFile}
@ -358,21 +396,16 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
onDeleteConfig={handleDeleteConfig} onDeleteConfig={handleDeleteConfig}
onLoadConfigFiles={loadConfigFiles} onLoadConfigFiles={loadConfigFiles}
/> />
<Tabs <Tabs
value={activeTab} value={activeTab}
onChange={setActiveTab} onChange={setActiveTab}
style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}
styles={{ styles={{
panel: { panel: {
flex: 1, flex: 1,
overflow: 'auto', overflow: 'auto',
paddingTop: '1rem', paddingTop: '1rem',
paddingRight: '0.5rem', paddingInline: '0.5rem',
},
root: {
maxHeight: '51vh',
display: 'flex',
flexDirection: 'column',
}, },
}} }}
> >
@ -404,8 +437,15 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
<AdvancedTab /> <AdvancedTab />
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>
<div
<Group justify="flex-end"> style={{
borderTop: '1px solid var(--mantine-color-default-border)',
padding: 'var(--mantine-spacing-md) 0',
display: 'flex',
justifyContent: 'flex-end',
flexShrink: 0,
}}
>
<WarningDisplay warnings={combinedWarnings}> <WarningDisplay warnings={combinedWarnings}>
<Button <Button
radius="md" radius="md"
@ -413,7 +453,7 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
onClick={handleLaunchClick} onClick={handleLaunchClick}
size="lg" size="lg"
variant="filled" variant="filled"
color="blue" color="brand"
style={{ style={{
fontSize: '1em', fontSize: '1em',
fontWeight: 600, fontWeight: 600,
@ -426,10 +466,7 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
Launch Launch
</Button> </Button>
</WarningDisplay> </WarningDisplay>
</Group> </div>
</Stack>
</Card>
</Stack>
</Container> </Container>
); );
}; };

View file

@ -1,17 +1,5 @@
import iconUrl from '/icon.png'; import iconUrl from '/icon.png';
import { import { Badge, Box, Button, Group, Stack, Text, Title } from '@mantine/core';
Button,
Card,
Container,
Group,
Image,
List,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import { Check } from 'lucide-react';
import { PRODUCT_NAME } from '@/constants'; import { PRODUCT_NAME } from '@/constants';
@ -19,79 +7,44 @@ interface WelcomeScreenProps {
onGetStarted: () => void; onGetStarted: () => void;
} }
const FEATURES = ['Chat & roleplay', 'Image generation', '100% local', 'CUDA · ROCm · Vulkan'];
export const WelcomeScreen = ({ onGetStarted }: WelcomeScreenProps) => ( export const WelcomeScreen = ({ onGetStarted }: WelcomeScreenProps) => (
<Container size="md" mt="md"> <Box
<Stack gap="xl"> style={{
<Card withBorder radius="md" shadow="sm" p="xl"> display: 'flex',
<Stack gap="lg" align="center"> alignItems: 'center',
<Stack gap="md" align="center"> justifyContent: 'center',
<Group gap="md" mr="xl" align="center"> minHeight: 'calc(100svh - 6rem)',
<Image src={iconUrl} alt={PRODUCT_NAME} w={36} h={36} /> }}
<Title order={1} ta="center">
{PRODUCT_NAME}
</Title>
</Group>
<Text size="lg" c="dimmed" ta="center" maw={600}>
Run Large Language Models locally
</Text>
</Stack>
<Stack gap="lg" w="100%" maw={600}>
<List
spacing="sm"
size="sm"
center
icon={
<ThemeIcon color="green" size={20} radius="xl">
<Check size={12} />
</ThemeIcon>
}
> >
<List.Item> <Stack gap={40} align="center" maw={480} style={{ width: '100%', textAlign: 'center' }}>
<Text> <Stack gap="lg" align="center">
<Text component="span" fw={500}> <img src={iconUrl} alt={PRODUCT_NAME} width={64} height={64} />
Chat with AI models <Stack gap="xs" align="center">
</Text>{' '} <Title order={1}>{PRODUCT_NAME}</Title>
- Have conversations, ask questions, get help with writing <Text size="lg" fw={500}>
</Text> Local LLMs, fully under your control.
</List.Item>
<List.Item>
<Text>
<Text component="span" fw={500}>
Generate images
</Text>{' '}
- Easily create artwork and illustrations with LLMs
</Text>
</List.Item>
<List.Item>
<Text>
<Text component="span" fw={500}>
Complete privacy
</Text>{' '}
- Everything runs on your computer, no data sent to servers
</Text>
</List.Item>
<List.Item>
<Text>
<Text component="span" fw={500}>
Hardware acceleration
</Text>{' '}
- Supports CUDA, ROCm and Vulkan backends
</Text>
</List.Item>
</List>
<Text size="xs" c="dimmed" ta="center" mt="sm">
Hardware acceleration requires appropriate drivers to be manually installed (CUDA for
NVIDIA GPUs, ROCm for AMD GPUs, etc.)
</Text> </Text>
</Stack> </Stack>
</Stack>
<Button size="lg" mt="lg" onClick={onGetStarted}> <Group gap="xs" justify="center" wrap="wrap">
{FEATURES.map((feature) => (
<Badge key={feature} size="lg" variant="light" color="brand" radius="sm">
{feature}
</Badge>
))}
</Group>
<Stack gap="xs" align="center">
<Button size="lg" onClick={onGetStarted} px="xl">
Get Started Get Started
</Button> </Button>
<Text size="xs" c="dimmed">
GPU acceleration requires CUDA, ROCm or Vulkan drivers.
</Text>
</Stack> </Stack>
</Card>
</Stack> </Stack>
</Container> </Box>
); );

View file

@ -1,12 +1,17 @@
import icon from '/icon.png'; import icon from '/icon.png';
import { Badge, Button, Card, Center, Group, Image, rem, Stack, Text } from '@mantine/core'; import { Badge, Button, Card, Center, Group, Image, rem, Stack, Text } from '@mantine/core';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { SiGithub } from 'react-icons/si';
import { GITHUB_API, PRODUCT_NAME } from '@/constants'; import { GITHUB_API, PRODUCT_NAME } from '@/constants';
import { useLogoClickSounds } from '@/hooks/useLogoClickSounds'; import { useLogoClickSounds } from '@/hooks/useLogoClickSounds';
import type { SystemVersionInfo } from '@/types/electron'; import type { SystemVersionInfo } from '@/types/electron';
const GitHubIcon = ({ style }: { style?: React.CSSProperties }) => (
<svg aria-hidden viewBox="0 0 24 24" fill="currentColor" style={style}>
<path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z" />
</svg>
);
export const AboutTab = () => { export const AboutTab = () => {
const [versionInfo, setVersionInfo] = useState<SystemVersionInfo | null>(null); const [versionInfo, setVersionInfo] = useState<SystemVersionInfo | null>(null);
const { handleLogoClick, getLogoStyles } = useLogoClickSounds(); const { handleLogoClick, getLogoStyles } = useLogoClickSounds();
@ -32,7 +37,7 @@ export const AboutTab = () => {
const actionButtons = [ const actionButtons = [
{ {
icon: SiGithub, icon: GitHubIcon,
label: 'GitHub', label: 'GitHub',
onClick: () => window.electronAPI.app.openExternal(GITHUB_API.GERBIL_GITHUB_URL), onClick: () => window.electronAPI.app.openExternal(GITHUB_API.GERBIL_GITHUB_URL),
}, },
@ -59,7 +64,7 @@ export const AboutTab = () => {
<Text size="xl" fw={600}> <Text size="xl" fw={600}>
{PRODUCT_NAME} {PRODUCT_NAME}
</Text> </Text>
<Badge variant="light" color="blue" size="lg" style={{ textTransform: 'none' }}> <Badge variant="light" color="brand" size="lg" style={{ textTransform: 'none' }}>
v{versionInfo.appVersion} v{versionInfo.appVersion}
</Badge> </Badge>
</Group> </Group>
@ -84,17 +89,17 @@ export const AboutTab = () => {
</Group> </Group>
</Card> </Card>
<Card withBorder radius="md" p="md"> <Stack gap="xs">
<Text size="lg" fw={500} mb="md"> <Text size="sm" fw={500} c="dimmed">
About {PRODUCT_NAME} About {PRODUCT_NAME}
</Text> </Text>
<Text size="sm" c="dimmed" mb="md"> <Text size="sm" c="dimmed">
{PRODUCT_NAME} is a user-friendly desktop application that makes it easy to run large {PRODUCT_NAME} is a user-friendly desktop application that makes it easy to run large
language models locally on your machine. Whether you&apos;re looking to chat with AI language models locally on your machine. Whether you&apos;re looking to chat with AI
models, generate images, or explore different interfaces like SillyTavern and Open WebUI,{' '} models, generate images, or explore different interfaces like SillyTavern and Open WebUI,{' '}
{PRODUCT_NAME} provides a streamlined experience for local AI. {PRODUCT_NAME} provides a streamlined experience for local AI.
</Text> </Text>
</Card> </Stack>
</Stack> </Stack>
); );
}; };

View file

@ -2,6 +2,7 @@ import { Group, rem, SegmentedControl, Slider, Stack, Text, TextInput } from '@m
import type { MantineColorScheme } from '@mantine/core'; import type { MantineColorScheme } from '@mantine/core';
import { Monitor, Moon, Sun } from 'lucide-react'; import { Monitor, Moon, Sun } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { CSSProperties } from 'react';
import { FrontendInterfaceSelector } from '@/components/settings/FrontendInterfaceSelector'; import { FrontendInterfaceSelector } from '@/components/settings/FrontendInterfaceSelector';
import { ZOOM } from '@/constants'; import { ZOOM } from '@/constants';
@ -13,13 +14,7 @@ interface AppearanceTabProps {
} }
export const AppearanceTab = ({ isOnInterfaceScreen = false }: AppearanceTabProps) => { export const AppearanceTab = ({ isOnInterfaceScreen = false }: AppearanceTabProps) => {
const { const { rawColorScheme, setColorScheme: setStoreColorScheme } = usePreferencesStore();
rawColorScheme,
resolvedColorScheme,
setColorScheme: setStoreColorScheme,
} = usePreferencesStore();
const isDark = resolvedColorScheme === 'dark';
const [zoomLevel, setZoomLevel] = useState(ZOOM.DEFAULT_LEVEL as number); const [zoomLevel, setZoomLevel] = useState(ZOOM.DEFAULT_LEVEL as number);
const [zoomPercentage, setZoomPercentage] = useState(ZOOM.DEFAULT_PERCENTAGE.toString()); const [zoomPercentage, setZoomPercentage] = useState(ZOOM.DEFAULT_PERCENTAGE.toString());
@ -74,15 +69,16 @@ export const AppearanceTab = ({ isOnInterfaceScreen = false }: AppearanceTabProp
fullWidth fullWidth
value={rawColorScheme} value={rawColorScheme}
onChange={handleColorSchemeChange} onChange={handleColorSchemeChange}
styles={(theme) => ({ style={
indicator: { {
backgroundColor: isDark ? theme.colors.dark[5] : theme.colors.gray[2], '--sc-indicator-color':
border: `1px solid ${isDark ? theme.colors.dark[4] : theme.colors.gray[4]}`, 'light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5))',
}, '--sc-indicator-border':
root: { 'light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))',
border: `0.5px solid ${isDark ? theme.colors.dark[4] : theme.colors.gray[4]}`, border:
}, '0.5px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))',
})} } as CSSProperties
}
data={[ data={[
{ {
label: ( label: (
@ -139,6 +135,7 @@ export const AppearanceTab = ({ isOnInterfaceScreen = false }: AppearanceTabProp
} }
size="sm" size="sm"
w={80} w={80}
aria-label="Zoom percentage"
styles={{ styles={{
input: { input: {
textAlign: 'center', textAlign: 'center',

View file

@ -244,7 +244,7 @@ export const BackendsTab = () => {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
size="sm" size="sm"
c="blue" c="brand"
> >
<Group gap={4} align="center"> <Group gap={4} align="center">
<span>Release notes</span> <span>Release notes</span>

View file

@ -199,10 +199,7 @@ export const FrontendInterfaceSelector = ({
}; };
useEffect(() => { useEffect(() => {
const initialize = async () => { void checkAllFrontendRequirements();
await checkAllFrontendRequirements();
};
void initialize();
const handleFocus = () => { const handleFocus = () => {
void checkAllFrontendRequirements(); void checkAllFrontendRequirements();
@ -210,13 +207,7 @@ export const FrontendInterfaceSelector = ({
window.addEventListener('focus', handleFocus); window.addEventListener('focus', handleFocus);
return () => window.removeEventListener('focus', handleFocus); return () => window.removeEventListener('focus', handleFocus);
}, [checkAllFrontendRequirements]); }, [checkAllFrontendRequirements, frontendPreference]);
useEffect(() => {
if (frontendPreference) {
void checkAllFrontendRequirements();
}
}, [frontendPreference, checkAllFrontendRequirements]);
return ( return (
<> <>

View file

@ -8,7 +8,7 @@ import {
SlidersHorizontal, SlidersHorizontal,
Wrench, Wrench,
} from 'lucide-react'; } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { Modal } from '@/components/Modal'; import { Modal } from '@/components/Modal';
import { AboutTab } from '@/components/settings/AboutTab'; import { AboutTab } from '@/components/settings/AboutTab';
@ -38,21 +38,6 @@ export const SettingsModal = ({
const effectiveActiveTab = !showBackendsTab && activeTab === 'backends' ? 'general' : activeTab; const effectiveActiveTab = !showBackendsTab && activeTab === 'backends' ? 'general' : activeTab;
useEffect(() => {
if (opened) {
const originalOverflow = document.body.style.overflow;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = 'hidden';
document.body.style.paddingRight = `${scrollbarWidth}px`;
return () => {
document.body.style.overflow = originalOverflow;
document.body.style.paddingRight = '';
};
}
}, [opened]);
return ( return (
<Modal <Modal
opened={opened} opened={opened}

View file

@ -24,7 +24,13 @@ export const SystemTab = () => {
setVersionInfo(info); setVersionInfo(info);
setKoboldVersion(currentBackend?.version ?? null); setKoboldVersion(currentBackend?.version ?? null);
} catch (error) {
window.electronAPI.logs.logError('Failed to load system info', error as Error);
} finally {
setLoading(false);
}
try {
const [cpu, gpu, gpuCapabilities, gpuMemory, systemMemory] = await Promise.all([ const [cpu, gpu, gpuCapabilities, gpuMemory, systemMemory] = await Promise.all([
window.electronAPI.kobold.detectCPU(), window.electronAPI.kobold.detectCPU(),
window.electronAPI.kobold.detectGPU(), window.electronAPI.kobold.detectGPU(),
@ -41,9 +47,7 @@ export const SystemTab = () => {
systemMemory, systemMemory,
}); });
} catch (error) { } catch (error) {
window.electronAPI.logs.logError('Failed to load system info', error as Error); window.electronAPI.logs.logError('Failed to load hardware info', error as Error);
} finally {
setLoading(false);
} }
}; };

View file

@ -44,6 +44,7 @@ export const TroubleshootingTab = () => {
readOnly readOnly
placeholder="Default installation directory" placeholder="Default installation directory"
style={{ flex: 1 }} style={{ flex: 1 }}
aria-label="Installation directory path"
leftSection={<Folder style={{ height: rem(16), width: rem(16) }} />} leftSection={<Folder style={{ height: rem(16), width: rem(16) }} />}
/> />
<Button <Button

View file

@ -14,6 +14,9 @@ export const SERVER_READY_SIGNALS = {
SILLYTAVERN: 'SillyTavern is listening on', SILLYTAVERN: 'SillyTavern is listening on',
} as const; } as const;
/** Buffer after server-ready signal before UI transitions — gives the HTTP server time to accept connections */
export const SERVER_READY_DELAY_MS = 3000;
export const DEFAULT_CONTEXT_SIZE = 4096; export const DEFAULT_CONTEXT_SIZE = 4096;
export const DEFAULT_MODEL_URL = `${HUGGINGFACE_BASE_URL}/MaziyarPanahi/gemma-3-4b-it-GGUF/resolve/main/gemma-3-4b-it.Q8_0.gguf`; export const DEFAULT_MODEL_URL = `${HUGGINGFACE_BASE_URL}/MaziyarPanahi/gemma-3-4b-it-GGUF/resolve/main/gemma-3-4b-it.Q8_0.gguf`;

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { GITHUB_API } from '@/constants'; import { GITHUB_API } from '@/constants';
import { withRetry } from '@/utils/logger';
import { compareVersions } from '@/utils/version'; import { compareVersions } from '@/utils/version';
interface AppUpdateInfo { interface AppUpdateInfo {
@ -61,8 +62,8 @@ export const useAppUpdateChecker = () => {
try { try {
const currentVersion = await window.electronAPI.app.getVersion(); const currentVersion = await window.electronAPI.app.getVersion();
const response = await fetch( const response = await withRetry(() =>
`${GITHUB_API.BASE_URL}/repos/${GITHUB_API.GERBIL_REPO}/releases/latest`, fetch(`${GITHUB_API.BASE_URL}/repos/${GITHUB_API.GERBIL_REPO}/releases/latest`),
); );
if (!response.ok) { if (!response.ok) {

View file

@ -89,6 +89,7 @@ export const useHuggingFaceSearch = (initialParams: HuggingFaceSearchParams) =>
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [selectedModel, setSelectedModel] = useState<HuggingFaceModelInfo>(); const [selectedModel, setSelectedModel] = useState<HuggingFaceModelInfo>();
const [sortBy, setSortBy] = useState<HuggingFaceSortOption>(initialParams.sort); const [sortBy, setSortBy] = useState<HuggingFaceSortOption>(initialParams.sort);
// oxlint-disable-next-line hook-use-state
const [searchParams] = useState(initialParams); const [searchParams] = useState(initialParams);
const pageRef = useRef(0); const pageRef = useRef(0);
const currentQueryRef = useRef<string | undefined>(undefined); const currentQueryRef = useRef<string | undefined>(undefined);

View file

@ -47,6 +47,9 @@ interface LaunchArgs {
smartcache: boolean; smartcache: boolean;
pipelineparallel: boolean; pipelineparallel: boolean;
quantkv: number; quantkv: number;
jinja: boolean;
jinjatools: boolean;
jinjakwargs: string;
} }
const buildModelArgs = (model: string, sdmodel: string, launchArgs: LaunchArgs) => { const buildModelArgs = (model: string, sdmodel: string, launchArgs: LaunchArgs) => {
@ -158,8 +161,19 @@ const buildConfigArgs = (isImageMode: boolean, launchArgs: LaunchArgs) => {
args.push('--pipelineparallel'); args.push('--pipelineparallel');
} }
if (launchArgs.quantkv > 0) { const quantkvMap: Record<number, string> = { 1: 'q8_0', 2: 'q4_0', 3: 'bf16', 4: 'q5_1' };
args.push('--quantkv', launchArgs.quantkv.toString()); if (launchArgs.quantkv > 0 && quantkvMap[launchArgs.quantkv]) {
args.push('--quantkv', quantkvMap[launchArgs.quantkv]);
}
if (launchArgs.jinjatools) {
args.push('--jinja_tools');
} else if (launchArgs.jinja) {
args.push('--jinja');
}
if ((launchArgs.jinja || launchArgs.jinjatools) && launchArgs.jinjakwargs.trim()) {
args.push('--jinja_kwargs', launchArgs.jinjakwargs.trim());
} }
return args; return args;

View file

@ -33,8 +33,12 @@ export const useLogoClickSounds = () => {
} catch {} } catch {}
}; };
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const getLogoStyles = () => ({ const getLogoStyles = () => ({
animation: isElephantMode animation: prefersReducedMotion
? 'none'
: isElephantMode
? 'elephantShake 1.5s ease-in-out' ? 'elephantShake 1.5s ease-in-out'
: isMouseSqueaking : isMouseSqueaking
? 'mouseSqueak 0.3s ease-in-out' ? 'mouseSqueak 0.3s ease-in-out'

View file

@ -112,9 +112,9 @@ export function getBackgroundColor() {
if (colorScheme === 'light') { if (colorScheme === 'light') {
return '#ffffff'; return '#ffffff';
} else if (colorScheme === 'dark') { } else if (colorScheme === 'dark') {
return '#1a1b1e'; return '#2a2d33';
} else { } else {
return nativeTheme.shouldUseDarkColors ? '#1a1b1e' : '#ffffff'; return nativeTheme.shouldUseDarkColors ? '#2a2d33' : '#ffffff';
} }
} }

View file

@ -1,7 +1,8 @@
import { cpus as osCpus, totalmem } from 'node:os';
import { platform } from 'node:process'; import { platform } from 'node:process';
import { execa } from 'execa'; import { execa } from 'execa';
import { cpu as siCpu, mem as siMem, memLayout as siMemLayout } from 'systeminformation'; import { memLayout as siMemLayout } from 'systeminformation';
import type { import type {
BasicGPUInfo, BasicGPUInfo,
@ -20,41 +21,39 @@ const COMMON_EXEC_OPTIONS = {
timeout: 3000, timeout: 3000,
}; };
const ROCM_EXEC_OPTIONS = {
...COMMON_EXEC_OPTIONS,
env: {
...process.env,
PATH: ['/opt/rocm/bin', process.env.PATH].filter(Boolean).join(':'),
},
};
let cpuCapabilitiesCache: CPUCapabilities | null = null; let cpuCapabilitiesCache: CPUCapabilities | null = null;
let basicGPUInfoCache: BasicGPUInfo | null = null; let basicGPUInfoCache: BasicGPUInfo | null = null;
let gpuCapabilitiesCache: GPUCapabilities | null = null; let gpuCapabilitiesCache: GPUCapabilities | null = null;
let gpuMemoryInfoCache: GPUMemoryInfo[] | null = null; let gpuMemoryInfoCache: GPUMemoryInfo[] | null = null;
export async function detectCPU() { export function detectCPU() {
if (cpuCapabilitiesCache) { if (cpuCapabilitiesCache) {
return cpuCapabilitiesCache; return cpuCapabilitiesCache;
} }
const result = await safeExecute(async () => { const cpuList = osCpus();
const cpu = await siCpu(); const brand = cpuList[0]?.model;
const cores = cpuList.length;
const speed = (cpuList[0]?.speed ?? 0) / 1000;
const devices: { name: string; detailedName: string }[] = []; const devices: { name: string; detailedName: string }[] = [];
if (cpu.brand) { if (brand) {
const name = formatDeviceName(cpu.brand); const name = formatDeviceName(brand);
devices.push({ devices.push({
detailedName: `${name} (${cpu.cores} cores) @ ${cpu.speed} GHz`, detailedName: `${name} (${cores} cores) @ ${speed} GHz`,
name, name,
}); });
} }
const capabilities = { cpuCapabilitiesCache = { devices };
devices,
};
cpuCapabilitiesCache = capabilities;
return capabilities;
}, 'CPU detection failed');
cpuCapabilitiesCache = result ?? {
devices: [],
};
return cpuCapabilitiesCache; return cpuCapabilitiesCache;
} }
@ -165,10 +164,11 @@ async function detectCUDA() {
export async function detectROCm() { export async function detectROCm() {
try { try {
const rocminfoCommand = platform === 'win32' ? 'hipInfo' : 'rocminfo'; const rocminfoCommand = platform === 'win32' ? 'hipInfo' : 'rocminfo';
const execOptions = platform === 'win32' ? COMMON_EXEC_OPTIONS : ROCM_EXEC_OPTIONS;
const [rocminfoResult, vulkanInfo, hipccVersion] = await Promise.all([ const [rocminfoResult, vulkanInfo, hipccVersion] = await Promise.all([
execa(rocminfoCommand, [], COMMON_EXEC_OPTIONS), execa(rocminfoCommand, [], execOptions),
getVulkanInfo(), getVulkanInfo(),
execa('hipcc', ['--version'], COMMON_EXEC_OPTIONS), execa('hipcc', ['--version'], execOptions),
]); ]);
const { stdout } = rocminfoResult; const { stdout } = rocminfoResult;
const { stdout: hipccOutput } = hipccVersion; const { stdout: hipccOutput } = hipccVersion;
@ -185,9 +185,7 @@ export async function detectROCm() {
} }
} catch {} } catch {}
if (stdout.trim()) { const getDriverVersion = async () => {
const devices = parseRocmOutput(stdout, vulkanInfo);
if (platform === 'win32') { if (platform === 'win32') {
try { try {
const { stdout: driverOutput } = await execa( const { stdout: driverOutput } = await execa(
@ -198,27 +196,31 @@ export async function detectROCm() {
], ],
COMMON_EXEC_OPTIONS, COMMON_EXEC_OPTIONS,
); );
if (driverOutput.trim()) return driverOutput.trim();
if (driverOutput.trim()) {
driverVersion = driverOutput.trim();
}
} catch {} } catch {}
} else { } else {
try {
for (const gpu of vulkanInfo.allGPUs) { for (const gpu of vulkanInfo.allGPUs) {
if (gpu.driverInfo && !gpu.isIntegrated) { if (gpu.driverInfo && !gpu.isIntegrated) return gpu.driverInfo;
driverVersion = gpu.driverInfo;
break;
} }
} }
} catch {} return undefined;
};
if (stdout.trim()) {
const devices = parseRocmOutput(stdout, vulkanInfo);
driverVersion = await getDriverVersion();
return { devices, driverVersion, version };
} }
return { if (version) {
devices, const amdDevices = vulkanInfo.allGPUs
driverVersion, .filter((gpu) => gpu.hasAMD && !gpu.isIntegrated)
version, .map((gpu) => ({ isIntegrated: false, name: formatDeviceName(gpu.name) }));
};
if (amdDevices.length > 0) {
driverVersion = await getDriverVersion();
return { devices: amdDevices, driverVersion, version };
}
} }
return { devices: [] }; return { devices: [] };
@ -359,10 +361,13 @@ export async function detectGPUMemory() {
} }
export const detectSystemMemory = async () => { export const detectSystemMemory = async () => {
try { const totalGB = (totalmem() / 1024 ** 3).toFixed(2);
const [memInfo, memLayout] = await Promise.all([siMem(), siMemLayout()]);
const totalGB = (memInfo.total / 1024 ** 3).toFixed(2); try {
const memLayout = await Promise.race([
siMemLayout(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
]);
let speed: number | undefined; let speed: number | undefined;
let type: string | undefined; let type: string | undefined;
@ -383,15 +388,8 @@ export const detectSystemMemory = async () => {
} }
} }
return { return { speed, totalGB, type };
speed,
totalGB,
type,
};
} catch { } catch {
const mem = await siMem(); return { totalGB };
return {
totalGB: (mem.total / 1024 ** 3).toFixed(2),
};
} }
}; };

View file

@ -15,6 +15,7 @@ import { stripAssetExtensions } from '@/utils/version';
import { getCurrentKoboldBinary, getInstallDir, setCurrentKoboldBinary } from '../config'; import { getCurrentKoboldBinary, getInstallDir, setCurrentKoboldBinary } from '../config';
import { getMainWindow, sendToRenderer } from '../window'; import { getMainWindow, sendToRenderer } from '../window';
import { clearBackendVersionCache, getVersionFromBinary } from './backend'; import { clearBackendVersionCache, getVersionFromBinary } from './backend';
import { isKoboldRunning } from './launcher';
async function removeDirectoryWithRetry(dirPath: string, maxRetries = 3, currentRetry = 0) { async function removeDirectoryWithRetry(dirPath: string, maxRetries = 3, currentRetry = 0) {
try { try {
@ -31,6 +32,10 @@ async function removeDirectoryWithRetry(dirPath: string, maxRetries = 3, current
} }
async function downloadFile(asset: GitHubAsset, tempPackedFilePath: string) { async function downloadFile(asset: GitHubAsset, tempPackedFilePath: string) {
try {
await unlink(tempPackedFilePath);
} catch {}
const writer = createWriteStream(tempPackedFilePath); const writer = createWriteStream(tempPackedFilePath);
const mainWindow = getMainWindow(); const mainWindow = getMainWindow();
@ -39,6 +44,7 @@ async function downloadFile(asset: GitHubAsset, tempPackedFilePath: string) {
const response = await fetch(asset.browser_download_url); const response = await fetch(asset.browser_download_url);
if (!response.ok || !response.body) { if (!response.ok || !response.body) {
writer.destroy();
throw new Error(`Failed to download: ${response.statusText}`); throw new Error(`Failed to download: ${response.statusText}`);
} }
@ -193,6 +199,10 @@ async function installBackend({
} }
export async function downloadRelease(asset: GitHubAsset, options: DownloadReleaseOptions) { export async function downloadRelease(asset: GitHubAsset, options: DownloadReleaseOptions) {
if (isKoboldRunning()) {
throw new Error('KoboldCpp is currently running. Stop it before updating.');
}
const tempPackedFilePath = join(getInstallDir(), `${asset.name}.packed`); const tempPackedFilePath = join(getInstallDir(), `${asset.name}.packed`);
const baseFilename = stripAssetExtensions(asset.name); const baseFilename = stripAssetExtensions(asset.name);
const folderName = asset.version ? `${baseFilename}-${asset.version}` : baseFilename; const folderName = asset.version ? `${baseFilename}-${asset.version}` : baseFilename;

View file

@ -26,6 +26,8 @@ import { filterSpam, patchKcppSduiEmbd, patchKliteEmbd, patchLcppGzEmbd } from '
let koboldProcess: ChildProcess | null = null; let koboldProcess: ChildProcess | null = null;
let isIntentionalStop = false; let isIntentionalStop = false;
export const isKoboldRunning = () => koboldProcess !== null;
let hasProcessStartedSuccessfully = false; let hasProcessStartedSuccessfully = false;
const preLaunchProcesses = new Set<ChildProcess>(); const preLaunchProcesses = new Set<ChildProcess>();
@ -197,11 +199,24 @@ export async function launchKoboldCpp(
const resolvedArgs = await resolveModelPaths(args); const resolvedArgs = await resolveModelPaths(args);
const finalArgs = resolvedArgs.filter((arg) => arg !== '--remotetunnel'); const finalArgs = resolvedArgs.filter((arg) => arg !== '--remotetunnel');
await stopProxy();
await startProxy(koboldHost, koboldPort); await startProxy(koboldHost, koboldPort);
const rocmEnv =
platform !== 'win32'
? {
HSA_OVERRIDE_GFX_VERSION: process.env.HSA_OVERRIDE_GFX_VERSION,
LD_LIBRARY_PATH: ['/opt/rocm/lib', '/opt/rocm/lib64', process.env.LD_LIBRARY_PATH]
.filter(Boolean)
.join(':'),
PATH: ['/opt/rocm/bin', process.env.PATH].filter(Boolean).join(':'),
}
: {};
const child = spawn(currentBackend.path, finalArgs, { const child = spawn(currentBackend.path, finalArgs, {
cwd: binaryDir, cwd: binaryDir,
detached: false, detached: false,
env: { ...process.env, ...rocmEnv },
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
}); });
@ -211,10 +226,6 @@ export async function launchKoboldCpp(
sendKoboldOutput(commandLine); sendKoboldOutput(commandLine);
if (remotetunnel) {
void startTunnel(frontendPreference);
}
let readyResolve: ((value: { success: boolean; pid?: number; error?: string }) => void) | null = let readyResolve: ((value: { success: boolean; pid?: number; error?: string }) => void) | null =
null; null;
let readyReject: ((error: Error) => void) | null = null; let readyReject: ((error: Error) => void) | null = null;
@ -240,6 +251,10 @@ export async function launchKoboldCpp(
} }
readyResolve?.({ pid: child.pid, success: true }); readyResolve?.({ pid: child.pid, success: true });
if (remotetunnel && isKoboldFrontend) {
void startTunnel(frontendPreference, true);
}
}; };
const handleOutput = (data: Buffer) => { const handleOutput = (data: Buffer) => {
@ -336,7 +351,7 @@ export const launchKoboldCppWithCustomFrontends = async (
const frontendPreference = getConfig('frontendPreference'); const frontendPreference = getConfig('frontendPreference');
const imageGenerationFrontendPreference = getConfig('imageGenerationFrontendPreference'); const imageGenerationFrontendPreference = getConfig('imageGenerationFrontendPreference');
const { isTextMode } = parseKoboldConfig(args); const { isTextMode, remotetunnel } = parseKoboldConfig(args);
const result = await launchKoboldCpp( const result = await launchKoboldCpp(
args, args,
@ -353,17 +368,29 @@ export const launchKoboldCppWithCustomFrontends = async (
} }
if (frontendPreference === 'sillytavern') { if (frontendPreference === 'sillytavern') {
startSillyTavernFrontend(args).catch((error) => { startSillyTavernFrontend(args)
.then(() => {
if (remotetunnel) {
void startTunnel(frontendPreference, true);
}
})
.catch((error) => {
logError('Failed to start SillyTavern frontend:', error); logError('Failed to start SillyTavern frontend:', error);
sendKoboldOutput( sendKoboldOutput(
`Failed to start SillyTavern: ${error instanceof Error ? error.message : String(error)}`, `Failed to start SillyTavern: ${error instanceof Error ? error.message : String(error)}`,
); );
}); });
} else if (frontendPreference === 'openwebui') { } else if (frontendPreference === 'openwebui') {
startOpenWebUIFrontend(args).catch((error) => { startOpenWebUIFrontend(args)
logError('Failed to start OpenWebUI frontend:', error); .then(() => {
if (remotetunnel) {
void startTunnel(frontendPreference, true);
}
})
.catch((error) => {
logError('Failed to start Open WebUI frontend:', error);
sendKoboldOutput( sendKoboldOutput(
`Failed to start OpenWebUI: ${error instanceof Error ? error.message : String(error)}`, `Failed to start Open WebUI: ${error instanceof Error ? error.message : String(error)}`,
); );
}); });
} }

View file

@ -60,6 +60,7 @@ export const patchKliteEmbd = (unpackedDir: string) =>
const possiblePaths = [ const possiblePaths = [
join(unpackedDir, '_internal', 'embd_res', 'klite.embd'), join(unpackedDir, '_internal', 'embd_res', 'klite.embd'),
join(unpackedDir, 'embd_res', 'klite.embd'), join(unpackedDir, 'embd_res', 'klite.embd'),
join(unpackedDir, 'klite.embd'),
]; ];
let kliteEmbdPath: string | null = null; let kliteEmbdPath: string | null = null;
@ -96,6 +97,7 @@ export const patchKcppSduiEmbd = (unpackedDir: string) =>
tryExecute(async () => { tryExecute(async () => {
const possiblePaths = [ const possiblePaths = [
join(unpackedDir, '_internal', 'embd_res', 'kcpp_sdui.embd'), join(unpackedDir, '_internal', 'embd_res', 'kcpp_sdui.embd'),
join(unpackedDir, 'embd_res', 'kcpp_sdui.embd'),
join(unpackedDir, 'kcpp_sdui.embd'), join(unpackedDir, 'kcpp_sdui.embd'),
]; ];
@ -113,6 +115,7 @@ export const patchLcppGzEmbd = (unpackedDir: string) =>
tryExecute(async () => { tryExecute(async () => {
const possiblePaths = [ const possiblePaths = [
join(unpackedDir, '_internal', 'embd_res', 'lcpp.gz.embd'), join(unpackedDir, '_internal', 'embd_res', 'lcpp.gz.embd'),
join(unpackedDir, 'embd_res', 'lcpp.gz.embd'),
join(unpackedDir, 'lcpp.gz.embd'), join(unpackedDir, 'lcpp.gz.embd'),
]; ];
@ -149,6 +152,8 @@ export function filterSpam(output: string) {
/^done_getting_tensors:/, /^done_getting_tensors:/,
/^sched_reserve:/, /^sched_reserve:/,
/^llama_memory_recurrent:/, /^llama_memory_recurrent:/,
/^str: cannot properly format tensor name/,
/^tensor .+ buffer type overridden to /,
]; ];
return output return output

View file

@ -142,6 +142,8 @@ async function downloadFile(
let downloadedBytes = 0; let downloadedBytes = 0;
let lastReportTime = Date.now(); let lastReportTime = Date.now();
let lastReportedBytes = 0; let lastReportedBytes = 0;
const speedSamples: number[] = [];
const SPEED_WINDOW = 10;
fileStream = createWriteStream(tempPath); fileStream = createWriteStream(tempPath);
@ -154,7 +156,10 @@ async function downloadFile(
if (timeDiff >= 0.5) { if (timeDiff >= 0.5) {
const bytesDiff = downloadedBytes - lastReportedBytes; const bytesDiff = downloadedBytes - lastReportedBytes;
const speedBytesPerSec = bytesDiff / timeDiff; const instantSpeed = bytesDiff / timeDiff;
speedSamples.push(instantSpeed);
if (speedSamples.length > SPEED_WINDOW) speedSamples.shift();
const speedBytesPerSec = speedSamples.reduce((a, b) => a + b, 0) / speedSamples.length;
const percent = totalBytes ? Math.round((downloadedBytes / totalBytes) * 100) : 0; const percent = totalBytes ? Math.round((downloadedBytes / totalBytes) * 100) : 0;
const downloadedMB = (downloadedBytes / 1024 / 1024).toFixed(2); const downloadedMB = (downloadedBytes / 1024 / 1024).toFixed(2);
const totalMB = (totalBytes / 1024 / 1024).toFixed(2); const totalMB = (totalBytes / 1024 / 1024).toFixed(2);

View file

@ -20,7 +20,10 @@ const replaceKoboldWithGerbil = (data: string) => {
const proxyRequest = (clientReq: IncomingMessage, clientRes: ServerResponse) => { const proxyRequest = (clientReq: IncomingMessage, clientRes: ServerResponse) => {
const options = { const options = {
headers: clientReq.headers, headers: {
...clientReq.headers,
host: `${koboldCppHost}:${koboldCppPort}`,
},
hostname: koboldCppHost, hostname: koboldCppHost,
method: clientReq.method, method: clientReq.method,
path: clientReq.url, path: clientReq.url,

View file

@ -36,19 +36,31 @@ const getCloudflaredAssetName = () => {
} }
}; };
const getCloudflaredDownloadUrl = async () => { const getCloudflaredLatestVersion = async () => {
const response = await fetch(GITHUB_API.CLOUDFLARED_LATEST_RELEASE_URL); const response = await fetch(GITHUB_API.CLOUDFLARED_LATEST_RELEASE_URL);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch latest cloudflared release: ${response.statusText}`); throw new Error(`Failed to fetch latest cloudflared release: ${response.statusText}`);
} }
const release = (await response.json()) as { tag_name: string }; const release = (await response.json()) as { tag_name: string };
return GITHUB_API.getCloudflaredDownloadUrl(release.tag_name, getCloudflaredAssetName()); return release.tag_name;
}; };
const downloadCloudflared = async (binPath: string) => { const getCloudflaredDownloadUrl = (version: string) =>
const url = await getCloudflaredDownloadUrl(); GITHUB_API.getCloudflaredDownloadUrl(version, getCloudflaredAssetName());
sendKoboldOutput(`Downloading cloudflared from ${url}...`);
const getInstalledCloudflaredVersion = async (binPath: string) => {
try {
const result = await execa(binPath, ['--version']);
const match = /(\d{4}\.\d+\.\d+)/.exec(result.stdout + result.stderr);
return match ? match[1] : null;
} catch {
return null;
}
};
const downloadCloudflared = async (binPath: string, version: string) => {
const url = getCloudflaredDownloadUrl(version);
sendKoboldOutput(`Downloading cloudflared ${version} from ${url}...`);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok || !response.body) { if (!response.ok || !response.body) {
@ -61,7 +73,7 @@ const downloadCloudflared = async (binPath: string) => {
await chmod(binPath, 0o755); await chmod(binPath, 0o755);
} }
sendKoboldOutput(`Downloaded cloudflared to ${binPath}`); sendKoboldOutput(`Downloaded cloudflared ${version} to ${binPath}`);
}; };
const getTunnelTarget = (frontendPreference: FrontendPreference) => { const getTunnelTarget = (frontendPreference: FrontendPreference) => {
@ -97,7 +109,10 @@ const waitForBackend = async (url: string, timeoutMs = 30_000) => {
return false; return false;
}; };
export const startTunnel = async (frontendPreference: FrontendPreference = 'koboldcpp') => { export const startTunnel = async (
frontendPreference: FrontendPreference = 'koboldcpp',
skipBackendCheck = false,
) => {
if (activeTunnel) { if (activeTunnel) {
return tunnelUrl; return tunnelUrl;
} }
@ -105,6 +120,7 @@ export const startTunnel = async (frontendPreference: FrontendPreference = 'kobo
try { try {
const tunnelTarget = getTunnelTarget(frontendPreference); const tunnelTarget = getTunnelTarget(frontendPreference);
if (!skipBackendCheck) {
sendKoboldOutput('Waiting for backend to be ready...'); sendKoboldOutput('Waiting for backend to be ready...');
const backendReady = await waitForBackend(tunnelTarget); const backendReady = await waitForBackend(tunnelTarget);
@ -113,20 +129,43 @@ export const startTunnel = async (frontendPreference: FrontendPreference = 'kobo
'Backend not ready after 30 seconds. Start your backend first before enabling tunnel.', 'Backend not ready after 30 seconds. Start your backend first before enabling tunnel.',
); );
} }
}
sendKoboldOutput('Starting Cloudflare tunnel...'); sendKoboldOutput(`Starting Cloudflare tunnel → ${tunnelTarget}`);
const bin = getCloudflaredBin(); const bin = getCloudflaredBin();
const latestVersion = await getCloudflaredLatestVersion();
const binExists = await access(bin) const binExists = await access(bin)
.then(() => true) .then(() => true)
.catch(() => false); .catch(() => false);
if (!binExists) { if (binExists) {
await downloadCloudflared(bin); const installedVersion = await getInstalledCloudflaredVersion(bin);
const normalizedInstalled = installedVersion?.replace(/^(\d{4}\.\d+\.\d+).*$/, '$1');
const normalizedLatest = latestVersion.replace(/^[v]?(\d{4}\.\d+\.\d+).*$/, '$1');
if (normalizedInstalled !== normalizedLatest) {
sendKoboldOutput(
`Updating cloudflared ${installedVersion ?? 'unknown'}${latestVersion}`,
);
await downloadCloudflared(bin, latestVersion);
} else {
sendKoboldOutput(`cloudflared ${installedVersion} is up to date`);
}
} else {
await downloadCloudflared(bin, latestVersion);
} }
const tunnel = execa(bin, ['tunnel', '--url', tunnelTarget, '--no-autoupdate']); const nullDevice = platform === 'win32' ? 'NUL' : '/dev/null';
const tunnel = execa(bin, [
'tunnel',
'--config',
nullDevice,
'--url',
tunnelTarget,
'--no-autoupdate',
]);
tunnel.catch(() => {});
activeTunnel = tunnel; activeTunnel = tunnel;
@ -134,19 +173,25 @@ export const startTunnel = async (frontendPreference: FrontendPreference = 'kobo
let output = ''; let output = '';
let urlFound = false; let urlFound = false;
tunnel.stderr?.on('data', (data: Buffer) => { const onTunnelOutput = (text: string) => {
const text = data.toString();
output += text; output += text;
if (text.includes('429') || text.includes('Too Many Requests')) { if (text.includes('429') || text.includes('Too Many Requests')) {
rateLimited = true; rateLimited = true;
} }
}); const match = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/.exec(text);
if (match && match[0] !== tunnelUrl) {
tunnelUrl = match[0];
if (urlFound) {
sendKoboldOutput(`Tunnel URL: ${tunnelUrl}`);
}
sendToRenderer('tunnel-url-changed', tunnelUrl);
}
};
tunnel.stdout?.on('data', (data: Buffer) => { tunnel.stderr?.on('data', (data: Buffer) => onTunnelOutput(data.toString()));
output += data.toString(); tunnel.stdout?.on('data', (data: Buffer) => onTunnelOutput(data.toString()));
});
const url = await new Promise<string>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
const message = rateLimited const message = rateLimited
? 'Cloudflare rate limit exceeded. Please wait a few minutes and try again.' ? 'Cloudflare rate limit exceeded. Please wait a few minutes and try again.'
@ -155,17 +200,14 @@ export const startTunnel = async (frontendPreference: FrontendPreference = 'kobo
reject(new Error(message)); reject(new Error(message));
}, 30_000); }, 30_000);
const checkForUrl = () => { const checkInterval = setInterval(() => {
const match = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/.exec(output); if (tunnelUrl) {
if (match) {
urlFound = true; urlFound = true;
clearTimeout(timeout); clearTimeout(timeout);
clearInterval(checkInterval); clearInterval(checkInterval);
resolve(match[0]); resolve();
} }
}; }, 100);
const checkInterval = setInterval(checkForUrl, 100);
tunnel.once('error', (error) => { tunnel.once('error', (error) => {
clearTimeout(timeout); clearTimeout(timeout);
@ -182,11 +224,7 @@ export const startTunnel = async (frontendPreference: FrontendPreference = 'kobo
}); });
}); });
await new Promise((resolve) => setTimeout(resolve, 3000));
tunnelUrl = url;
sendKoboldOutput(`Tunnel ready at ${tunnelUrl}`); sendKoboldOutput(`Tunnel ready at ${tunnelUrl}`);
sendToRenderer('tunnel-url-changed', tunnelUrl);
tunnel.on('error', (error: Error) => { tunnel.on('error', (error: Error) => {
logError(`Tunnel error: ${error.message}`, error); logError(`Tunnel error: ${error.message}`, error);

View file

@ -61,6 +61,7 @@ let memoryInterval: ReturnType<typeof setInterval> | null = null;
let gpuInterval: ReturnType<typeof setInterval> | null = null; let gpuInterval: ReturnType<typeof setInterval> | null = null;
let isRunning = false; let isRunning = false;
const updateFrequency = 1000; const updateFrequency = 1000;
const memoryUpdateFrequency = platform === 'win32' ? 3000 : updateFrequency;
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let latestCpuMetrics: CpuMetrics | null = null; let latestCpuMetrics: CpuMetrics | null = null;
@ -87,7 +88,7 @@ export function startMonitoring(window: BrowserWindow) {
void collectAndSendMemoryMetrics(); void collectAndSendMemoryMetrics();
memoryInterval = setInterval(() => { memoryInterval = setInterval(() => {
void collectAndSendMemoryMetrics(); void collectAndSendMemoryMetrics();
}, updateFrequency); }, memoryUpdateFrequency);
if (platform === 'linux') { if (platform === 'linux') {
void collectAndSendGpuMetrics(); void collectAndSendGpuMetrics();

View file

@ -97,6 +97,7 @@ export async function startFrontend(args: string[]) {
const envConfig: Record<string, string> = { const envConfig: Record<string, string> = {
DATA_DIR: openWebUIDataDir, DATA_DIR: openWebUIDataDir,
OPENSSL_CONF: '/dev/null',
ENABLE_OLLAMA_API: 'false', ENABLE_OLLAMA_API: 'false',
ENABLE_VERSION_UPDATE_CHECK: 'false', ENABLE_VERSION_UPDATE_CHECK: 'false',
GLOBAL_LOG_LEVEL: 'warning', GLOBAL_LOG_LEVEL: 'warning',

View file

@ -1,5 +1,6 @@
import type { ChildProcess } from 'node:child_process'; import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { readFile, unlink, writeFile } from 'node:fs/promises';
import { createServer, request } from 'node:http'; import { createServer, request } from 'node:http';
import type { Server } from 'node:http'; import type { Server } from 'node:http';
import { join } from 'node:path'; import { join } from 'node:path';
@ -10,7 +11,7 @@ import { PROXY } from '@/constants/proxy';
import { pathExists, readJsonFile, writeJsonFile } from '@/utils/node/fs'; import { pathExists, readJsonFile, writeJsonFile } from '@/utils/node/fs';
import { parseKoboldConfig } from '@/utils/node/kobold'; import { parseKoboldConfig } from '@/utils/node/kobold';
import { logError, tryExecute } from '@/utils/node/logging'; import { logError, tryExecute } from '@/utils/node/logging';
import { terminateProcess } from '@/utils/node/process'; import { killProcessByPid, terminateProcess } from '@/utils/node/process';
import { getInstallDir } from './config'; import { getInstallDir } from './config';
import { getNodeEnvironment } from './dependencies'; import { getNodeEnvironment } from './dependencies';
@ -39,6 +40,27 @@ const getSillyTavernServerPath = () =>
const getSillyTavernSettingsPath = () => const getSillyTavernSettingsPath = () =>
join(getSillyTavernDataDir(), 'default-user', 'settings.json'); join(getSillyTavernDataDir(), 'default-user', 'settings.json');
const getSillyTavernPidPath = () => join(getSillyTavernInstallDir(), 'sillytavern.pid');
async function saveSillyTavernPid(pid: number) {
await writeFile(getSillyTavernPidPath(), String(pid), 'utf8').catch(() => {});
}
async function clearSillyTavernPid() {
await unlink(getSillyTavernPidPath()).catch(() => {});
}
async function killOrphanedSillyTavern() {
const pidPath = getSillyTavernPidPath();
if (!(await pathExists(pidPath))) return;
const raw = await readFile(pidPath, 'utf8').catch(() => null);
const pid = raw ? parseInt(raw.trim(), 10) : NaN;
if (!isNaN(pid)) {
await killProcessByPid(pid);
}
await clearSillyTavernPid();
}
async function ensureSillyTavernInstalled() { async function ensureSillyTavernInstalled() {
const serverPath = getSillyTavernServerPath(); const serverPath = getSillyTavernServerPath();
const installDir = getSillyTavernInstallDir(); const installDir = getSillyTavernInstallDir();
@ -73,7 +95,10 @@ async function ensureSillyTavernInstalled() {
sendKoboldOutput('Installing SillyTavern via npm...'); sendKoboldOutput('Installing SillyTavern via npm...');
} }
return new Promise<void>((resolve, reject) => { await killOrphanedSillyTavern();
const runNpmInstall = () =>
new Promise<void>((resolve, reject) => {
const npmProcess = spawn( const npmProcess = spawn(
'npm', 'npm',
[ [
@ -105,17 +130,31 @@ async function ensureSillyTavernInstalled() {
sendKoboldOutput('SillyTavern is ready'); sendKoboldOutput('SillyTavern is ready');
resolve(); resolve();
} else { } else {
if (errorOutput) { reject(Object.assign(new Error(`npm install failed with code ${code}`), { errorOutput }));
sendKoboldOutput(`npm install error: ${errorOutput.trim()}`);
}
reject(new Error(`npm install failed with code ${code}`));
} }
}); });
npmProcess.on('error', (error) => { npmProcess.on('error', reject);
reject(error);
});
}); });
try {
await runNpmInstall();
} catch (err) {
const isBusy =
err instanceof Error && (err.message.includes('4294963214') || err.message.includes('-4082'));
if (isBusy && platform === 'win32') {
sendKoboldOutput('File busy, retrying npm install...');
await new Promise<void>((resolve) => setTimeout(resolve, 2000));
await runNpmInstall();
} else {
const output = (err as { errorOutput?: string }).errorOutput;
if (output) {
sendKoboldOutput(`npm install error: ${output.trim()}`);
}
throw err;
}
}
} }
async function createNpxProcess(args: string[]) { async function createNpxProcess(args: string[]) {
@ -289,8 +328,15 @@ async function waitForSillyTavernToStart() {
const createProxyServer = (targetPort: number, proxyPort: number) => { const createProxyServer = (targetPort: number, proxyPort: number) => {
proxyServer = createServer((req, res) => { proxyServer = createServer((req, res) => {
const headers = { ...req.headers };
delete headers['x-forwarded-for'];
delete headers['x-real-ip'];
delete headers['cf-connecting-ip'];
delete headers['x-forwarded-host'];
delete headers.forwarded;
const options = { const options = {
headers: req.headers, headers,
hostname: 'localhost', hostname: 'localhost',
method: req.method, method: req.method,
path: req.url, path: req.url,
@ -298,9 +344,9 @@ const createProxyServer = (targetPort: number, proxyPort: number) => {
}; };
const proxyReq = request(options, (proxyRes) => { const proxyReq = request(options, (proxyRes) => {
const headers = { ...proxyRes.headers }; const responseHeaders = { ...proxyRes.headers };
delete headers['x-frame-options']; delete responseHeaders['x-frame-options'];
res.writeHead(proxyRes.statusCode ?? 200, headers); res.writeHead(proxyRes.statusCode ?? 200, responseHeaders);
proxyRes.pipe(res); proxyRes.pipe(res);
}); });
@ -341,6 +387,8 @@ export async function startFrontend(args: string[]) {
sendKoboldOutput(`Starting ${config.name} frontend on port ${config.port}...`); sendKoboldOutput(`Starting ${config.name} frontend on port ${config.port}...`);
createProxyServer(config.port, config.proxyPort);
const sillyTavernDataDir = getSillyTavernDataDir(); const sillyTavernDataDir = getSillyTavernDataDir();
const sillyTavernArgs = [ const sillyTavernArgs = [
@ -353,6 +401,10 @@ export async function startFrontend(args: string[]) {
sillyTavernProcess = await createNpxProcess(sillyTavernArgs); sillyTavernProcess = await createNpxProcess(sillyTavernArgs);
if (sillyTavernProcess.pid) {
void saveSillyTavernPid(sillyTavernProcess.pid);
}
if (sillyTavernProcess.stdout) { if (sillyTavernProcess.stdout) {
sillyTavernProcess.stdout.on('data', (data: Buffer) => { sillyTavernProcess.stdout.on('data', (data: Buffer) => {
sendKoboldOutput(data.toString(), true); sendKoboldOutput(data.toString(), true);
@ -371,6 +423,7 @@ export async function startFrontend(args: string[]) {
: `SillyTavern exited with code ${code}`; : `SillyTavern exited with code ${code}`;
sendKoboldOutput(message); sendKoboldOutput(message);
sillyTavernProcess = null; sillyTavernProcess = null;
void clearSillyTavernPid();
}); });
sillyTavernProcess.on('error', (error) => { sillyTavernProcess.on('error', (error) => {
@ -381,12 +434,12 @@ export async function startFrontend(args: string[]) {
}); });
await waitForSillyTavernToStart(); await waitForSillyTavernToStart();
createProxyServer(config.port, config.proxyPort);
} catch (error) { } catch (error) {
logError( logError(
`Failed to start SillyTavern: ${error instanceof Error ? error.message : String(error)}`, `Failed to start SillyTavern: ${error instanceof Error ? error.message : String(error)}`,
error as Error, error as Error,
); );
await stopFrontend();
throw error; throw error;
} }
} }
@ -410,4 +463,10 @@ export async function stopFrontend() {
} }
await Promise.all(promises); await Promise.all(promises);
if (platform === 'win32') {
await new Promise<void>((resolve) => {
setTimeout(resolve, 1000);
});
}
} }

View file

@ -148,6 +148,10 @@ export async function createMainWindow(options?: { startHidden?: boolean }) {
}, },
})); }));
mainWindow.webContents.on('did-create-window', (popupWindow) => {
setupContextMenu(popupWindow);
});
mainWindow.on('close', (event) => { mainWindow.on('close', (event) => {
saveBounds(); saveBounds();
if (getEnableSystemTray() && isTrayActive()) { if (getEnableSystemTray() && isTrayActive()) {

View file

@ -9,7 +9,7 @@ import type {
ReleaseWithStatus, ReleaseWithStatus,
} from '@/types/electron'; } from '@/types/electron';
import { sortDownloadsByType } from '@/utils/assets'; import { sortDownloadsByType } from '@/utils/assets';
import { logError, safeExecute } from '@/utils/logger'; import { logError, safeExecute, withRetry } from '@/utils/logger';
import { filterAssetsByPlatform } from '@/utils/platform'; import { filterAssetsByPlatform } from '@/utils/platform';
import { getROCmDownload } from '@/utils/rocm'; import { getROCmDownload } from '@/utils/rocm';
@ -154,7 +154,7 @@ export const useKoboldBackendsStore = create<KoboldBackendsState>((set, get) =>
const platform = await window.electronAPI.kobold.getPlatform(); const platform = await window.electronAPI.kobold.getPlatform();
set({ platform, loadingPlatform: false }); set({ platform, loadingPlatform: false });
const downloads = await fetchDownloads(platform); const downloads = await withRetry(() => fetchDownloads(platform));
set({ availableDownloads: downloads }); set({ availableDownloads: downloads });
} catch (error) { } catch (error) {
logError('Failed to initialize store:', error as Error); logError('Failed to initialize store:', error as Error);

View file

@ -45,6 +45,9 @@ interface LaunchConfigState {
smartcache: boolean; smartcache: boolean;
pipelineparallel: boolean; pipelineparallel: boolean;
quantkv: number; quantkv: number;
jinja: boolean;
jinjatools: boolean;
jinjakwargs: string;
isImageGenerationMode: boolean; isImageGenerationMode: boolean;
isTextMode: boolean; isTextMode: boolean;
@ -88,6 +91,9 @@ interface LaunchConfigState {
setSmartcache: (smartcache: boolean) => void; setSmartcache: (smartcache: boolean) => void;
setPipelineparallel: (pipelineparallel: boolean) => void; setPipelineparallel: (pipelineparallel: boolean) => void;
setQuantkv: (quantkv: number) => void; setQuantkv: (quantkv: number) => void;
setJinja: (jinja: boolean) => void;
setJinjatools: (jinjatools: boolean) => void;
setJinjakwargs: (jinjakwargs: string) => void;
parseAndApplyConfigFile: (configPath: string) => Promise<void>; parseAndApplyConfigFile: (configPath: string) => Promise<void>;
loadConfigFromFile: ( loadConfigFromFile: (
@ -400,11 +406,32 @@ export const useLaunchConfigStore = create<LaunchConfigState>((set, get) => ({
updates.quantkv = 0; updates.quantkv = 0;
} }
if (typeof configData.jinja === 'boolean') {
updates.jinja = configData.jinja;
} else {
updates.jinja = false;
}
if (typeof configData.jinjatools === 'boolean') {
updates.jinjatools = configData.jinjatools;
} else {
updates.jinjatools = false;
}
if (typeof configData.jinjakwargs === 'string') {
updates.jinjakwargs = configData.jinjakwargs;
} else {
updates.jinjakwargs = '';
}
set(updates); set(updates);
} }
}, },
pipelineparallel: false, pipelineparallel: false,
quantkv: 0, quantkv: 0,
jinja: false,
jinjatools: false,
jinjakwargs: '',
port: undefined, port: undefined,
preLaunchCommands: [''], preLaunchCommands: [''],
quantmatmul: true, quantmatmul: true,
@ -482,6 +509,9 @@ export const useLaunchConfigStore = create<LaunchConfigState>((set, get) => ({
setSdvae: (vae) => set({ sdvae: vae }), setSdvae: (vae) => set({ sdvae: vae }),
setSdvaecpu: (enabled) => set({ sdvaecpu: enabled }), setSdvaecpu: (enabled) => set({ sdvaecpu: enabled }),
setSmartcache: (smartcache) => set({ smartcache }), setSmartcache: (smartcache) => set({ smartcache }),
setJinja: (jinja) => set({ jinja }),
setJinjatools: (jinjatools) => set({ jinjatools }),
setJinjakwargs: (jinjakwargs) => set({ jinjakwargs }),
setTensorSplit: (split) => set({ tensorSplit: split }), setTensorSplit: (split) => set({ tensorSplit: split }),
setUsemmap: (usemmap) => set({ usemmap }), setUsemmap: (usemmap) => set({ usemmap }),

View file

@ -1,21 +1,50 @@
@import '@mantine/core/styles.css'; @import '@mantine/core/styles.css';
@import '@fontsource/inter/latin-400.css'; @import '@fontsource/geist/latin-400.css';
@import '@fontsource/inter/latin-500.css'; @import '@fontsource/geist/latin-500.css';
@import '@fontsource/inter/latin-600.css'; @import '@fontsource/geist/latin-600.css';
@import '@fontsource/barlow-semi-condensed/latin-500.css';
@import '@fontsource/barlow-semi-condensed/latin-600.css';
/* Heading letter-spacing — Barlow Semi Condensed reads tighter at display sizes */
.mantine-Title-root[data-order='1'],
.mantine-Title-root[data-order='2'] {
letter-spacing: -0.01em;
}
:root {
--gerbil-window-border: light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.08));
--gerbil-surface-secondary: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
--gerbil-scrollbar-thumb: color-mix(in srgb, var(--mantine-color-gray-5) 50%, transparent);
--gerbil-scrollbar-thumb-hover: color-mix(in srgb, var(--mantine-color-gray-5) 80%, transparent);
--gerbil-scrollbar-thumb-active: color-mix(in srgb, var(--mantine-color-gray-7) 90%, transparent);
--gerbil-shadow-sm: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.3);
}
[data-mantine-color-scheme='dark'] {
--gerbil-scrollbar-thumb: color-mix(in srgb, var(--mantine-color-gray-4) 50%, transparent);
--gerbil-scrollbar-thumb-hover: color-mix(in srgb, var(--mantine-color-gray-4) 80%, transparent);
--gerbil-scrollbar-thumb-active: color-mix(in srgb, var(--mantine-color-gray-3) 90%, transparent);
}
#root { #root {
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
border-left: 1px solid light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.08)); border-left: 1px solid var(--gerbil-window-border);
border-right: 1px solid light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.08)); border-right: 1px solid var(--gerbil-window-border);
box-sizing: border-box; box-sizing: border-box;
} }
[data-mantine-color-scheme='light'] .mantine-Select-option:hover { [data-mantine-color-scheme='light'] .mantine-Select-option:hover {
background-color: #e9ecef; background-color: var(--mantine-color-gray-2);
} }
/* Custom scrollbars */ /* Custom scrollbars */
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--gerbil-scrollbar-thumb) transparent;
}
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 0.5rem; width: 0.5rem;
height: 0.5rem; height: 0.5rem;
@ -27,64 +56,54 @@
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(134, 142, 150, 0.5); background: var(--gerbil-scrollbar-thumb);
border-radius: 0.25rem; border-radius: 0.25rem;
transition: all 0.2s ease; transition: background 0.2s ease;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(134, 142, 150, 0.8); background: var(--gerbil-scrollbar-thumb-hover);
} }
::-webkit-scrollbar-thumb:active { ::-webkit-scrollbar-thumb:active {
background: rgba(73, 80, 87, 0.9); background: var(--gerbil-scrollbar-thumb-active);
} }
/* Dark mode scrollbar support */ /* Dark mode scrollbar support */
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb { [data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb {
background: rgba(173, 181, 189, 0.5); background: var(--gerbil-scrollbar-thumb);
} }
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:hover { [data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:hover {
background: rgba(173, 181, 189, 0.8); background: var(--gerbil-scrollbar-thumb-hover);
} }
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:active { [data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:active {
background: rgba(206, 212, 218, 0.9); background: var(--gerbil-scrollbar-thumb-active);
} }
/* TitleBar gerbil icon animations */ /* TitleBar gerbil icon animations */
@keyframes elephantShake { @keyframes elephantShake {
0%, 0% {
100% { transform: scale(1) rotate(0deg);
transform: scale(1.1) rotate(5deg) translateX(0);
} }
10% { 15% {
transform: scale(1.2) rotate(-3deg) translateX(-2px); transform: scale(1.2) rotate(-6deg) translateX(-2px);
}
20% {
transform: scale(1.3) rotate(8deg) translateX(2px);
} }
30% { 30% {
transform: scale(1.2) rotate(-5deg) translateX(-1px); transform: scale(1.2) rotate(6deg) translateX(2px);
} }
40% { 45% {
transform: scale(1.4) rotate(10deg) translateX(3px); transform: scale(1.2) rotate(-4deg) translateX(-1px);
}
50% {
transform: scale(1.2) rotate(-8deg) translateX(-2px);
} }
60% { 60% {
transform: scale(1.3) rotate(6deg) translateX(1px); transform: scale(1.2) rotate(4deg) translateX(1px);
}
70% {
transform: scale(1) rotate(-4deg) translateX(-1px);
} }
80% { 80% {
transform: scale(1.2) rotate(3deg) translateX(2px); transform: scale(1.1) rotate(-2deg);
} }
90% { 100% {
transform: scale(1) rotate(-2deg) translateX(-1px); transform: scale(1) rotate(0deg);
} }
} }

View file

@ -1,32 +1,47 @@
import { createTheme } from '@mantine/core'; import { createTheme, v8CssVariablesResolver } from '@mantine/core';
import type { CSSVariablesResolver } from '@mantine/core'; import type { CSSVariablesResolver } from '@mantine/core';
export const theme = createTheme({ export const theme = createTheme({
black: '#101113', black: '#101113',
primaryColor: 'brand',
colors: { colors: {
// Steel-blue: technical, enthusiast-grade — vibrant but cooler than Mantine default
brand: [
'#eef5ff', // 0 — faint wash
'#d8eaff', // 1
'#b0d0ff', // 2
'#7aaff7', // 3
'#4890f0', // 4 — dark mode accent
'#2b72e0', // 5
'#1e5ec8', // 6 — light primary
'#184eb0', // 7
'#113d88', // 8 — dark primary
'#0c2d64', // 9 — deep
],
// OKLCH tinted toward steel-blue hue 240 — creates subconscious cohesion with brand accent
gray: [ gray: [
'#f8f9fa', 'oklch(97.5% 0.005 240)', // 0 — near-white surface
'#f1f3f4', 'oklch(95% 0.006 240)', // 1 — subtle wash
'#e9ecef', 'oklch(92% 0.007 240)', // 2 — light bg
'#dee2e6', 'oklch(89% 0.007 240)', // 3 — border
'#ced4da', 'oklch(84% 0.008 240)', // 4 — input border
'#adb5bd', 'oklch(73% 0.009 240)', // 5 — secondary text / muted
'#6c757d', 'oklch(50% 0.009 240)', // 6 — mid text
'#495057', 'oklch(37% 0.008 240)', // 7 — dark label
'#343a40', 'oklch(26% 0.007 240)', // 8 — dark text
'#212529', 'oklch(18% 0.006 240)', // 9 — near-black
], ],
dark: [ dark: [
'#c1c2c5', 'oklch(79% 0.008 240)', // 0 — main text
'#a6a7ab', 'oklch(70% 0.008 240)', // 1 — secondary text
'#909296', 'oklch(61% 0.008 240)', // 2 — dimmed
'#5c5f66', 'oklch(48% 0.009 240)', // 3 — borders
'#373a40', 'oklch(40% 0.010 240)', // 4 — input background
'#2c2e33', 'oklch(32% 0.010 240)', // 5 — card background
'#25262b', 'oklch(25% 0.009 240)', // 6 — component background
'#1a1b1e', 'oklch(20% 0.008 240)', // 7 — raised surface
'#141517', 'oklch(15% 0.007 240)', // 8 — body
'#101113', 'oklch(11% 0.006 240)', // 9 — deepest
], ],
}, },
components: { components: {
@ -52,22 +67,35 @@ export const theme = createTheme({
}, },
}, },
}, },
fontFamily: 'Inter, sans-serif', fontFamily: 'Geist, sans-serif',
headings: { headings: {
fontFamily: 'Inter, sans-serif', fontFamily: "'Barlow Semi Condensed', sans-serif",
sizes: {
h1: { fontWeight: '600', lineHeight: '1.15' },
h2: { fontWeight: '600', lineHeight: '1.2' },
h3: { fontWeight: '500', lineHeight: '1.25' },
h4: { fontWeight: '500', lineHeight: '1.3' },
},
}, },
white: '#fafafa', white: '#fafafa',
}); });
export const cssVariablesResolver: CSSVariablesResolver = () => ({ export const cssVariablesResolver: CSSVariablesResolver = (t) => {
const v8 = v8CssVariablesResolver(t);
return {
variables: { ...v8.variables },
dark: { dark: {
'--mantine-color-body': '#0f0f0f', ...v8.dark,
'--mantine-color-default-border': '#2a2a2a', '--mantine-color-body': 'oklch(22% 0.008 240)',
'--mantine-color-default-border': 'oklch(48% 0.009 240)',
'--gerbil-link-color': 'var(--mantine-color-brand-4)',
}, },
light: { light: {
'--mantine-color-body': '#fafafa', ...v8.light,
'--mantine-color-body': 'oklch(97.5% 0.005 240)',
'--mantine-color-white': '#fafafa', '--mantine-color-white': '#fafafa',
'--mantine-color-default-border': '#dee2e6', '--mantine-color-default-border': 'oklch(89% 0.007 240)',
'--gerbil-link-color': 'var(--mantine-color-brand-6)',
}, },
variables: {}, };
}); };

View file

@ -120,6 +120,9 @@ export interface KoboldConfig {
smartcache?: boolean; smartcache?: boolean;
pipelineparallel?: boolean; pipelineparallel?: boolean;
quantkv?: number; quantkv?: number;
jinja?: boolean;
jinjatools?: boolean;
jinjakwargs?: string;
autoGpuLayers?: boolean; autoGpuLayers?: boolean;
model?: string; model?: string;
backend?: string; backend?: string;

View file

@ -2,6 +2,21 @@ export const logError = (message: string, error: Error) => {
window.electronAPI.logs.logError(message, error); window.electronAPI.logs.logError(message, error);
}; };
export const withRetry = async <T>(operation: () => Promise<T>, retries = 3, delayMs = 3000) => {
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error;
if (attempt < retries) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
throw lastError;
};
export const safeExecute = async <T>(operation: () => Promise<T>, errorMessage: string) => { export const safeExecute = async <T>(operation: () => Promise<T>, errorMessage: string) => {
try { try {
return await operation(); return await operation();

View file

@ -1,3 +1,3 @@
import { env } from 'node:process'; import { app } from 'electron';
export const isDevelopment = env.NODE_ENV === 'development'; export const isDevelopment = !app.isPackaged;

View file

@ -151,7 +151,10 @@ async function getLinuxGPUData() {
async function getWindowsGPUData() { async function getWindowsGPUData() {
try { try {
const graphics = await siGraphics(); const graphics = await Promise.race([
siGraphics(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
]);
const discreteControllers = graphics.controllers.filter( const discreteControllers = graphics.controllers.filter(
(controller) => controller.vram && controller.vram >= 1024, (controller) => controller.vram && controller.vram >= 1024,

View file

@ -11,6 +11,16 @@ async function killWindowsProcessTree(pid: number) {
} catch {} } catch {}
} }
export async function killProcessByPid(pid: number) {
if (platform === 'win32') {
await killWindowsProcessTree(pid);
} else {
try {
process.kill(pid, 'SIGKILL');
} catch {}
}
}
export async function terminateProcess(childProcess: ChildProcess | null) { export async function terminateProcess(childProcess: ChildProcess | null) {
if (!childProcess?.pid) { if (!childProcess?.pid) {
return; return;

View file

@ -12,6 +12,14 @@ interface VramCalculationParams {
acceleration: Acceleration; acceleration: Acceleration;
} }
interface LayerKvInfo {
headCountKvByLayer: number[];
globalHeadDim: number;
swaHeadDim: number;
slidingWindow: number;
isSwaByLayer: boolean[];
}
function getAccelerationOverhead(acceleration: Acceleration) { function getAccelerationOverhead(acceleration: Acceleration) {
switch (acceleration) { switch (acceleration) {
case 'cuda': { case 'cuda': {
@ -36,17 +44,27 @@ function getAccelerationOverhead(acceleration: Acceleration) {
function estimateContextVram( function estimateContextVram(
contextSize: number, contextSize: number,
layers: number, layers: number,
kvDim: number,
flashAttention: boolean, flashAttention: boolean,
layerKvInfo: LayerKvInfo | number,
) { ) {
const bytesPerElement = 2; const bytesPerElement = 2;
let kvCacheSizeBytes = 2 * contextSize * layers * kvDim * bytesPerElement; const flashAttnFactor = flashAttention ? 0.5 : 1;
if (flashAttention) { if (typeof layerKvInfo === 'number') {
kvCacheSizeBytes *= 0.5; return (2 * contextSize * layers * layerKvInfo * bytesPerElement * flashAttnFactor) / 1024 ** 3;
} }
return kvCacheSizeBytes / 1024 ** 3; const { headCountKvByLayer, globalHeadDim, swaHeadDim, slidingWindow, isSwaByLayer } =
layerKvInfo;
let totalBytes = 0;
for (let i = 0; i < layers; i++) {
const isSwa = isSwaByLayer[i] ?? false;
const effectiveContext = isSwa ? Math.min(contextSize, slidingWindow) : contextSize;
const headDim = isSwa ? swaHeadDim : globalHeadDim;
const kvHeads = headCountKvByLayer[i] ?? headCountKvByLayer[headCountKvByLayer.length - 1];
totalBytes += 2 * effectiveContext * kvHeads * headDim * bytesPerElement * flashAttnFactor;
}
return totalBytes / 1024 ** 3;
} }
export async function calculateOptimalGpuLayers({ export async function calculateOptimalGpuLayers({
@ -86,11 +104,45 @@ export async function calculateOptimalGpuLayers({
const totalLayers = (metadataRecord[`${architecture}.block_count`] as number) || 32; const totalLayers = (metadataRecord[`${architecture}.block_count`] as number) || 32;
const embeddingLength = (metadataRecord[`${architecture}.embedding_length`] as number) || 4096; const embeddingLength = (metadataRecord[`${architecture}.embedding_length`] as number) || 4096;
const headCount = (metadataRecord[`${architecture}.attention.head_count`] as number) || 32; const headCount = (metadataRecord[`${architecture}.attention.head_count`] as number) || 32;
const headCountKv =
(metadataRecord[`${architecture}.attention.head_count_kv`] as number) || headCount;
const headDim = embeddingLength / headCount; const headCountKvRaw = metadataRecord[`${architecture}.attention.head_count_kv`];
const kvDim = headCountKv * headDim; const headCountKvByLayer = Array.isArray(headCountKvRaw) ? (headCountKvRaw as number[]) : null;
const headCountKvScalar = headCountKvByLayer
? Math.max(...headCountKvByLayer)
: (headCountKvRaw as number) || headCount;
const keyLength = metadataRecord[`${architecture}.attention.key_length`] as number | undefined;
const valueLength = metadataRecord[`${architecture}.attention.value_length`] as
| number
| undefined;
const keyLengthSwa = metadataRecord[`${architecture}.attention.key_length_swa`] as
| number
| undefined;
const valueLengthSwa = metadataRecord[`${architecture}.attention.value_length_swa`] as
| number
| undefined;
const globalHeadDim = keyLength ?? valueLength ?? embeddingLength / headCount;
const swaHeadDim = keyLengthSwa ?? valueLengthSwa ?? globalHeadDim;
const slidingWindow = metadataRecord[`${architecture}.attention.sliding_window`] as
| number
| undefined;
const slidingWindowPatternRaw =
metadataRecord[`${architecture}.attention.sliding_window_pattern`];
const isSwaByLayer = Array.isArray(slidingWindowPatternRaw)
? (slidingWindowPatternRaw as boolean[])
: null;
const layerKvInfo: LayerKvInfo | number =
(headCountKvByLayer ?? (slidingWindow !== undefined && isSwaByLayer))
? {
headCountKvByLayer: headCountKvByLayer ?? Array(totalLayers).fill(headCountKvScalar),
globalHeadDim,
swaHeadDim,
slidingWindow: slidingWindow ?? contextSize,
isSwaByLayer: isSwaByLayer ?? Array(totalLayers).fill(false),
}
: headCountKvScalar * globalHeadDim;
const { multiplier, computeBufferGB, headroomGB } = getAccelerationOverhead(acceleration); const { multiplier, computeBufferGB, headroomGB } = getAccelerationOverhead(acceleration);
@ -104,7 +156,7 @@ export async function calculateOptimalGpuLayers({
for (let layers = 1; layers <= totalLayers; layers++) { for (let layers = 1; layers <= totalLayers; layers++) {
const modelVram = layers * vramPerLayerGB; const modelVram = layers * vramPerLayerGB;
const contextVram = estimateContextVram(contextSize, layers, kvDim, flashAttention); const contextVram = estimateContextVram(contextSize, layers, flashAttention, layerKvInfo);
const totalVram = modelVram + contextVram; const totalVram = modelVram + contextVram;
if (totalVram <= availableForModel) { if (totalVram <= availableForModel) {
@ -115,7 +167,12 @@ export async function calculateOptimalGpuLayers({
} }
const modelVramGB = recommendedLayers * vramPerLayerGB; const modelVramGB = recommendedLayers * vramPerLayerGB;
const contextVramGB = estimateContextVram(contextSize, recommendedLayers, kvDim, flashAttention); const contextVramGB = estimateContextVram(
contextSize,
recommendedLayers,
flashAttention,
layerKvInfo,
);
return { return {
contextVramGB, contextVramGB,

View file

@ -38,14 +38,18 @@ export const initializeAudio = async () => {
audio.pause(); audio.pause();
audio.currentTime = 0; audio.currentTime = 0;
audio.volume = 0.5; audio.volume = 0.5;
} catch {} } catch (err) {
window.electronAPI.logs.logError(`Failed to init audio for ${soundUrl}: ${String(err)}`);
}
audioCache.set(soundUrl, audio); audioCache.set(soundUrl, audio);
}); });
await Promise.allSettled(initPromises); await Promise.allSettled(initPromises);
audioInitialized = true; audioInitialized = true;
} catch {} } catch (err) {
window.electronAPI.logs.logError(`initializeAudio failed: ${String(err)}`);
}
}; };
export const playSound = async (soundUrl: string, volume = 0.5) => { export const playSound = async (soundUrl: string, volume = 0.5) => {
@ -63,5 +67,7 @@ export const playSound = async (soundUrl: string, volume = 0.5) => {
audio.volume = volume; audio.volume = volume;
audio.currentTime = 0; audio.currentTime = 0;
await audio.play(); await audio.play();
} catch {} } catch (err) {
window.electronAPI.logs.logError(`playSound failed for ${soundUrl}: ${String(err)}`);
}
}; };

View file

@ -10,23 +10,33 @@ export const handleTerminalOutput = (prevContent: string, newData: string) => {
const URL_REGEX = /(https?:\/\/[^\s<>"{}|\\^`[\]]+)/gi; const URL_REGEX = /(https?:\/\/[^\s<>"{}|\\^`[\]]+)/gi;
const linkifyText = (text: string) => const escapeHtml = (text: string) =>
text.replace(URL_REGEX, (url) => { text
const cleanUrl = url.replace(/[.,;:!?]+$/, '');
const trailingPunctuation = url.slice(cleanUrl.length);
return `<a href="${cleanUrl}" target="_blank" rel="noopener noreferrer" style="color: #339af0; text-decoration: underline; cursor: pointer;">${cleanUrl}</a>${trailingPunctuation}`;
});
const escapeHtmlExceptLinks = (text: string) => {
const escaped = text
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
.replace(/"/g, '&quot;') .replace(/"/g, '&quot;')
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
return linkifyText(escaped); const linkifyAndEscape = (text: string) => {
const parts: string[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
URL_REGEX.lastIndex = 0;
while ((match = URL_REGEX.exec(text)) !== null) {
parts.push(escapeHtml(text.slice(lastIndex, match.index)));
const rawUrl = match[0];
const cleanUrl = rawUrl.replace(/[.,;:!?]+$/, '');
const trailingPunctuation = rawUrl.slice(cleanUrl.length);
parts.push(
`<a href="${escapeHtml(cleanUrl)}" target="_blank" rel="noopener noreferrer" style="color: var(--mantine-color-brand-5); text-decoration: underline; cursor: pointer;">${escapeHtml(cleanUrl)}</a>${escapeHtml(trailingPunctuation)}`,
);
lastIndex = match.index + rawUrl.length;
}
parts.push(escapeHtml(text.slice(lastIndex)));
return parts.join('');
}; };
export const processTerminalContent = (content: string) => { export const processTerminalContent = (content: string) => {
@ -34,5 +44,5 @@ export const processTerminalContent = (content: string) => {
return ''; return '';
} }
return escapeHtmlExceptLinks(content); return linkifyAndEscape(content);
}; };