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,8 +29,8 @@ 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>
) )
@ -38,7 +38,15 @@ export const WarningDisplay = ({ warnings, children }: WarningDisplayProps) => {
multiline multiline
maw={320} maw={320}
> >
<AlertTriangle size={18} color="var(--mantine-color-orange-6)" strokeWidth={2} /> <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} />
</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,66 +59,62 @@ 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>
) : ( ) : (
<> <>
{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}>
<DownloadCard <DownloadCard
backend={{ backend={{
downloadUrl: download.url, downloadUrl: download.url,
hasUpdate: false, hasUpdate: false,
isCurrent: false, isCurrent: false,
isInstalled: false, isInstalled: false,
name: download.name, name: download.name,
size: download.size, size: download.size,
version: download.version ?? '', version: download.version ?? '',
}} }}
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(); void handleDownload(download);
void handleDownload(download); }}
}} />
/> </div>
</div> );
); })}
})} </Stack>
</Stack> ) : (
) : ( <Text size="sm" c="dimmed" ta="center">
<Text size="sm" c="dimmed" ta="center"> Unable to fetch downloads for your platform ({getPlatformDisplayName(platform)}).
Unable to fetch downloads for your platform ({getPlatformDisplayName(platform)}). Check your internet connection and try again.
Check your internet connection and try again. </Text>
</Text> )}
)}
<ImportBackendLink <ImportBackendLink
disabled={Boolean(downloading)} disabled={Boolean(downloading)}
onSuccess={onDownloadComplete} onSuccess={onDownloadComplete}
onImportingChange={setImporting} onImportingChange={setImporting}
/> />
</> </>
)} )}
</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(
const currentArgs = additionalArguments.trim(); (newArgument: string) => {
const updatedArgs = currentArgs ? `${currentArgs} ${newArgument}` : newArgument; const currentArgs = additionalArguments.trim();
setAdditionalArguments(updatedArgs); const updatedArgs = currentArgs ? `${currentArgs} ${newArgument}` : newArgument;
}; 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>
<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> <div>
<Group mb="xs" justify="space-between"> <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,15 +32,21 @@ export const AccelerationSelector = () => {
const loadAccelerations = async () => { const loadAccelerations = async () => {
setIsLoadingAccelerations(true); setIsLoadingAccelerations(true);
const [accelerations, platform] = await Promise.all([ try {
window.electronAPI.kobold.getAvailableAccelerations(true), const [accelerations, platform] = await Promise.all([
window.electronAPI.kobold.getPlatform(), window.electronAPI.kobold.getAvailableAccelerations(true),
]); window.electronAPI.kobold.getPlatform(),
]);
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"
/>
<Switch
label="Auto"
checked={autoGpuLayers}
onChange={(event) => setAutoGpuLayers(event.currentTarget.checked)}
size="sm"
disabled={acceleration === 'cpu' && !isMac}
/> />
<Group gap="xs" align="center">
<Checkbox
label="Auto"
checked={autoGpuLayers}
onChange={(event) => setAutoGpuLayers(event.currentTarget.checked)}
size="sm"
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(
const level = Number(value ?? '0'); (value: string | null) => {
setQuantkv(level); const level = Number(value ?? '0');
if (level > 0) { setQuantkv(level);
setFlashattention(true); if (level > 0) {
} 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,34 +132,31 @@ 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 </Text>
</Text> <InfoTooltip label="Quantize the KV cache to reduce VRAM usage. Works best with Flash Attention (auto-enabled). May conflict with Context Shifting." />
<InfoTooltip label="Quantize the KV cache to reduce VRAM usage. Works best with Flash Attention (auto-enabled). May conflict with Context Shifting." /> </Group>
</Group> <Select
<Select value={String(quantkv)}
value={String(quantkv)} onChange={handleQuantkvChange}
onChange={handleQuantkvChange} 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}>
Flash Attention is off. Only K cache will be quantized; performance and VRAM may Flash Attention is off. Only K cache will be quantized; performance and VRAM may
suffer. suffer.
</Text> </Text>
)} )}
</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,94 +351,122 @@ 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})`,
<ConfigFileManager display: 'flex',
configFiles={configFiles} flexDirection: 'column',
selectedFile={selectedFile} paddingTop: 'var(--mantine-spacing-md)',
onFileSelection={handleFileSelection} gap: 'var(--mantine-spacing-lg)',
onCreateNewConfig={handleCreateNewConfig} }}
onSaveConfig={handleSaveConfig} >
onDeleteConfig={handleDeleteConfig} <ConfigFileManager
onLoadConfigFiles={loadConfigFiles} configFiles={configFiles}
/> selectedFile={selectedFile}
onFileSelection={handleFileSelection}
onCreateNewConfig={handleCreateNewConfig}
onSaveConfig={handleSaveConfig}
onDeleteConfig={handleDeleteConfig}
onLoadConfigFiles={loadConfigFiles}
/>
<Tabs
value={activeTab}
onChange={setActiveTab}
style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}
styles={{
panel: {
flex: 1,
overflow: 'auto',
paddingTop: '1rem',
paddingInline: '0.5rem',
},
}}
>
<Tabs.List>
<Tabs.Tab value="general">General</Tabs.Tab>
<Tabs.Tab value="image">Image Generation</Tabs.Tab>
<Tabs.Tab value="performance">Performance</Tabs.Tab>
<Tabs.Tab value="network">Network</Tabs.Tab>
<Tabs.Tab value="advanced">Advanced</Tabs.Tab>
</Tabs.List>
<Tabs <Tabs.Panel value="general">
value={activeTab} <GeneralTab configLoaded={configLoaded} />
onChange={setActiveTab} </Tabs.Panel>
styles={{
panel: {
flex: 1,
overflow: 'auto',
paddingTop: '1rem',
paddingRight: '0.5rem',
},
root: {
maxHeight: '51vh',
display: 'flex',
flexDirection: 'column',
},
}}
>
<Tabs.List>
<Tabs.Tab value="general">General</Tabs.Tab>
<Tabs.Tab value="image">Image Generation</Tabs.Tab>
<Tabs.Tab value="performance">Performance</Tabs.Tab>
<Tabs.Tab value="network">Network</Tabs.Tab>
<Tabs.Tab value="advanced">Advanced</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="general"> <Tabs.Panel value="image">
<GeneralTab configLoaded={configLoaded} /> <ImageGenerationTab />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="image"> <Tabs.Panel value="performance">
<ImageGenerationTab /> <PerformanceTab />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="performance"> <Tabs.Panel value="network">
<PerformanceTab /> <NetworkTab />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="network"> <Tabs.Panel value="advanced">
<NetworkTab /> <AdvancedTab />
</Tabs.Panel> </Tabs.Panel>
</Tabs>
<Tabs.Panel value="advanced"> <div
<AdvancedTab /> style={{
</Tabs.Panel> borderTop: '1px solid var(--mantine-color-default-border)',
</Tabs> padding: 'var(--mantine-spacing-md) 0',
display: 'flex',
<Group justify="flex-end"> justifyContent: 'flex-end',
<WarningDisplay warnings={combinedWarnings}> flexShrink: 0,
<Button }}
radius="md" >
disabled={(!model && !sdmodel) || isLaunching} <WarningDisplay warnings={combinedWarnings}>
onClick={handleLaunchClick} <Button
size="lg" radius="md"
variant="filled" disabled={(!model && !sdmodel) || isLaunching}
color="blue" onClick={handleLaunchClick}
style={{ size="lg"
fontSize: '1em', variant="filled"
fontWeight: 600, color="brand"
letterSpacing: '0.03125rem', style={{
minWidth: '7.5rem', fontSize: '1em',
padding: '0.75rem 1.75rem', fontWeight: 600,
textTransform: 'uppercase', letterSpacing: '0.03125rem',
}} minWidth: '7.5rem',
> padding: '0.75rem 1.75rem',
Launch textTransform: 'uppercase',
</Button> }}
</WarningDisplay> >
</Group> Launch
</Stack> </Button>
</Card> </WarningDisplay>
</Stack> </div>
</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} <Stack gap={40} align="center" maw={480} style={{ width: '100%', textAlign: 'center' }}>
</Title> <Stack gap="lg" align="center">
</Group> <img src={iconUrl} alt={PRODUCT_NAME} width={64} height={64} />
<Text size="lg" c="dimmed" ta="center" maw={600}> <Stack gap="xs" align="center">
Run Large Language Models locally <Title order={1}>{PRODUCT_NAME}</Title>
</Text> <Text size="lg" fw={500}>
</Stack> Local LLMs, fully under your control.
</Text>
<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>
<Text>
<Text component="span" fw={500}>
Chat with AI models
</Text>{' '}
- Have conversations, ask questions, get help with writing
</Text>
</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>
</Stack>
<Button size="lg" mt="lg" onClick={onGetStarted}>
Get Started
</Button>
</Stack> </Stack>
</Card> </Stack>
<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
</Button>
<Text size="xs" c="dimmed">
GPU acceleration requires CUDA, ROCm or Vulkan drivers.
</Text>
</Stack>
</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,12 +33,16 @@ export const useLogoClickSounds = () => {
} catch {} } catch {}
}; };
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const getLogoStyles = () => ({ const getLogoStyles = () => ({
animation: isElephantMode animation: prefersReducedMotion
? 'elephantShake 1.5s ease-in-out' ? 'none'
: isMouseSqueaking : isElephantMode
? 'mouseSqueak 0.3s ease-in-out' ? 'elephantShake 1.5s ease-in-out'
: 'none', : isMouseSqueaking
? 'mouseSqueak 0.3s ease-in-out'
: 'none',
cursor: 'pointer', cursor: 'pointer',
transform: isElephantMode ? 'scale(1.3) rotate(5deg)' : 'scale(1) rotate(0deg)', transform: isElephantMode ? 'scale(1.3) rotate(5deg)' : 'scale(1) rotate(0deg)',
transition: 'transform 0.15s ease-in-out', transition: 'transform 0.15s 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} (${cores} cores) @ ${speed} GHz`,
detailedName: `${name} (${cpu.cores} cores) @ ${cpu.speed} GHz`, name,
name, });
}); }
}
const capabilities = {
devices,
};
cpuCapabilitiesCache = capabilities;
return capabilities;
}, 'CPU detection failed');
cpuCapabilitiesCache = result ?? {
devices: [],
};
cpuCapabilitiesCache = { 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) return gpu.driverInfo;
if (gpu.driverInfo && !gpu.isIntegrated) { }
driverVersion = gpu.driverInfo;
break;
}
}
} catch {}
} }
return undefined;
};
return { if (stdout.trim()) {
devices, const devices = parseRocmOutput(stdout, vulkanInfo);
driverVersion, driverVersion = await getDriverVersion();
version, return { devices, driverVersion, version };
}; }
if (version) {
const amdDevices = vulkanInfo.allGPUs
.filter((gpu) => gpu.hasAMD && !gpu.isIntegrated)
.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,19 +368,31 @@ export const launchKoboldCppWithCustomFrontends = async (
} }
if (frontendPreference === 'sillytavern') { if (frontendPreference === 'sillytavern') {
startSillyTavernFrontend(args).catch((error) => { startSillyTavernFrontend(args)
logError('Failed to start SillyTavern frontend:', error); .then(() => {
sendKoboldOutput( if (remotetunnel) {
`Failed to start SillyTavern: ${error instanceof Error ? error.message : String(error)}`, void startTunnel(frontendPreference, true);
); }
}); })
.catch((error) => {
logError('Failed to start SillyTavern frontend:', error);
sendKoboldOutput(
`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(() => {
sendKoboldOutput( if (remotetunnel) {
`Failed to start OpenWebUI: ${error instanceof Error ? error.message : String(error)}`, void startTunnel(frontendPreference, true);
); }
}); })
.catch((error) => {
logError('Failed to start Open WebUI frontend:', error);
sendKoboldOutput(
`Failed to start Open WebUI: ${error instanceof Error ? error.message : String(error)}`,
);
});
} }
return result; return result;

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,28 +120,52 @@ export const startTunnel = async (frontendPreference: FrontendPreference = 'kobo
try { try {
const tunnelTarget = getTunnelTarget(frontendPreference); const tunnelTarget = getTunnelTarget(frontendPreference);
sendKoboldOutput('Waiting for backend to be ready...'); if (!skipBackendCheck) {
const backendReady = await waitForBackend(tunnelTarget); sendKoboldOutput('Waiting for backend to be ready...');
const backendReady = await waitForBackend(tunnelTarget);
if (!backendReady) { if (!backendReady) {
throw new Error( throw new Error(
'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,49 +95,66 @@ async function ensureSillyTavernInstalled() {
sendKoboldOutput('Installing SillyTavern via npm...'); sendKoboldOutput('Installing SillyTavern via npm...');
} }
return new Promise<void>((resolve, reject) => { await killOrphanedSillyTavern();
const npmProcess = spawn(
'npm',
[
'install',
'sillytavern@latest',
'--prefix',
installDir,
'--no-save',
'--install-strategy=nested',
'--silent',
],
{
env,
shell: platform === 'win32',
stdio: ['pipe', 'pipe', 'pipe'],
},
);
let errorOutput = ''; const runNpmInstall = () =>
new Promise<void>((resolve, reject) => {
const npmProcess = spawn(
'npm',
[
'install',
'sillytavern@latest',
'--prefix',
installDir,
'--no-save',
'--install-strategy=nested',
'--silent',
],
{
env,
shell: platform === 'win32',
stdio: ['pipe', 'pipe', 'pipe'],
},
);
if (npmProcess.stderr) { let errorOutput = '';
npmProcess.stderr.on('data', (data: Buffer) => {
errorOutput += data.toString();
});
}
npmProcess.on('exit', (code) => { if (npmProcess.stderr) {
if (code === 0) { npmProcess.stderr.on('data', (data: Buffer) => {
sendKoboldOutput('SillyTavern is ready'); errorOutput += data.toString();
resolve(); });
} else {
if (errorOutput) {
sendKoboldOutput(`npm install error: ${errorOutput.trim()}`);
}
reject(new Error(`npm install failed with code ${code}`));
} }
npmProcess.on('exit', (code) => {
if (code === 0) {
sendKoboldOutput('SillyTavern is ready');
resolve();
} else {
reject(Object.assign(new Error(`npm install failed with code ${code}`), { errorOutput }));
}
});
npmProcess.on('error', reject);
}); });
npmProcess.on('error', (error) => { try {
reject(error); 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) => {
dark: { const v8 = v8CssVariablesResolver(t);
'--mantine-color-body': '#0f0f0f', return {
'--mantine-color-default-border': '#2a2a2a', variables: { ...v8.variables },
}, dark: {
light: { ...v8.dark,
'--mantine-color-body': '#fafafa', '--mantine-color-body': 'oklch(22% 0.008 240)',
'--mantine-color-white': '#fafafa', '--mantine-color-default-border': 'oklch(48% 0.009 240)',
'--mantine-color-default-border': '#dee2e6', '--gerbil-link-color': 'var(--mantine-color-brand-4)',
}, },
variables: {}, light: {
}); ...v8.light,
'--mantine-color-body': 'oklch(97.5% 0.005 240)',
'--mantine-color-white': '#fafafa',
'--mantine-color-default-border': 'oklch(89% 0.007 240)',
'--gerbil-link-color': 'var(--mantine-color-brand-6)',
},
};
};

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);
}; };