mirror of
https://github.com/lone-cloud/gerbil
synced 2026-06-03 09:33:10 -07:00
Compare commits
28 commits
8e39cba942
...
d8d896beaa
| Author | SHA1 | Date | |
|---|---|---|---|
| d8d896beaa | |||
| b85baf75f5 | |||
| aa4d00b736 | |||
| 76a06f0c0a | |||
| d163b9bd67 | |||
| 343f9810d7 | |||
| b9b2069fd4 | |||
| 249eb07f26 | |||
| 84ed1d3655 | |||
|
|
064361d11f | ||
| 75ba344fd7 | |||
| 6bab3cbf0b | |||
| 03db414867 | |||
| 28f8cf68c3 | |||
| 9ec73381df | |||
| 6146e9cb95 | |||
| e96cd94cb5 | |||
| de61e6a3fb | |||
| 3181f80e60 | |||
| 0929e30f82 | |||
| f568194ba7 | |||
| 204e203d5f | |||
| 6356387e89 | |||
| f5f78b62fe | |||
| 6f9fe85068 | |||
| 871d3cfcd1 | |||
| bd67ef4d82 | |||
| ec44a78457 |
79 changed files with 2439 additions and 2108 deletions
233
.github/copilot-instructions.md
vendored
233
.github/copilot-instructions.md
vendored
|
|
@ -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
2
.gitignore
vendored
|
|
@ -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
2
.nvmrc
|
|
@ -1 +1 @@
|
||||||
24.14.1
|
24.15.0
|
||||||
21
README.md
21
README.md
|
|
@ -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**
|
||||||
|
|
||||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
[](https://github.com/lone-cloud/gerbil/releases)
|
[](https://github.com/lone-cloud/gerbil/releases)
|
||||||
[](https://github.com/lone-cloud/gerbil/stargazers)
|
|
||||||
[](https://aur.archlinux.org/packages/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.
|
||||||
|
|
|
||||||
BIN
assets/badges/github-badge.png
Normal file
BIN
assets/badges/github-badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
File diff suppressed because one or more lines are too long
|
|
@ -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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
45
package.json
45
package.json
|
|
@ -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
1916
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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" />}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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={{
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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`}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Group, List, Tooltip } from '@mantine/core';
|
import { Box, Group, List, Tooltip } from '@mantine/core';
|
||||||
import { AlertTriangle, Info } from 'lucide-react';
|
import { AlertTriangle, Info } from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
|
@ -29,16 +29,24 @@ export const WarningDisplay = ({ warnings, children }: WarningDisplayProps) => {
|
||||||
warningMessages[0].message
|
warningMessages[0].message
|
||||||
) : (
|
) : (
|
||||||
<List size="sm" spacing={4}>
|
<List size="sm" spacing={4}>
|
||||||
{warningMessages.map((warning, index) => (
|
{warningMessages.map((warning) => (
|
||||||
<List.Item key={index}>{warning.message}</List.Item>
|
<List.Item key={warning.message}>{warning.message}</List.Item>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
multiline
|
multiline
|
||||||
maw={320}
|
maw={320}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
tabIndex={0}
|
||||||
|
role="img"
|
||||||
|
aria-label="Warning"
|
||||||
|
style={{ display: 'inline-flex', cursor: 'default' }}
|
||||||
>
|
>
|
||||||
<AlertTriangle size={18} color="var(--mantine-color-orange-6)" strokeWidth={2} />
|
<AlertTriangle size={18} color="var(--mantine-color-orange-6)" strokeWidth={2} />
|
||||||
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{infoMessages.length > 0 && (
|
{infoMessages.length > 0 && (
|
||||||
|
|
@ -48,8 +56,8 @@ export const WarningDisplay = ({ warnings, children }: WarningDisplayProps) => {
|
||||||
infoMessages[0].message
|
infoMessages[0].message
|
||||||
) : (
|
) : (
|
||||||
<List size="sm" spacing={4}>
|
<List size="sm" spacing={4}>
|
||||||
{infoMessages.map((info, index) => (
|
{infoMessages.map((info) => (
|
||||||
<List.Item key={index}>{info.message}</List.Item>
|
<List.Item key={info.message}>{info.message}</List.Item>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
)
|
)
|
||||||
|
|
@ -57,7 +65,15 @@ export const WarningDisplay = ({ warnings, children }: WarningDisplayProps) => {
|
||||||
multiline
|
multiline
|
||||||
maw={320}
|
maw={320}
|
||||||
>
|
>
|
||||||
<Info size={18} color="var(--mantine-color-blue-6)" strokeWidth={2} />
|
<Box
|
||||||
|
component="span"
|
||||||
|
tabIndex={0}
|
||||||
|
role="img"
|
||||||
|
aria-label="Info"
|
||||||
|
style={{ display: 'inline-flex', cursor: 'default' }}
|
||||||
|
>
|
||||||
|
<Info size={18} color="var(--mantine-color-brand-5)" strokeWidth={2} />
|
||||||
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Card, Container, Loader, Stack, Text, Title } from '@mantine/core';
|
import { Container, Loader, Stack, Text, Title } from '@mantine/core';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { DownloadCard } from '@/components/DownloadCard';
|
import { DownloadCard } from '@/components/DownloadCard';
|
||||||
|
|
@ -59,13 +59,12 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="sm" mt="md">
|
<Container size="sm" mt="md">
|
||||||
<Card withBorder radius="md" shadow="sm">
|
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Title order={3}>Select a Backend</Title>
|
<Title order={3}>Select a Backend</Title>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Stack align="center" gap="md" py="xl">
|
<Stack align="center" gap="md" py="xl">
|
||||||
<Loader color="blue" />
|
<Loader color="brand" />
|
||||||
<Text c="dimmed">Preparing download options...</Text>
|
<Text c="dimmed">Preparing download options...</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -73,8 +72,7 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
|
||||||
{availableDownloads.length > 0 ? (
|
{availableDownloads.length > 0 ? (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{availableDownloads.map((download) => {
|
{availableDownloads.map((download) => {
|
||||||
const isDownloading =
|
const isDownloading = Boolean(downloading) && downloadingAsset === download.name;
|
||||||
Boolean(downloading) && downloadingAsset === download.name;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={download.name} ref={isDownloading ? downloadingItemRef : null}>
|
<div key={download.name} ref={isDownloading ? downloadingItemRef : null}>
|
||||||
|
|
@ -91,8 +89,7 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
|
||||||
size={formatDownloadSize(download.size, download.url)}
|
size={formatDownloadSize(download.size, download.url)}
|
||||||
description={getAssetDescription(download.name)}
|
description={getAssetDescription(download.name)}
|
||||||
disabled={
|
disabled={
|
||||||
importing ||
|
importing || (Boolean(downloading) && downloadingAsset !== download.name)
|
||||||
(Boolean(downloading) && downloadingAsset !== download.name)
|
|
||||||
}
|
}
|
||||||
onDownload={(e) => {
|
onDownload={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -118,7 +115,6 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { ActionIcon, Button, Group, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
import { ActionIcon, Button, Group, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { CheckboxWithTooltip } from '@/components/CheckboxWithTooltip';
|
import { CheckboxWithTooltip } from '@/components/CheckboxWithTooltip';
|
||||||
import { InfoTooltip } from '@/components/InfoTooltip';
|
import { InfoTooltip } from '@/components/InfoTooltip';
|
||||||
|
|
@ -14,11 +14,17 @@ export const AdvancedTab = () => {
|
||||||
noavx2,
|
noavx2,
|
||||||
failsafe,
|
failsafe,
|
||||||
debugmode,
|
debugmode,
|
||||||
|
jinja,
|
||||||
|
jinjatools,
|
||||||
|
jinjakwargs,
|
||||||
setAdditionalArguments,
|
setAdditionalArguments,
|
||||||
setPreLaunchCommands,
|
setPreLaunchCommands,
|
||||||
setNoavx2,
|
setNoavx2,
|
||||||
setFailsafe,
|
setFailsafe,
|
||||||
setDebugmode,
|
setDebugmode,
|
||||||
|
setJinja,
|
||||||
|
setJinjatools,
|
||||||
|
setJinjakwargs,
|
||||||
} = useLaunchConfigStore();
|
} = useLaunchConfigStore();
|
||||||
const [commandLineModalOpen, setCommandLineModalOpen] = useState(false);
|
const [commandLineModalOpen, setCommandLineModalOpen] = useState(false);
|
||||||
const [backendSupport, setBackendSupport] = useState<{
|
const [backendSupport, setBackendSupport] = useState<{
|
||||||
|
|
@ -27,11 +33,30 @@ export const AdvancedTab = () => {
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const handleAddArgument = (newArgument: string) => {
|
const handleAddArgument = useCallback(
|
||||||
|
(newArgument: string) => {
|
||||||
const currentArgs = additionalArguments.trim();
|
const currentArgs = additionalArguments.trim();
|
||||||
const updatedArgs = currentArgs ? `${currentArgs} ${newArgument}` : newArgument;
|
const updatedArgs = currentArgs ? `${currentArgs} ${newArgument}` : newArgument;
|
||||||
setAdditionalArguments(updatedArgs);
|
setAdditionalArguments(updatedArgs);
|
||||||
};
|
},
|
||||||
|
[additionalArguments, setAdditionalArguments],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleJinjaChange = useCallback(
|
||||||
|
(val: boolean) => {
|
||||||
|
setJinja(val);
|
||||||
|
if (!val) setJinjatools(false);
|
||||||
|
},
|
||||||
|
[setJinja, setJinjatools],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleJinjatoolsChange = useCallback(
|
||||||
|
(val: boolean) => {
|
||||||
|
setJinjatools(val);
|
||||||
|
if (val) setJinja(true);
|
||||||
|
},
|
||||||
|
[setJinja, setJinjatools],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const detectAccelerationSupport = async () => {
|
const detectAccelerationSupport = async () => {
|
||||||
|
|
@ -86,11 +111,42 @@ export const AdvancedTab = () => {
|
||||||
label="Debug Mode"
|
label="Debug Mode"
|
||||||
tooltip="Shows additional debug info in the terminal."
|
tooltip="Shows additional debug info in the terminal."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CheckboxWithTooltip
|
||||||
|
checked={jinja}
|
||||||
|
onChange={handleJinjaChange}
|
||||||
|
label="Use Jinja"
|
||||||
|
tooltip="Enables using jinja chat template formatting for chat completions endpoint."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CheckboxWithTooltip
|
||||||
|
checked={jinjatools}
|
||||||
|
onChange={handleJinjatoolsChange}
|
||||||
|
label="Jinja for Tools"
|
||||||
|
tooltip="Allows jinja even with tool calls. If unchecked, jinja will be disabled when tools are used."
|
||||||
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(jinja || jinjatools) && (
|
||||||
<div>
|
<div>
|
||||||
<Group mb="xs" justify="space-between">
|
<Group mb="xs">
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
Jinja Kwargs
|
||||||
|
</Text>
|
||||||
|
<InfoTooltip label="Set additional fields for the Jinja JSON template parser, must be a valid JSON object." />
|
||||||
|
</Group>
|
||||||
|
<TextInput
|
||||||
|
placeholder='e.g. {"enable_thinking":true}'
|
||||||
|
value={jinjakwargs}
|
||||||
|
onChange={(event) => setJinjakwargs(event.currentTarget.value)}
|
||||||
|
aria-label="Jinja kwargs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Group justify="space-between">
|
||||||
<Group>
|
<Group>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
Additional Arguments
|
Additional Arguments
|
||||||
|
|
@ -102,14 +158,16 @@ export const AdvancedTab = () => {
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
mt="xs"
|
||||||
placeholder="Additional command line arguments"
|
placeholder="Additional command line arguments"
|
||||||
value={additionalArguments}
|
value={additionalArguments}
|
||||||
onChange={(event) => setAdditionalArguments(event.currentTarget.value)}
|
onChange={(event) => setAdditionalArguments(event.currentTarget.value)}
|
||||||
|
aria-label="Additional command line arguments"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Group mb="xs">
|
<Group>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
Pre-Launch Commands
|
Pre-Launch Commands
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -117,7 +175,7 @@ export const AdvancedTab = () => {
|
||||||
</Group>
|
</Group>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{preLaunchCommands.map((command, index) => (
|
{preLaunchCommands.map((command, index) => (
|
||||||
<Group key={index} gap="xs">
|
<Group key={`cmd-${index}`} gap="xs">
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Enter a shell command"
|
placeholder="Enter a shell command"
|
||||||
value={command}
|
value={command}
|
||||||
|
|
@ -127,11 +185,13 @@ export const AdvancedTab = () => {
|
||||||
setPreLaunchCommands(newCommands);
|
setPreLaunchCommands(newCommands);
|
||||||
}}
|
}}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
|
aria-label={`Pre-launch command ${index + 1}`}
|
||||||
/>
|
/>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color="red"
|
||||||
disabled={preLaunchCommands.length === 1}
|
disabled={preLaunchCommands.length === 1}
|
||||||
|
aria-label={`Remove command ${index + 1}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newCommands = preLaunchCommands.filter((_, i) => i !== index);
|
const newCommands = preLaunchCommands.filter((_, i) => i !== index);
|
||||||
setPreLaunchCommands(newCommands.length === 0 ? [''] : newCommands);
|
setPreLaunchCommands(newCommands.length === 0 ? [''] : newCommands);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Checkbox, Group, Text, TextInput } from '@mantine/core';
|
import { Group, Switch, Text, TextInput } from '@mantine/core';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { InfoTooltip } from '@/components/InfoTooltip';
|
import { InfoTooltip } from '@/components/InfoTooltip';
|
||||||
import { AccelerationSelectItem } from '@/components/screens/Launch/GeneralTab/AccelerationSelectItem';
|
import { AccelerationSelectItem } from '@/components/screens/Launch/GeneralTab/AccelerationSelectItem';
|
||||||
|
|
@ -32,6 +32,7 @@ export const AccelerationSelector = () => {
|
||||||
const loadAccelerations = async () => {
|
const loadAccelerations = async () => {
|
||||||
setIsLoadingAccelerations(true);
|
setIsLoadingAccelerations(true);
|
||||||
|
|
||||||
|
try {
|
||||||
const [accelerations, platform] = await Promise.all([
|
const [accelerations, platform] = await Promise.all([
|
||||||
window.electronAPI.kobold.getAvailableAccelerations(true),
|
window.electronAPI.kobold.getAvailableAccelerations(true),
|
||||||
window.electronAPI.kobold.getPlatform(),
|
window.electronAPI.kobold.getPlatform(),
|
||||||
|
|
@ -39,8 +40,13 @@ export const AccelerationSelector = () => {
|
||||||
|
|
||||||
setAvailableAccelerations(accelerations || []);
|
setAvailableAccelerations(accelerations || []);
|
||||||
setIsMac(platform === 'darwin');
|
setIsMac(platform === 'darwin');
|
||||||
setIsLoadingAccelerations(false);
|
|
||||||
hasInitialized.current = true;
|
hasInitialized.current = true;
|
||||||
|
} catch (error) {
|
||||||
|
window.electronAPI.logs.logError('Failed to load accelerations', error as Error);
|
||||||
|
setAvailableAccelerations([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingAccelerations(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!hasInitialized.current) {
|
if (!hasInitialized.current) {
|
||||||
|
|
@ -129,10 +135,25 @@ export const AccelerationSelector = () => {
|
||||||
setGpuLayers,
|
setGpuLayers,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const renderAccelerationOption = useCallback(
|
||||||
|
({ option }: { option: { value: string; label: string } }) => {
|
||||||
|
const accelerationData = availableAccelerations.find((a) => a.value === option.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccelerationSelectItem
|
||||||
|
label={accelerationData?.label ?? option.label.split(' (')[0]}
|
||||||
|
devices={accelerationData?.devices}
|
||||||
|
disabled={accelerationData?.disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[availableAccelerations],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Group justify="space-between" align="flex-start" mb="xs">
|
<Group justify="space-between" align="flex-start" mb="xs">
|
||||||
<div style={{ flex: 1, marginRight: '1rem' }}>
|
<div style={{ flex: 1, marginRight: 'var(--mantine-spacing-md)' }}>
|
||||||
<Group gap="xs" align="center" mb="xs">
|
<Group gap="xs" align="center" mb="xs">
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
Acceleration
|
Acceleration
|
||||||
|
|
@ -159,17 +180,7 @@ export const AccelerationSelector = () => {
|
||||||
value: a.value,
|
value: a.value,
|
||||||
}))}
|
}))}
|
||||||
disabled={isLoadingAccelerations || availableAccelerations.length === 0}
|
disabled={isLoadingAccelerations || availableAccelerations.length === 0}
|
||||||
renderOption={({ option }) => {
|
renderOption={renderAccelerationOption}
|
||||||
const accelerationData = availableAccelerations.find((a) => a.value === option.value);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AccelerationSelectItem
|
|
||||||
label={accelerationData?.label ?? option.label.split(' (')[0]}
|
|
||||||
devices={accelerationData?.devices}
|
|
||||||
disabled={accelerationData?.disabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -180,16 +191,10 @@ export const AccelerationSelector = () => {
|
||||||
</Text>
|
</Text>
|
||||||
<InfoTooltip label="The number of layers to offload to your GPU's VRAM. When Auto is enabled, this is calculated based on your model size, context size, available VRAM and flash attention settings." />
|
<InfoTooltip label="The number of layers to offload to your GPU's VRAM. When Auto is enabled, this is calculated based on your model size, context size, available VRAM and flash attention settings." />
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="lg" align="center">
|
<Group gap="xs" align="center">
|
||||||
<TextInput
|
<TextInput
|
||||||
value={autoGpuLayers ? '' : gpuLayers.toString()}
|
value={gpuLayers.toString()}
|
||||||
placeholder={
|
placeholder={isCalculatingLayers ? 'Calculating...' : undefined}
|
||||||
autoGpuLayers
|
|
||||||
? isCalculatingLayers
|
|
||||||
? 'Calculating...'
|
|
||||||
: gpuLayers.toString()
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onChange={(event) => setGpuLayers(Number(event.target.value) || 0)}
|
onChange={(event) => setGpuLayers(Number(event.target.value) || 0)}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
|
|
@ -198,17 +203,15 @@ export const AccelerationSelector = () => {
|
||||||
size="sm"
|
size="sm"
|
||||||
w={80}
|
w={80}
|
||||||
disabled={autoGpuLayers || (acceleration === 'cpu' && !isMac)}
|
disabled={autoGpuLayers || (acceleration === 'cpu' && !isMac)}
|
||||||
|
aria-label="GPU layers"
|
||||||
/>
|
/>
|
||||||
<Group gap="xs" align="center">
|
<Switch
|
||||||
<Checkbox
|
|
||||||
label="Auto"
|
label="Auto"
|
||||||
checked={autoGpuLayers}
|
checked={autoGpuLayers}
|
||||||
onChange={(event) => setAutoGpuLayers(event.currentTarget.checked)}
|
onChange={(event) => setAutoGpuLayers(event.currentTarget.checked)}
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={acceleration === 'cpu' && !isMac}
|
disabled={acceleration === 'cpu' && !isMac}
|
||||||
/>
|
/>
|
||||||
<InfoTooltip label="Automatically calculate optimal GPU layers based on available VRAM. The calculation accounts for model size, context size and flash attention." />
|
|
||||||
</Group>
|
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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)}>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { Group, NumberInput, Select, SimpleGrid, Stack, Text } from '@mantine/core';
|
import { Group, NumberInput, Select, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
|
||||||
import { CheckboxWithTooltip } from '@/components/CheckboxWithTooltip';
|
import { CheckboxWithTooltip } from '@/components/CheckboxWithTooltip';
|
||||||
import { InfoTooltip } from '@/components/InfoTooltip';
|
import { InfoTooltip } from '@/components/InfoTooltip';
|
||||||
|
|
@ -9,8 +11,11 @@ const KV_QUANT_OPTIONS = [
|
||||||
{ value: '1', label: 'Q8 (8-bit)' },
|
{ value: '1', label: 'Q8 (8-bit)' },
|
||||||
{ value: '2', label: 'Q4 (4-bit)' },
|
{ value: '2', label: 'Q4 (4-bit)' },
|
||||||
{ value: '3', label: 'BF16' },
|
{ value: '3', label: 'BF16' },
|
||||||
|
{ value: '4', label: 'Q5 (5-bit)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const FLEX_COL: CSSProperties = { flex: 1, minWidth: 200 };
|
||||||
|
|
||||||
export const PerformanceTab = () => {
|
export const PerformanceTab = () => {
|
||||||
const {
|
const {
|
||||||
noshift,
|
noshift,
|
||||||
|
|
@ -40,13 +45,16 @@ export const PerformanceTab = () => {
|
||||||
const quantkvActive = quantkv > 0;
|
const quantkvActive = quantkv > 0;
|
||||||
const quantkvWithoutFlash = quantkvActive && !flashattention;
|
const quantkvWithoutFlash = quantkvActive && !flashattention;
|
||||||
|
|
||||||
const handleQuantkvChange = (value: string | null) => {
|
const handleQuantkvChange = useCallback(
|
||||||
|
(value: string | null) => {
|
||||||
const level = Number(value ?? '0');
|
const level = Number(value ?? '0');
|
||||||
setQuantkv(level);
|
setQuantkv(level);
|
||||||
if (level > 0) {
|
if (level > 0) {
|
||||||
setFlashattention(true);
|
setFlashattention(true);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[setQuantkv, setFlashattention],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
|
@ -59,24 +67,13 @@ export const PerformanceTab = () => {
|
||||||
tooltip="Use Context Shifting to reduce reprocessing and improve performance with long contexts."
|
tooltip="Use Context Shifting to reduce reprocessing and improve performance with long contexts."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CheckboxWithTooltip
|
|
||||||
checked={noshift}
|
|
||||||
onChange={setNoshift}
|
|
||||||
label="No Shift"
|
|
||||||
tooltip="Disable context shifting. May reduce performance but can help with compatibility issues."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CheckboxWithTooltip
|
<CheckboxWithTooltip
|
||||||
checked={smartcache}
|
checked={smartcache}
|
||||||
onChange={setSmartcache}
|
onChange={setSmartcache}
|
||||||
label="Smart Cache"
|
label="Smart Cache"
|
||||||
tooltip="Enables intelligent context switching by saving KV cache snapshots to RAM. Requires fast forwarding."
|
tooltip="Enables intelligent context switching by saving KV cache snapshots to RAM. Requires fast forwarding."
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<SimpleGrid cols={3} spacing="lg" verticalSpacing="md">
|
|
||||||
<CheckboxWithTooltip
|
<CheckboxWithTooltip
|
||||||
checked={flashattention}
|
checked={flashattention}
|
||||||
onChange={setFlashattention}
|
onChange={setFlashattention}
|
||||||
|
|
@ -135,8 +132,7 @@ export const PerformanceTab = () => {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Group gap="lg" align="flex-start" wrap="nowrap">
|
<div>
|
||||||
<div style={{ flex: 1, minWidth: 200 }}>
|
|
||||||
<Group gap="xs" align="center" mb="xs">
|
<Group gap="xs" align="center" mb="xs">
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
KV Cache Quantization
|
KV Cache Quantization
|
||||||
|
|
@ -149,6 +145,7 @@ export const PerformanceTab = () => {
|
||||||
data={KV_QUANT_OPTIONS}
|
data={KV_QUANT_OPTIONS}
|
||||||
size="sm"
|
size="sm"
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
|
aria-label="KV cache quantization"
|
||||||
/>
|
/>
|
||||||
{quantkvWithoutFlash && (
|
{quantkvWithoutFlash && (
|
||||||
<Text size="xs" c="red" mt={4}>
|
<Text size="xs" c="red" mt={4}>
|
||||||
|
|
@ -158,11 +155,8 @@ export const PerformanceTab = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 200 }} />
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group gap="lg" align="flex-start" wrap="nowrap">
|
<Group gap="lg" align="flex-start" wrap="nowrap">
|
||||||
<div style={{ flex: 1, minWidth: 200 }}>
|
<div style={FLEX_COL}>
|
||||||
<Group gap="xs" align="center" mb="xs">
|
<Group gap="xs" align="center" mb="xs">
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
MoE Experts
|
MoE Experts
|
||||||
|
|
@ -176,10 +170,11 @@ export const PerformanceTab = () => {
|
||||||
max={128}
|
max={128}
|
||||||
step={1}
|
step={1}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
aria-label="MoE experts"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 200 }}>
|
<div style={FLEX_COL}>
|
||||||
<Group gap="xs" align="center" mb="xs">
|
<Group gap="xs" align="center" mb="xs">
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
MoE CPU Layers
|
MoE CPU Layers
|
||||||
|
|
@ -193,6 +188,7 @@ export const PerformanceTab = () => {
|
||||||
max={999}
|
max={999}
|
||||||
step={1}
|
step={1}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
aria-label="MoE CPU layers"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Button, Card, Container, Group, Stack, Tabs } from '@mantine/core';
|
import { Button, Container, Tabs } from '@mantine/core';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { AdvancedTab } from '@/components/screens/Launch/AdvancedTab';
|
import { AdvancedTab } from '@/components/screens/Launch/AdvancedTab';
|
||||||
|
|
@ -8,7 +8,7 @@ import { ImageGenerationTab } from '@/components/screens/Launch/ImageGenerationT
|
||||||
import { NetworkTab } from '@/components/screens/Launch/NetworkTab';
|
import { NetworkTab } from '@/components/screens/Launch/NetworkTab';
|
||||||
import { PerformanceTab } from '@/components/screens/Launch/PerformanceTab';
|
import { PerformanceTab } from '@/components/screens/Launch/PerformanceTab';
|
||||||
import { WarningDisplay } from '@/components/WarningDisplay';
|
import { WarningDisplay } from '@/components/WarningDisplay';
|
||||||
import { DEFAULT_MODEL_URL } from '@/constants';
|
import { DEFAULT_MODEL_URL, STATUSBAR_HEIGHT, TITLEBAR_HEIGHT } from '@/constants';
|
||||||
import { useLaunchLogic } from '@/hooks/useLaunchLogic';
|
import { useLaunchLogic } from '@/hooks/useLaunchLogic';
|
||||||
import { useWarnings } from '@/hooks/useWarnings';
|
import { useWarnings } from '@/hooks/useWarnings';
|
||||||
import { useLaunchConfigStore } from '@/stores/launchConfig';
|
import { useLaunchConfigStore } from '@/stores/launchConfig';
|
||||||
|
|
@ -68,6 +68,9 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
|
||||||
smartcache,
|
smartcache,
|
||||||
pipelineparallel,
|
pipelineparallel,
|
||||||
quantkv,
|
quantkv,
|
||||||
|
jinja,
|
||||||
|
jinjatools,
|
||||||
|
jinjakwargs,
|
||||||
parseAndApplyConfigFile,
|
parseAndApplyConfigFile,
|
||||||
loadConfigFromFile,
|
loadConfigFromFile,
|
||||||
setModel,
|
setModel,
|
||||||
|
|
@ -187,6 +190,9 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
|
||||||
usevulkan: acceleration === 'vulkan',
|
usevulkan: acceleration === 'vulkan',
|
||||||
websearch,
|
websearch,
|
||||||
quantkv,
|
quantkv,
|
||||||
|
jinja,
|
||||||
|
jinjatools,
|
||||||
|
jinjakwargs,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCreateNewConfig = async (configName: string) => {
|
const handleCreateNewConfig = async (configName: string) => {
|
||||||
|
|
@ -301,6 +307,9 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
|
||||||
usemmap,
|
usemmap,
|
||||||
websearch,
|
websearch,
|
||||||
quantkv,
|
quantkv,
|
||||||
|
jinja,
|
||||||
|
jinjatools,
|
||||||
|
jinjakwargs,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
handleLaunch,
|
handleLaunch,
|
||||||
|
|
@ -342,13 +351,42 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
|
||||||
smartcache,
|
smartcache,
|
||||||
pipelineparallel,
|
pipelineparallel,
|
||||||
quantkv,
|
quantkv,
|
||||||
|
jinja,
|
||||||
|
jinjatools,
|
||||||
|
jinjakwargs,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Enter') return;
|
||||||
|
if ((!model && !sdmodel) || isLaunching) return;
|
||||||
|
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
const isInputFocused =
|
||||||
|
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable;
|
||||||
|
|
||||||
|
// Ctrl/Cmd+Enter always launches; bare Enter only when no input is focused
|
||||||
|
if (isInputFocused && !event.ctrlKey && !event.metaKey) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
handleLaunchClick();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleLaunchClick, model, sdmodel, isLaunching]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="sm" mt="md">
|
<Container
|
||||||
<Stack gap="md">
|
size="sm"
|
||||||
<Card withBorder radius="md" shadow="sm" p="lg" style={{ position: 'relative' }}>
|
style={{
|
||||||
<Stack gap="lg">
|
height: `calc(100svh - ${TITLEBAR_HEIGHT} - ${STATUSBAR_HEIGHT})`,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
paddingTop: 'var(--mantine-spacing-md)',
|
||||||
|
gap: 'var(--mantine-spacing-lg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ConfigFileManager
|
<ConfigFileManager
|
||||||
configFiles={configFiles}
|
configFiles={configFiles}
|
||||||
selectedFile={selectedFile}
|
selectedFile={selectedFile}
|
||||||
|
|
@ -358,21 +396,16 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
|
||||||
onDeleteConfig={handleDeleteConfig}
|
onDeleteConfig={handleDeleteConfig}
|
||||||
onLoadConfigFiles={loadConfigFiles}
|
onLoadConfigFiles={loadConfigFiles}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onChange={setActiveTab}
|
onChange={setActiveTab}
|
||||||
|
style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}
|
||||||
styles={{
|
styles={{
|
||||||
panel: {
|
panel: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
paddingTop: '1rem',
|
paddingTop: '1rem',
|
||||||
paddingRight: '0.5rem',
|
paddingInline: '0.5rem',
|
||||||
},
|
|
||||||
root: {
|
|
||||||
maxHeight: '51vh',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -404,8 +437,15 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
|
||||||
<AdvancedTab />
|
<AdvancedTab />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
<div
|
||||||
<Group justify="flex-end">
|
style={{
|
||||||
|
borderTop: '1px solid var(--mantine-color-default-border)',
|
||||||
|
padding: 'var(--mantine-spacing-md) 0',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<WarningDisplay warnings={combinedWarnings}>
|
<WarningDisplay warnings={combinedWarnings}>
|
||||||
<Button
|
<Button
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|
@ -413,7 +453,7 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
|
||||||
onClick={handleLaunchClick}
|
onClick={handleLaunchClick}
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color="blue"
|
color="brand"
|
||||||
style={{
|
style={{
|
||||||
fontSize: '1em',
|
fontSize: '1em',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
|
@ -426,10 +466,7 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
|
||||||
Launch
|
Launch
|
||||||
</Button>
|
</Button>
|
||||||
</WarningDisplay>
|
</WarningDisplay>
|
||||||
</Group>
|
</div>
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,5 @@
|
||||||
import iconUrl from '/icon.png';
|
import iconUrl from '/icon.png';
|
||||||
import {
|
import { Badge, Box, Button, Group, Stack, Text, Title } from '@mantine/core';
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Container,
|
|
||||||
Group,
|
|
||||||
Image,
|
|
||||||
List,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
ThemeIcon,
|
|
||||||
Title,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { Check } from 'lucide-react';
|
|
||||||
|
|
||||||
import { PRODUCT_NAME } from '@/constants';
|
import { PRODUCT_NAME } from '@/constants';
|
||||||
|
|
||||||
|
|
@ -19,79 +7,44 @@ interface WelcomeScreenProps {
|
||||||
onGetStarted: () => void;
|
onGetStarted: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FEATURES = ['Chat & roleplay', 'Image generation', '100% local', 'CUDA · ROCm · Vulkan'];
|
||||||
|
|
||||||
export const WelcomeScreen = ({ onGetStarted }: WelcomeScreenProps) => (
|
export const WelcomeScreen = ({ onGetStarted }: WelcomeScreenProps) => (
|
||||||
<Container size="md" mt="md">
|
<Box
|
||||||
<Stack gap="xl">
|
style={{
|
||||||
<Card withBorder radius="md" shadow="sm" p="xl">
|
display: 'flex',
|
||||||
<Stack gap="lg" align="center">
|
alignItems: 'center',
|
||||||
<Stack gap="md" align="center">
|
justifyContent: 'center',
|
||||||
<Group gap="md" mr="xl" align="center">
|
minHeight: 'calc(100svh - 6rem)',
|
||||||
<Image src={iconUrl} alt={PRODUCT_NAME} w={36} h={36} />
|
}}
|
||||||
<Title order={1} ta="center">
|
|
||||||
{PRODUCT_NAME}
|
|
||||||
</Title>
|
|
||||||
</Group>
|
|
||||||
<Text size="lg" c="dimmed" ta="center" maw={600}>
|
|
||||||
Run Large Language Models locally
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack gap="lg" w="100%" maw={600}>
|
|
||||||
<List
|
|
||||||
spacing="sm"
|
|
||||||
size="sm"
|
|
||||||
center
|
|
||||||
icon={
|
|
||||||
<ThemeIcon color="green" size={20} radius="xl">
|
|
||||||
<Check size={12} />
|
|
||||||
</ThemeIcon>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<List.Item>
|
<Stack gap={40} align="center" maw={480} style={{ width: '100%', textAlign: 'center' }}>
|
||||||
<Text>
|
<Stack gap="lg" align="center">
|
||||||
<Text component="span" fw={500}>
|
<img src={iconUrl} alt={PRODUCT_NAME} width={64} height={64} />
|
||||||
Chat with AI models
|
<Stack gap="xs" align="center">
|
||||||
</Text>{' '}
|
<Title order={1}>{PRODUCT_NAME}</Title>
|
||||||
- Have conversations, ask questions, get help with writing
|
<Text size="lg" fw={500}>
|
||||||
</Text>
|
Local LLMs, fully under your control.
|
||||||
</List.Item>
|
|
||||||
<List.Item>
|
|
||||||
<Text>
|
|
||||||
<Text component="span" fw={500}>
|
|
||||||
Generate images
|
|
||||||
</Text>{' '}
|
|
||||||
- Easily create artwork and illustrations with LLMs
|
|
||||||
</Text>
|
|
||||||
</List.Item>
|
|
||||||
<List.Item>
|
|
||||||
<Text>
|
|
||||||
<Text component="span" fw={500}>
|
|
||||||
Complete privacy
|
|
||||||
</Text>{' '}
|
|
||||||
- Everything runs on your computer, no data sent to servers
|
|
||||||
</Text>
|
|
||||||
</List.Item>
|
|
||||||
<List.Item>
|
|
||||||
<Text>
|
|
||||||
<Text component="span" fw={500}>
|
|
||||||
Hardware acceleration
|
|
||||||
</Text>{' '}
|
|
||||||
- Supports CUDA, ROCm and Vulkan backends
|
|
||||||
</Text>
|
|
||||||
</List.Item>
|
|
||||||
</List>
|
|
||||||
|
|
||||||
<Text size="xs" c="dimmed" ta="center" mt="sm">
|
|
||||||
Hardware acceleration requires appropriate drivers to be manually installed (CUDA for
|
|
||||||
NVIDIA GPUs, ROCm for AMD GPUs, etc.)
|
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Button size="lg" mt="lg" onClick={onGetStarted}>
|
<Group gap="xs" justify="center" wrap="wrap">
|
||||||
|
{FEATURES.map((feature) => (
|
||||||
|
<Badge key={feature} size="lg" variant="light" color="brand" radius="sm">
|
||||||
|
{feature}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Stack gap="xs" align="center">
|
||||||
|
<Button size="lg" onClick={onGetStarted} px="xl">
|
||||||
Get Started
|
Get Started
|
||||||
</Button>
|
</Button>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
GPU acceleration requires CUDA, ROCm or Vulkan drivers.
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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're looking to chat with AI
|
language models locally on your machine. Whether you'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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,12 @@ export const useLogoClickSounds = () => {
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
const getLogoStyles = () => ({
|
const getLogoStyles = () => ({
|
||||||
animation: isElephantMode
|
animation: prefersReducedMotion
|
||||||
|
? 'none'
|
||||||
|
: isElephantMode
|
||||||
? 'elephantShake 1.5s ease-in-out'
|
? 'elephantShake 1.5s ease-in-out'
|
||||||
: isMouseSqueaking
|
: isMouseSqueaking
|
||||||
? 'mouseSqueak 0.3s ease-in-out'
|
? 'mouseSqueak 0.3s ease-in-out'
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
import { cpus as osCpus, totalmem } from 'node:os';
|
||||||
import { platform } from 'node:process';
|
import { platform } from 'node:process';
|
||||||
|
|
||||||
import { execa } from 'execa';
|
import { execa } from 'execa';
|
||||||
import { cpu as siCpu, mem as siMem, memLayout as siMemLayout } from 'systeminformation';
|
import { memLayout as siMemLayout } from 'systeminformation';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
BasicGPUInfo,
|
BasicGPUInfo,
|
||||||
|
|
@ -20,41 +21,39 @@ const COMMON_EXEC_OPTIONS = {
|
||||||
timeout: 3000,
|
timeout: 3000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ROCM_EXEC_OPTIONS = {
|
||||||
|
...COMMON_EXEC_OPTIONS,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: ['/opt/rocm/bin', process.env.PATH].filter(Boolean).join(':'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
let cpuCapabilitiesCache: CPUCapabilities | null = null;
|
let cpuCapabilitiesCache: CPUCapabilities | null = null;
|
||||||
let basicGPUInfoCache: BasicGPUInfo | null = null;
|
let basicGPUInfoCache: BasicGPUInfo | null = null;
|
||||||
let gpuCapabilitiesCache: GPUCapabilities | null = null;
|
let gpuCapabilitiesCache: GPUCapabilities | null = null;
|
||||||
let gpuMemoryInfoCache: GPUMemoryInfo[] | null = null;
|
let gpuMemoryInfoCache: GPUMemoryInfo[] | null = null;
|
||||||
|
|
||||||
export async function detectCPU() {
|
export function detectCPU() {
|
||||||
if (cpuCapabilitiesCache) {
|
if (cpuCapabilitiesCache) {
|
||||||
return cpuCapabilitiesCache;
|
return cpuCapabilitiesCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await safeExecute(async () => {
|
const cpuList = osCpus();
|
||||||
const cpu = await siCpu();
|
const brand = cpuList[0]?.model;
|
||||||
|
const cores = cpuList.length;
|
||||||
|
const speed = (cpuList[0]?.speed ?? 0) / 1000;
|
||||||
|
|
||||||
const devices: { name: string; detailedName: string }[] = [];
|
const devices: { name: string; detailedName: string }[] = [];
|
||||||
if (cpu.brand) {
|
if (brand) {
|
||||||
const name = formatDeviceName(cpu.brand);
|
const name = formatDeviceName(brand);
|
||||||
|
|
||||||
devices.push({
|
devices.push({
|
||||||
detailedName: `${name} (${cpu.cores} cores) @ ${cpu.speed} GHz`,
|
detailedName: `${name} (${cores} cores) @ ${speed} GHz`,
|
||||||
name,
|
name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const capabilities = {
|
cpuCapabilitiesCache = { devices };
|
||||||
devices,
|
|
||||||
};
|
|
||||||
|
|
||||||
cpuCapabilitiesCache = capabilities;
|
|
||||||
return capabilities;
|
|
||||||
}, 'CPU detection failed');
|
|
||||||
|
|
||||||
cpuCapabilitiesCache = result ?? {
|
|
||||||
devices: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
return cpuCapabilitiesCache;
|
return cpuCapabilitiesCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,10 +164,11 @@ async function detectCUDA() {
|
||||||
export async function detectROCm() {
|
export async function detectROCm() {
|
||||||
try {
|
try {
|
||||||
const rocminfoCommand = platform === 'win32' ? 'hipInfo' : 'rocminfo';
|
const rocminfoCommand = platform === 'win32' ? 'hipInfo' : 'rocminfo';
|
||||||
|
const execOptions = platform === 'win32' ? COMMON_EXEC_OPTIONS : ROCM_EXEC_OPTIONS;
|
||||||
const [rocminfoResult, vulkanInfo, hipccVersion] = await Promise.all([
|
const [rocminfoResult, vulkanInfo, hipccVersion] = await Promise.all([
|
||||||
execa(rocminfoCommand, [], COMMON_EXEC_OPTIONS),
|
execa(rocminfoCommand, [], execOptions),
|
||||||
getVulkanInfo(),
|
getVulkanInfo(),
|
||||||
execa('hipcc', ['--version'], COMMON_EXEC_OPTIONS),
|
execa('hipcc', ['--version'], execOptions),
|
||||||
]);
|
]);
|
||||||
const { stdout } = rocminfoResult;
|
const { stdout } = rocminfoResult;
|
||||||
const { stdout: hipccOutput } = hipccVersion;
|
const { stdout: hipccOutput } = hipccVersion;
|
||||||
|
|
@ -185,9 +185,7 @@ export async function detectROCm() {
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
if (stdout.trim()) {
|
const getDriverVersion = async () => {
|
||||||
const devices = parseRocmOutput(stdout, vulkanInfo);
|
|
||||||
|
|
||||||
if (platform === 'win32') {
|
if (platform === 'win32') {
|
||||||
try {
|
try {
|
||||||
const { stdout: driverOutput } = await execa(
|
const { stdout: driverOutput } = await execa(
|
||||||
|
|
@ -198,27 +196,31 @@ export async function detectROCm() {
|
||||||
],
|
],
|
||||||
COMMON_EXEC_OPTIONS,
|
COMMON_EXEC_OPTIONS,
|
||||||
);
|
);
|
||||||
|
if (driverOutput.trim()) return driverOutput.trim();
|
||||||
if (driverOutput.trim()) {
|
|
||||||
driverVersion = driverOutput.trim();
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
} else {
|
} else {
|
||||||
try {
|
|
||||||
for (const gpu of vulkanInfo.allGPUs) {
|
for (const gpu of vulkanInfo.allGPUs) {
|
||||||
if (gpu.driverInfo && !gpu.isIntegrated) {
|
if (gpu.driverInfo && !gpu.isIntegrated) return gpu.driverInfo;
|
||||||
driverVersion = gpu.driverInfo;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (stdout.trim()) {
|
||||||
|
const devices = parseRocmOutput(stdout, vulkanInfo);
|
||||||
|
driverVersion = await getDriverVersion();
|
||||||
|
return { devices, driverVersion, version };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
if (version) {
|
||||||
devices,
|
const amdDevices = vulkanInfo.allGPUs
|
||||||
driverVersion,
|
.filter((gpu) => gpu.hasAMD && !gpu.isIntegrated)
|
||||||
version,
|
.map((gpu) => ({ isIntegrated: false, name: formatDeviceName(gpu.name) }));
|
||||||
};
|
|
||||||
|
if (amdDevices.length > 0) {
|
||||||
|
driverVersion = await getDriverVersion();
|
||||||
|
return { devices: amdDevices, driverVersion, version };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { devices: [] };
|
return { devices: [] };
|
||||||
|
|
@ -359,10 +361,13 @@ export async function detectGPUMemory() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const detectSystemMemory = async () => {
|
export const detectSystemMemory = async () => {
|
||||||
try {
|
const totalGB = (totalmem() / 1024 ** 3).toFixed(2);
|
||||||
const [memInfo, memLayout] = await Promise.all([siMem(), siMemLayout()]);
|
|
||||||
|
|
||||||
const totalGB = (memInfo.total / 1024 ** 3).toFixed(2);
|
try {
|
||||||
|
const memLayout = await Promise.race([
|
||||||
|
siMemLayout(),
|
||||||
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
|
||||||
|
]);
|
||||||
|
|
||||||
let speed: number | undefined;
|
let speed: number | undefined;
|
||||||
let type: string | undefined;
|
let type: string | undefined;
|
||||||
|
|
@ -383,15 +388,8 @@ export const detectSystemMemory = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { speed, totalGB, type };
|
||||||
speed,
|
|
||||||
totalGB,
|
|
||||||
type,
|
|
||||||
};
|
|
||||||
} catch {
|
} catch {
|
||||||
const mem = await siMem();
|
return { totalGB };
|
||||||
return {
|
|
||||||
totalGB: (mem.total / 1024 ** 3).toFixed(2),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ import { filterSpam, patchKcppSduiEmbd, patchKliteEmbd, patchLcppGzEmbd } from '
|
||||||
|
|
||||||
let koboldProcess: ChildProcess | null = null;
|
let koboldProcess: ChildProcess | null = null;
|
||||||
let isIntentionalStop = false;
|
let isIntentionalStop = false;
|
||||||
|
|
||||||
|
export const isKoboldRunning = () => koboldProcess !== null;
|
||||||
let hasProcessStartedSuccessfully = false;
|
let hasProcessStartedSuccessfully = false;
|
||||||
const preLaunchProcesses = new Set<ChildProcess>();
|
const preLaunchProcesses = new Set<ChildProcess>();
|
||||||
|
|
||||||
|
|
@ -197,11 +199,24 @@ export async function launchKoboldCpp(
|
||||||
const resolvedArgs = await resolveModelPaths(args);
|
const resolvedArgs = await resolveModelPaths(args);
|
||||||
const finalArgs = resolvedArgs.filter((arg) => arg !== '--remotetunnel');
|
const finalArgs = resolvedArgs.filter((arg) => arg !== '--remotetunnel');
|
||||||
|
|
||||||
|
await stopProxy();
|
||||||
await startProxy(koboldHost, koboldPort);
|
await startProxy(koboldHost, koboldPort);
|
||||||
|
|
||||||
|
const rocmEnv =
|
||||||
|
platform !== 'win32'
|
||||||
|
? {
|
||||||
|
HSA_OVERRIDE_GFX_VERSION: process.env.HSA_OVERRIDE_GFX_VERSION,
|
||||||
|
LD_LIBRARY_PATH: ['/opt/rocm/lib', '/opt/rocm/lib64', process.env.LD_LIBRARY_PATH]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(':'),
|
||||||
|
PATH: ['/opt/rocm/bin', process.env.PATH].filter(Boolean).join(':'),
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
const child = spawn(currentBackend.path, finalArgs, {
|
const child = spawn(currentBackend.path, finalArgs, {
|
||||||
cwd: binaryDir,
|
cwd: binaryDir,
|
||||||
detached: false,
|
detached: false,
|
||||||
|
env: { ...process.env, ...rocmEnv },
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -211,10 +226,6 @@ export async function launchKoboldCpp(
|
||||||
|
|
||||||
sendKoboldOutput(commandLine);
|
sendKoboldOutput(commandLine);
|
||||||
|
|
||||||
if (remotetunnel) {
|
|
||||||
void startTunnel(frontendPreference);
|
|
||||||
}
|
|
||||||
|
|
||||||
let readyResolve: ((value: { success: boolean; pid?: number; error?: string }) => void) | null =
|
let readyResolve: ((value: { success: boolean; pid?: number; error?: string }) => void) | null =
|
||||||
null;
|
null;
|
||||||
let readyReject: ((error: Error) => void) | null = null;
|
let readyReject: ((error: Error) => void) | null = null;
|
||||||
|
|
@ -240,6 +251,10 @@ export async function launchKoboldCpp(
|
||||||
}
|
}
|
||||||
|
|
||||||
readyResolve?.({ pid: child.pid, success: true });
|
readyResolve?.({ pid: child.pid, success: true });
|
||||||
|
|
||||||
|
if (remotetunnel && isKoboldFrontend) {
|
||||||
|
void startTunnel(frontendPreference, true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOutput = (data: Buffer) => {
|
const handleOutput = (data: Buffer) => {
|
||||||
|
|
@ -336,7 +351,7 @@ export const launchKoboldCppWithCustomFrontends = async (
|
||||||
const frontendPreference = getConfig('frontendPreference');
|
const frontendPreference = getConfig('frontendPreference');
|
||||||
const imageGenerationFrontendPreference = getConfig('imageGenerationFrontendPreference');
|
const imageGenerationFrontendPreference = getConfig('imageGenerationFrontendPreference');
|
||||||
|
|
||||||
const { isTextMode } = parseKoboldConfig(args);
|
const { isTextMode, remotetunnel } = parseKoboldConfig(args);
|
||||||
|
|
||||||
const result = await launchKoboldCpp(
|
const result = await launchKoboldCpp(
|
||||||
args,
|
args,
|
||||||
|
|
@ -353,17 +368,29 @@ export const launchKoboldCppWithCustomFrontends = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frontendPreference === 'sillytavern') {
|
if (frontendPreference === 'sillytavern') {
|
||||||
startSillyTavernFrontend(args).catch((error) => {
|
startSillyTavernFrontend(args)
|
||||||
|
.then(() => {
|
||||||
|
if (remotetunnel) {
|
||||||
|
void startTunnel(frontendPreference, true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
logError('Failed to start SillyTavern frontend:', error);
|
logError('Failed to start SillyTavern frontend:', error);
|
||||||
sendKoboldOutput(
|
sendKoboldOutput(
|
||||||
`Failed to start SillyTavern: ${error instanceof Error ? error.message : String(error)}`,
|
`Failed to start SillyTavern: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
} else if (frontendPreference === 'openwebui') {
|
} else if (frontendPreference === 'openwebui') {
|
||||||
startOpenWebUIFrontend(args).catch((error) => {
|
startOpenWebUIFrontend(args)
|
||||||
logError('Failed to start OpenWebUI frontend:', error);
|
.then(() => {
|
||||||
|
if (remotetunnel) {
|
||||||
|
void startTunnel(frontendPreference, true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logError('Failed to start Open WebUI frontend:', error);
|
||||||
sendKoboldOutput(
|
sendKoboldOutput(
|
||||||
`Failed to start OpenWebUI: ${error instanceof Error ? error.message : String(error)}`,
|
`Failed to start Open WebUI: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -36,19 +36,31 @@ const getCloudflaredAssetName = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCloudflaredDownloadUrl = async () => {
|
const getCloudflaredLatestVersion = async () => {
|
||||||
const response = await fetch(GITHUB_API.CLOUDFLARED_LATEST_RELEASE_URL);
|
const response = await fetch(GITHUB_API.CLOUDFLARED_LATEST_RELEASE_URL);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch latest cloudflared release: ${response.statusText}`);
|
throw new Error(`Failed to fetch latest cloudflared release: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const release = (await response.json()) as { tag_name: string };
|
const release = (await response.json()) as { tag_name: string };
|
||||||
return GITHUB_API.getCloudflaredDownloadUrl(release.tag_name, getCloudflaredAssetName());
|
return release.tag_name;
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadCloudflared = async (binPath: string) => {
|
const getCloudflaredDownloadUrl = (version: string) =>
|
||||||
const url = await getCloudflaredDownloadUrl();
|
GITHUB_API.getCloudflaredDownloadUrl(version, getCloudflaredAssetName());
|
||||||
sendKoboldOutput(`Downloading cloudflared from ${url}...`);
|
|
||||||
|
const getInstalledCloudflaredVersion = async (binPath: string) => {
|
||||||
|
try {
|
||||||
|
const result = await execa(binPath, ['--version']);
|
||||||
|
const match = /(\d{4}\.\d+\.\d+)/.exec(result.stdout + result.stderr);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadCloudflared = async (binPath: string, version: string) => {
|
||||||
|
const url = getCloudflaredDownloadUrl(version);
|
||||||
|
sendKoboldOutput(`Downloading cloudflared ${version} from ${url}...`);
|
||||||
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok || !response.body) {
|
||||||
|
|
@ -61,7 +73,7 @@ const downloadCloudflared = async (binPath: string) => {
|
||||||
await chmod(binPath, 0o755);
|
await chmod(binPath, 0o755);
|
||||||
}
|
}
|
||||||
|
|
||||||
sendKoboldOutput(`Downloaded cloudflared to ${binPath}`);
|
sendKoboldOutput(`Downloaded cloudflared ${version} to ${binPath}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTunnelTarget = (frontendPreference: FrontendPreference) => {
|
const getTunnelTarget = (frontendPreference: FrontendPreference) => {
|
||||||
|
|
@ -97,7 +109,10 @@ const waitForBackend = async (url: string, timeoutMs = 30_000) => {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const startTunnel = async (frontendPreference: FrontendPreference = 'koboldcpp') => {
|
export const startTunnel = async (
|
||||||
|
frontendPreference: FrontendPreference = 'koboldcpp',
|
||||||
|
skipBackendCheck = false,
|
||||||
|
) => {
|
||||||
if (activeTunnel) {
|
if (activeTunnel) {
|
||||||
return tunnelUrl;
|
return tunnelUrl;
|
||||||
}
|
}
|
||||||
|
|
@ -105,6 +120,7 @@ export const startTunnel = async (frontendPreference: FrontendPreference = 'kobo
|
||||||
try {
|
try {
|
||||||
const tunnelTarget = getTunnelTarget(frontendPreference);
|
const tunnelTarget = getTunnelTarget(frontendPreference);
|
||||||
|
|
||||||
|
if (!skipBackendCheck) {
|
||||||
sendKoboldOutput('Waiting for backend to be ready...');
|
sendKoboldOutput('Waiting for backend to be ready...');
|
||||||
const backendReady = await waitForBackend(tunnelTarget);
|
const backendReady = await waitForBackend(tunnelTarget);
|
||||||
|
|
||||||
|
|
@ -113,20 +129,43 @@ export const startTunnel = async (frontendPreference: FrontendPreference = 'kobo
|
||||||
'Backend not ready after 30 seconds. Start your backend first before enabling tunnel.',
|
'Backend not ready after 30 seconds. Start your backend first before enabling tunnel.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sendKoboldOutput('Starting Cloudflare tunnel...');
|
sendKoboldOutput(`Starting Cloudflare tunnel → ${tunnelTarget}`);
|
||||||
|
|
||||||
const bin = getCloudflaredBin();
|
const bin = getCloudflaredBin();
|
||||||
|
|
||||||
|
const latestVersion = await getCloudflaredLatestVersion();
|
||||||
const binExists = await access(bin)
|
const binExists = await access(bin)
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
|
|
||||||
if (!binExists) {
|
if (binExists) {
|
||||||
await downloadCloudflared(bin);
|
const installedVersion = await getInstalledCloudflaredVersion(bin);
|
||||||
|
const normalizedInstalled = installedVersion?.replace(/^(\d{4}\.\d+\.\d+).*$/, '$1');
|
||||||
|
const normalizedLatest = latestVersion.replace(/^[v]?(\d{4}\.\d+\.\d+).*$/, '$1');
|
||||||
|
if (normalizedInstalled !== normalizedLatest) {
|
||||||
|
sendKoboldOutput(
|
||||||
|
`Updating cloudflared ${installedVersion ?? 'unknown'} → ${latestVersion}`,
|
||||||
|
);
|
||||||
|
await downloadCloudflared(bin, latestVersion);
|
||||||
|
} else {
|
||||||
|
sendKoboldOutput(`cloudflared ${installedVersion} is up to date`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await downloadCloudflared(bin, latestVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tunnel = execa(bin, ['tunnel', '--url', tunnelTarget, '--no-autoupdate']);
|
const nullDevice = platform === 'win32' ? 'NUL' : '/dev/null';
|
||||||
|
const tunnel = execa(bin, [
|
||||||
|
'tunnel',
|
||||||
|
'--config',
|
||||||
|
nullDevice,
|
||||||
|
'--url',
|
||||||
|
tunnelTarget,
|
||||||
|
'--no-autoupdate',
|
||||||
|
]);
|
||||||
|
tunnel.catch(() => {});
|
||||||
|
|
||||||
activeTunnel = tunnel;
|
activeTunnel = tunnel;
|
||||||
|
|
||||||
|
|
@ -134,19 +173,25 @@ export const startTunnel = async (frontendPreference: FrontendPreference = 'kobo
|
||||||
let output = '';
|
let output = '';
|
||||||
let urlFound = false;
|
let urlFound = false;
|
||||||
|
|
||||||
tunnel.stderr?.on('data', (data: Buffer) => {
|
const onTunnelOutput = (text: string) => {
|
||||||
const text = data.toString();
|
|
||||||
output += text;
|
output += text;
|
||||||
if (text.includes('429') || text.includes('Too Many Requests')) {
|
if (text.includes('429') || text.includes('Too Many Requests')) {
|
||||||
rateLimited = true;
|
rateLimited = true;
|
||||||
}
|
}
|
||||||
});
|
const match = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/.exec(text);
|
||||||
|
if (match && match[0] !== tunnelUrl) {
|
||||||
|
tunnelUrl = match[0];
|
||||||
|
if (urlFound) {
|
||||||
|
sendKoboldOutput(`Tunnel URL: ${tunnelUrl}`);
|
||||||
|
}
|
||||||
|
sendToRenderer('tunnel-url-changed', tunnelUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
tunnel.stdout?.on('data', (data: Buffer) => {
|
tunnel.stderr?.on('data', (data: Buffer) => onTunnelOutput(data.toString()));
|
||||||
output += data.toString();
|
tunnel.stdout?.on('data', (data: Buffer) => onTunnelOutput(data.toString()));
|
||||||
});
|
|
||||||
|
|
||||||
const url = await new Promise<string>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
const message = rateLimited
|
const message = rateLimited
|
||||||
? 'Cloudflare rate limit exceeded. Please wait a few minutes and try again.'
|
? 'Cloudflare rate limit exceeded. Please wait a few minutes and try again.'
|
||||||
|
|
@ -155,17 +200,14 @@ export const startTunnel = async (frontendPreference: FrontendPreference = 'kobo
|
||||||
reject(new Error(message));
|
reject(new Error(message));
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
const checkForUrl = () => {
|
const checkInterval = setInterval(() => {
|
||||||
const match = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/.exec(output);
|
if (tunnelUrl) {
|
||||||
if (match) {
|
|
||||||
urlFound = true;
|
urlFound = true;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
clearInterval(checkInterval);
|
clearInterval(checkInterval);
|
||||||
resolve(match[0]);
|
resolve();
|
||||||
}
|
}
|
||||||
};
|
}, 100);
|
||||||
|
|
||||||
const checkInterval = setInterval(checkForUrl, 100);
|
|
||||||
|
|
||||||
tunnel.once('error', (error) => {
|
tunnel.once('error', (error) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
@ -182,11 +224,7 @@ export const startTunnel = async (frontendPreference: FrontendPreference = 'kobo
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
||||||
|
|
||||||
tunnelUrl = url;
|
|
||||||
sendKoboldOutput(`Tunnel ready at ${tunnelUrl}`);
|
sendKoboldOutput(`Tunnel ready at ${tunnelUrl}`);
|
||||||
sendToRenderer('tunnel-url-changed', tunnelUrl);
|
|
||||||
|
|
||||||
tunnel.on('error', (error: Error) => {
|
tunnel.on('error', (error: Error) => {
|
||||||
logError(`Tunnel error: ${error.message}`, error);
|
logError(`Tunnel error: ${error.message}`, error);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { ChildProcess } from 'node:child_process';
|
import type { ChildProcess } from 'node:child_process';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
|
import { readFile, unlink, writeFile } from 'node:fs/promises';
|
||||||
import { createServer, request } from 'node:http';
|
import { createServer, request } from 'node:http';
|
||||||
import type { Server } from 'node:http';
|
import type { Server } from 'node:http';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
@ -10,7 +11,7 @@ import { PROXY } from '@/constants/proxy';
|
||||||
import { pathExists, readJsonFile, writeJsonFile } from '@/utils/node/fs';
|
import { pathExists, readJsonFile, writeJsonFile } from '@/utils/node/fs';
|
||||||
import { parseKoboldConfig } from '@/utils/node/kobold';
|
import { parseKoboldConfig } from '@/utils/node/kobold';
|
||||||
import { logError, tryExecute } from '@/utils/node/logging';
|
import { logError, tryExecute } from '@/utils/node/logging';
|
||||||
import { terminateProcess } from '@/utils/node/process';
|
import { killProcessByPid, terminateProcess } from '@/utils/node/process';
|
||||||
|
|
||||||
import { getInstallDir } from './config';
|
import { getInstallDir } from './config';
|
||||||
import { getNodeEnvironment } from './dependencies';
|
import { getNodeEnvironment } from './dependencies';
|
||||||
|
|
@ -39,6 +40,27 @@ const getSillyTavernServerPath = () =>
|
||||||
const getSillyTavernSettingsPath = () =>
|
const getSillyTavernSettingsPath = () =>
|
||||||
join(getSillyTavernDataDir(), 'default-user', 'settings.json');
|
join(getSillyTavernDataDir(), 'default-user', 'settings.json');
|
||||||
|
|
||||||
|
const getSillyTavernPidPath = () => join(getSillyTavernInstallDir(), 'sillytavern.pid');
|
||||||
|
|
||||||
|
async function saveSillyTavernPid(pid: number) {
|
||||||
|
await writeFile(getSillyTavernPidPath(), String(pid), 'utf8').catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearSillyTavernPid() {
|
||||||
|
await unlink(getSillyTavernPidPath()).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function killOrphanedSillyTavern() {
|
||||||
|
const pidPath = getSillyTavernPidPath();
|
||||||
|
if (!(await pathExists(pidPath))) return;
|
||||||
|
const raw = await readFile(pidPath, 'utf8').catch(() => null);
|
||||||
|
const pid = raw ? parseInt(raw.trim(), 10) : NaN;
|
||||||
|
if (!isNaN(pid)) {
|
||||||
|
await killProcessByPid(pid);
|
||||||
|
}
|
||||||
|
await clearSillyTavernPid();
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureSillyTavernInstalled() {
|
async function ensureSillyTavernInstalled() {
|
||||||
const serverPath = getSillyTavernServerPath();
|
const serverPath = getSillyTavernServerPath();
|
||||||
const installDir = getSillyTavernInstallDir();
|
const installDir = getSillyTavernInstallDir();
|
||||||
|
|
@ -73,7 +95,10 @@ async function ensureSillyTavernInstalled() {
|
||||||
sendKoboldOutput('Installing SillyTavern via npm...');
|
sendKoboldOutput('Installing SillyTavern via npm...');
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
await killOrphanedSillyTavern();
|
||||||
|
|
||||||
|
const runNpmInstall = () =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
const npmProcess = spawn(
|
const npmProcess = spawn(
|
||||||
'npm',
|
'npm',
|
||||||
[
|
[
|
||||||
|
|
@ -105,17 +130,31 @@ async function ensureSillyTavernInstalled() {
|
||||||
sendKoboldOutput('SillyTavern is ready');
|
sendKoboldOutput('SillyTavern is ready');
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
if (errorOutput) {
|
reject(Object.assign(new Error(`npm install failed with code ${code}`), { errorOutput }));
|
||||||
sendKoboldOutput(`npm install error: ${errorOutput.trim()}`);
|
|
||||||
}
|
|
||||||
reject(new Error(`npm install failed with code ${code}`));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
npmProcess.on('error', (error) => {
|
npmProcess.on('error', reject);
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runNpmInstall();
|
||||||
|
} catch (err) {
|
||||||
|
const isBusy =
|
||||||
|
err instanceof Error && (err.message.includes('4294963214') || err.message.includes('-4082'));
|
||||||
|
|
||||||
|
if (isBusy && platform === 'win32') {
|
||||||
|
sendKoboldOutput('File busy, retrying npm install...');
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 2000));
|
||||||
|
await runNpmInstall();
|
||||||
|
} else {
|
||||||
|
const output = (err as { errorOutput?: string }).errorOutput;
|
||||||
|
if (output) {
|
||||||
|
sendKoboldOutput(`npm install error: ${output.trim()}`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createNpxProcess(args: string[]) {
|
async function createNpxProcess(args: string[]) {
|
||||||
|
|
@ -289,8 +328,15 @@ async function waitForSillyTavernToStart() {
|
||||||
|
|
||||||
const createProxyServer = (targetPort: number, proxyPort: number) => {
|
const createProxyServer = (targetPort: number, proxyPort: number) => {
|
||||||
proxyServer = createServer((req, res) => {
|
proxyServer = createServer((req, res) => {
|
||||||
|
const headers = { ...req.headers };
|
||||||
|
delete headers['x-forwarded-for'];
|
||||||
|
delete headers['x-real-ip'];
|
||||||
|
delete headers['cf-connecting-ip'];
|
||||||
|
delete headers['x-forwarded-host'];
|
||||||
|
delete headers.forwarded;
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
headers: req.headers,
|
headers,
|
||||||
hostname: 'localhost',
|
hostname: 'localhost',
|
||||||
method: req.method,
|
method: req.method,
|
||||||
path: req.url,
|
path: req.url,
|
||||||
|
|
@ -298,9 +344,9 @@ const createProxyServer = (targetPort: number, proxyPort: number) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const proxyReq = request(options, (proxyRes) => {
|
const proxyReq = request(options, (proxyRes) => {
|
||||||
const headers = { ...proxyRes.headers };
|
const responseHeaders = { ...proxyRes.headers };
|
||||||
delete headers['x-frame-options'];
|
delete responseHeaders['x-frame-options'];
|
||||||
res.writeHead(proxyRes.statusCode ?? 200, headers);
|
res.writeHead(proxyRes.statusCode ?? 200, responseHeaders);
|
||||||
proxyRes.pipe(res);
|
proxyRes.pipe(res);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -341,6 +387,8 @@ export async function startFrontend(args: string[]) {
|
||||||
|
|
||||||
sendKoboldOutput(`Starting ${config.name} frontend on port ${config.port}...`);
|
sendKoboldOutput(`Starting ${config.name} frontend on port ${config.port}...`);
|
||||||
|
|
||||||
|
createProxyServer(config.port, config.proxyPort);
|
||||||
|
|
||||||
const sillyTavernDataDir = getSillyTavernDataDir();
|
const sillyTavernDataDir = getSillyTavernDataDir();
|
||||||
|
|
||||||
const sillyTavernArgs = [
|
const sillyTavernArgs = [
|
||||||
|
|
@ -353,6 +401,10 @@ export async function startFrontend(args: string[]) {
|
||||||
|
|
||||||
sillyTavernProcess = await createNpxProcess(sillyTavernArgs);
|
sillyTavernProcess = await createNpxProcess(sillyTavernArgs);
|
||||||
|
|
||||||
|
if (sillyTavernProcess.pid) {
|
||||||
|
void saveSillyTavernPid(sillyTavernProcess.pid);
|
||||||
|
}
|
||||||
|
|
||||||
if (sillyTavernProcess.stdout) {
|
if (sillyTavernProcess.stdout) {
|
||||||
sillyTavernProcess.stdout.on('data', (data: Buffer) => {
|
sillyTavernProcess.stdout.on('data', (data: Buffer) => {
|
||||||
sendKoboldOutput(data.toString(), true);
|
sendKoboldOutput(data.toString(), true);
|
||||||
|
|
@ -371,6 +423,7 @@ export async function startFrontend(args: string[]) {
|
||||||
: `SillyTavern exited with code ${code}`;
|
: `SillyTavern exited with code ${code}`;
|
||||||
sendKoboldOutput(message);
|
sendKoboldOutput(message);
|
||||||
sillyTavernProcess = null;
|
sillyTavernProcess = null;
|
||||||
|
void clearSillyTavernPid();
|
||||||
});
|
});
|
||||||
|
|
||||||
sillyTavernProcess.on('error', (error) => {
|
sillyTavernProcess.on('error', (error) => {
|
||||||
|
|
@ -381,12 +434,12 @@ export async function startFrontend(args: string[]) {
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForSillyTavernToStart();
|
await waitForSillyTavernToStart();
|
||||||
createProxyServer(config.port, config.proxyPort);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(
|
logError(
|
||||||
`Failed to start SillyTavern: ${error instanceof Error ? error.message : String(error)}`,
|
`Failed to start SillyTavern: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
error as Error,
|
error as Error,
|
||||||
);
|
);
|
||||||
|
await stopFrontend();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -410,4 +463,10 @@ export async function stopFrontend() {
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
if (platform === 'win32') {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
88
src/theme.ts
88
src/theme.ts
|
|
@ -1,32 +1,47 @@
|
||||||
import { createTheme } from '@mantine/core';
|
import { createTheme, v8CssVariablesResolver } from '@mantine/core';
|
||||||
import type { CSSVariablesResolver } from '@mantine/core';
|
import type { CSSVariablesResolver } from '@mantine/core';
|
||||||
|
|
||||||
export const theme = createTheme({
|
export const theme = createTheme({
|
||||||
black: '#101113',
|
black: '#101113',
|
||||||
|
primaryColor: 'brand',
|
||||||
colors: {
|
colors: {
|
||||||
|
// Steel-blue: technical, enthusiast-grade — vibrant but cooler than Mantine default
|
||||||
|
brand: [
|
||||||
|
'#eef5ff', // 0 — faint wash
|
||||||
|
'#d8eaff', // 1
|
||||||
|
'#b0d0ff', // 2
|
||||||
|
'#7aaff7', // 3
|
||||||
|
'#4890f0', // 4 — dark mode accent
|
||||||
|
'#2b72e0', // 5
|
||||||
|
'#1e5ec8', // 6 — light primary
|
||||||
|
'#184eb0', // 7
|
||||||
|
'#113d88', // 8 — dark primary
|
||||||
|
'#0c2d64', // 9 — deep
|
||||||
|
],
|
||||||
|
// OKLCH tinted toward steel-blue hue 240 — creates subconscious cohesion with brand accent
|
||||||
gray: [
|
gray: [
|
||||||
'#f8f9fa',
|
'oklch(97.5% 0.005 240)', // 0 — near-white surface
|
||||||
'#f1f3f4',
|
'oklch(95% 0.006 240)', // 1 — subtle wash
|
||||||
'#e9ecef',
|
'oklch(92% 0.007 240)', // 2 — light bg
|
||||||
'#dee2e6',
|
'oklch(89% 0.007 240)', // 3 — border
|
||||||
'#ced4da',
|
'oklch(84% 0.008 240)', // 4 — input border
|
||||||
'#adb5bd',
|
'oklch(73% 0.009 240)', // 5 — secondary text / muted
|
||||||
'#6c757d',
|
'oklch(50% 0.009 240)', // 6 — mid text
|
||||||
'#495057',
|
'oklch(37% 0.008 240)', // 7 — dark label
|
||||||
'#343a40',
|
'oklch(26% 0.007 240)', // 8 — dark text
|
||||||
'#212529',
|
'oklch(18% 0.006 240)', // 9 — near-black
|
||||||
],
|
],
|
||||||
dark: [
|
dark: [
|
||||||
'#c1c2c5',
|
'oklch(79% 0.008 240)', // 0 — main text
|
||||||
'#a6a7ab',
|
'oklch(70% 0.008 240)', // 1 — secondary text
|
||||||
'#909296',
|
'oklch(61% 0.008 240)', // 2 — dimmed
|
||||||
'#5c5f66',
|
'oklch(48% 0.009 240)', // 3 — borders
|
||||||
'#373a40',
|
'oklch(40% 0.010 240)', // 4 — input background
|
||||||
'#2c2e33',
|
'oklch(32% 0.010 240)', // 5 — card background
|
||||||
'#25262b',
|
'oklch(25% 0.009 240)', // 6 — component background
|
||||||
'#1a1b1e',
|
'oklch(20% 0.008 240)', // 7 — raised surface
|
||||||
'#141517',
|
'oklch(15% 0.007 240)', // 8 — body
|
||||||
'#101113',
|
'oklch(11% 0.006 240)', // 9 — deepest
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
|
|
@ -52,22 +67,35 @@ export const theme = createTheme({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontFamily: 'Inter, sans-serif',
|
fontFamily: 'Geist, sans-serif',
|
||||||
headings: {
|
headings: {
|
||||||
fontFamily: 'Inter, sans-serif',
|
fontFamily: "'Barlow Semi Condensed', sans-serif",
|
||||||
|
sizes: {
|
||||||
|
h1: { fontWeight: '600', lineHeight: '1.15' },
|
||||||
|
h2: { fontWeight: '600', lineHeight: '1.2' },
|
||||||
|
h3: { fontWeight: '500', lineHeight: '1.25' },
|
||||||
|
h4: { fontWeight: '500', lineHeight: '1.3' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
white: '#fafafa',
|
white: '#fafafa',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const cssVariablesResolver: CSSVariablesResolver = () => ({
|
export const cssVariablesResolver: CSSVariablesResolver = (t) => {
|
||||||
|
const v8 = v8CssVariablesResolver(t);
|
||||||
|
return {
|
||||||
|
variables: { ...v8.variables },
|
||||||
dark: {
|
dark: {
|
||||||
'--mantine-color-body': '#0f0f0f',
|
...v8.dark,
|
||||||
'--mantine-color-default-border': '#2a2a2a',
|
'--mantine-color-body': 'oklch(22% 0.008 240)',
|
||||||
|
'--mantine-color-default-border': 'oklch(48% 0.009 240)',
|
||||||
|
'--gerbil-link-color': 'var(--mantine-color-brand-4)',
|
||||||
},
|
},
|
||||||
light: {
|
light: {
|
||||||
'--mantine-color-body': '#fafafa',
|
...v8.light,
|
||||||
|
'--mantine-color-body': 'oklch(97.5% 0.005 240)',
|
||||||
'--mantine-color-white': '#fafafa',
|
'--mantine-color-white': '#fafafa',
|
||||||
'--mantine-color-default-border': '#dee2e6',
|
'--mantine-color-default-border': 'oklch(89% 0.007 240)',
|
||||||
|
'--gerbil-link-color': 'var(--mantine-color-brand-6)',
|
||||||
},
|
},
|
||||||
variables: {},
|
};
|
||||||
});
|
};
|
||||||
|
|
|
||||||
3
src/types/electron.d.ts
vendored
3
src/types/electron.d.ts
vendored
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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, '&')
|
.replace(/&/g, '&')
|
||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, '>')
|
.replace(/>/g, '>')
|
||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
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);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue