Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ name: CI
# gate in tests/versions.test.ts), and the Rust unit tests. Releases are
# handled by release.yml; this workflow never publishes anything.
#
# Triggers on every push so dev branches get feedback without a PR, and on
# PRs to main so the merge gate sees a check. NOTE: this workflow does NOT
# Triggers on pushes to main so each merge commit gets its own verdict, and
# on PRs to main so the merge gate sees a check before merge. Dev branches
# get CI once they open a PR. NOTE: this workflow does NOT
# gate release.yml — both fire on the same push-to-main event and run in
# parallel, and release.yml drafts its release without waiting for CI (the
# draft still needs a manual publish, which is the human backstop). The
Expand All @@ -16,6 +17,7 @@ name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
Expand Down
36 changes: 36 additions & 0 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,42 @@ streaming starts; subsequent replies are instant.
launch, turn on **Settings → General → Default model preload**. The first
chat will start fast, at the cost of pinning VRAM as soon as Loach opens.

### Making Ollama generate faster

**Problem.** Generation is slower than you'd expect, or you want to squeeze
more tokens/sec out of your hardware.

**Solution.** These are environment variables on the **Ollama server** (not
Loach settings) — set them where `ollama serve` runs, then restart it. As of
Ollama 0.5+:

- **`OLLAMA_FLASH_ATTENTION=1`** — enables flash attention, which lowers memory
bandwidth and usually speeds up generation, especially at longer contexts.
Newer builds enable it by default for some models on the new engine; setting
it explicitly is harmless.
- **`OLLAMA_KV_CACHE_TYPE=q8_0`** — quantizes the KV cache to 8-bit, roughly
halving its memory so you can run a bigger context (or a bigger model) in the
same VRAM. Only takes effect **with flash attention enabled**. `q4_0` is
smaller but lossier; `f16` (the default) is highest quality.
- **`OLLAMA_KEEP_ALIVE=30m`** (or `-1` for "until unloaded") — how long Ollama
keeps a model resident after a request, so a reply after a pause skips the
cold reload. Loach also exposes this under **Settings → Providers → Keep
model loaded**, which is sent with every request and overrides the env
default.
- **`OLLAMA_NUM_PARALLEL=1`** — caps concurrent requests per model. The default
splits VRAM across parallel slots; pinning it to 1 gives a single chat the
whole budget (and the largest usable context).
- **`OLLAMA_MAX_LOADED_MODELS=1`** — keeps only one model resident at a time.
Useful on a single-GPU machine where a second model would evict the first or
spill to system RAM.

Setting environment variables for Ollama: on Windows, use **System Properties →
Environment Variables** (or `setx OLLAMA_FLASH_ATTENTION 1`) and restart the
Ollama tray app. On Linux with systemd, `systemctl edit ollama` (add an
`[Service]` block of `Environment="OLLAMA_…=…"` lines) then
`systemctl restart ollama`. On macOS, `launchctl setenv OLLAMA_FLASH_ATTENTION 1`
and relaunch the Ollama app.

---

## 4. Attachments
Expand Down
14 changes: 14 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Loach</title>
<!-- Paint the app's dark base immediately so there's no white flash while
the bundle's CSS loads on a cold start. Matches `.dark .app-mesh` in
globals.css; this inline <style> is allowed by the window CSP's
`style-src 'unsafe-inline'` (no inline script, so script-src is
untouched). Light-theme users still transition to light after hydration
— same as before, just without the preceding white frame.

MUST target `body`, NOT `html`: the Aurora gradient is a `-z-10` fixed
div (`.app-mesh`). The body→canvas background propagation that lets a
negative-z-index element show through only happens while `<html>` has
NO background of its own. Giving `<html>` a background disables that
propagation, so the opaque `body` background then paints OVER the mesh
and Aurora collapses to a flat fill (looks like the Solid theme). -->
<style>body{background:#06070f}</style>
</head>
<body class="bg-background text-foreground antialiased">
<div id="root"></div>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "loach",
"private": true,
"version": "1.2.3",
"version": "1.2.4",
"type": "module",
"description": "Native local LLM chat client for Windows, Linux and macOS (Ollama + OpenAI-compatible).",
"license": "MIT",
Expand Down
8 changes: 8 additions & 0 deletions releases/1.2.4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
**What's new**
- **Faster across the board.** Loach cold-starts quicker, replies begin sooner, and long answers stream more smoothly even in big sessions with large transcripts. Under the hood: lazier startup and hydration, cached model and MCP catalogs, windowed transcript rendering, chunked markdown while streaming, a pooled database reader, and a leaner release build.
- **In-app updates for Linux `.deb` and `.rpm`.** Debian and RPM installs now update from within Loach the way AppImage already did — Loach downloads the signed package, elevates to install it, and keeps your package database consistent. (Updating *to* this release still needs one manual download; after that, updates are in-app.)

**Fixes**
- **Ollama keep-alive now saves.** Changing how long models stay resident in memory now persists instead of silently reverting to the default.
- **"Open in VS Code" on macOS.** The Code Canvas's *Open in VS Code* button now works when Loach was launched from Finder.
- More accurate tokens/sec readout, plus assorted stability hardening under the hood.
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 18 additions & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "loach"
version = "1.2.3"
version = "1.2.4"
description = "Native local LLM chat client (Ollama + OpenAI-compatible)."
authors = ["Loach"]
license = "MIT"
Expand Down Expand Up @@ -91,3 +91,20 @@ tempfile = "3"
# this feature is used for production builds or when a dev server is not specified
# DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]

[profile.release]
# Smaller binary → smaller NSIS installer + updater `.nsis.zip` download for
# every user on every release, and a faster cold start (Windows cold-start cost
# is roughly proportional to bytes read + Defender-scanned). `lto = "thin"`
# captures most of full LTO's size win for a fraction of the link time;
# `codegen-units = 1` lets the optimizer see the whole crate; `strip` drops the
# symbol table (a real win on the Linux/macOS artifacts — on Windows MSVC the
# symbols already live in a separate, unshipped PDB).
#
# Intentionally NOT set: `panic = "abort"` (Tauri commands rely on unwinding a
# panic and catching it — abort would turn any command panic into a full-app
# crash) and `opt-level = "s"` (would measurably slow the token-streaming serde
# hot path; the default opt-level = 3 stays).
lto = "thin"
codegen-units = 1
strip = "symbols"
78 changes: 67 additions & 11 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,15 @@ pub async fn list_messages(
state.db.list_messages(&session_id).map_err(err)
}

/// Per-session message counts (`{ session_id: count }`). Lets the frontend
/// cull empty sessions at startup without loading every transcript over IPC.
#[tauri::command]
pub async fn session_message_counts(
state: State<'_, AppState>,
) -> Result<std::collections::HashMap<String, i64>, String> {
state.db.session_message_counts().map_err(err)
}

#[derive(Debug, Deserialize)]
pub struct AppendMessageArgs {
pub session_id: String,
Expand Down Expand Up @@ -435,6 +444,7 @@ const WRITABLE_SETTING_KEYS: &[&str] = &[
"temporal_awareness",
"web_fetch_enabled",
"low_vram_global",
"ollama_keep_alive",
"thinking_default",
"default_tone_id",
"onboarding_completed",
Expand Down Expand Up @@ -623,8 +633,19 @@ pub async fn ollama_preload_model(
state: State<'_, AppState>,
base_url: String,
model: String,
num_ctx: Option<u32>,
low_vram: Option<bool>,
num_gpu: Option<u32>,
) -> Result<(), String> {
providers::ollama::preload_model(&state.http, &base_url, &model)
let keep_alive = state
.db
.get_setting("ollama_keep_alive")
.ok()
.flatten()
.as_deref()
.and_then(providers::ollama::keep_alive_value);
let options = providers::ollama::preload_options(num_ctx, low_vram, num_gpu);
providers::ollama::preload_model(&state.http, &base_url, &model, keep_alive, options)
.await
.map_err(err)
}
Expand Down Expand Up @@ -1398,7 +1419,7 @@ pub async fn mcp_save(
.map(|m| serde_json::to_string(m).map_err(err))
.transpose()?;

state
let saved = state
.db
.upsert_mcp_server(
input.id.as_deref(),
Expand All @@ -1407,12 +1428,18 @@ pub async fn mcp_save(
headers_json.as_deref(),
input.enabled.unwrap_or(true),
)
.map_err(err)
.map_err(err)?;
// The cached tool catalogue (and the slugs derived from server names) is
// now stale — drop it so the next send re-aggregates with this change.
crate::mcp::invalidate_tools_cache(&state.mcp_tools_cache).await;
Ok(saved)
}

#[tauri::command]
pub async fn mcp_delete(state: State<'_, AppState>, id: String) -> Result<(), String> {
state.db.delete_mcp_server(&id).map_err(err)
state.db.delete_mcp_server(&id).map_err(err)?;
crate::mcp::invalidate_tools_cache(&state.mcp_tools_cache).await;
Ok(())
}

/// Probe the given MCP server config (handshake + list tools). Accepts the
Expand Down Expand Up @@ -1456,6 +1483,7 @@ pub async fn chat_stream(
let registry = state.streams.clone();
let provider = request.provider.clone();
let db = state.db.clone();
let mcp_cache = state.mcp_tools_cache.clone();

// Register the cancel Notify SYNCHRONOUSLY here — before we spawn the
// worker — so a `chat_cancel(stream_id)` arriving while we're still
Expand All @@ -1481,7 +1509,7 @@ pub async fn chat_stream(
let (tools, errors) = if request.private {
(Vec::new(), Vec::new())
} else {
let agg_fut = crate::mcp::aggregate_tools(&db);
let agg_fut = crate::mcp::aggregate_tools_cached(&db, &mcp_cache);
tokio::select! {
biased;
_ = cancel.notified() => {
Expand Down Expand Up @@ -1882,6 +1910,8 @@ pub async fn import_data_with_dialog(
.await
.map_err(|e| format!("import restore task panicked: {e}"))??;

// The restore rewrote the mcp_servers table — drop any cached catalogue.
crate::mcp::invalidate_tools_cache(&state.mcp_tools_cache).await;
Ok(Some(stats))
}

Expand Down Expand Up @@ -2040,7 +2070,26 @@ pub fn open_in_vscode(code: String, filename: String) -> Result<(), String> {
.spawn()
.map_err(|e| format!("Couldn't launch VS Code: {e}"))?;
}
#[cfg(not(windows))]
#[cfg(target_os = "macos")]
{
// GUI apps launched from Finder/Dock inherit a minimal PATH
// (/usr/bin:/bin:/usr/sbin:/sbin) that excludes /usr/local/bin (Intel)
// and /opt/homebrew/bin (Apple Silicon) where VS Code's `code` shim
// lives, so spawning `code` directly fails even when it works from a
// terminal. `open` is always at /usr/bin/open and resolves the app
// through LaunchServices, which doesn't depend on PATH. `.status()`
// returns as soon as `open` hands off (fast) and is non-zero when the
// app can't be found, so we can still surface the `not_found` hint.
let status = Command::new("open")
.args(["-a", "Visual Studio Code"])
.arg(&path)
.status()
.map_err(|e| format!("Couldn't launch VS Code: {e}"))?;
if !status.success() {
return Err(not_found);
}
}
#[cfg(all(unix, not(target_os = "macos")))]
{
Command::new("code").arg(&path).spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Expand All @@ -2057,8 +2106,11 @@ pub fn open_in_vscode(code: String, filename: String) -> Result<(), String> {
// ---------- updater support ----------
//
// The Tauri updater can replace a Windows NSIS install or a Linux AppImage in
// place, but it cannot upgrade a `.deb`/`.rpm` install — that has to go
// through the system package manager. On macOS the updater patches the `.app`
// place. Since updater plugin 2.10 it can also upgrade `.deb`/`.rpm` installs:
// it downloads the signed package advertised by the format-specific
// `latest.json` key (`linux-x86_64-deb` / `-rpm`) and elevates via pkexec
// (falling back to zenity/kdialog + sudo) to run `dpkg -i` / `rpm -U`, so the
// package database stays consistent. On macOS the updater patches the `.app`
// bundle in place; that works even though we don't sign with Apple, because
// the updater's integrity check uses our own Ed25519 signature (separate from
// Apple notarization). We expose this so the UI can hide the "Check for
Expand All @@ -2073,10 +2125,14 @@ pub fn updater_supported() -> bool {
#[cfg(target_os = "linux")]
{
// AppImage runtimes set $APPIMAGE to the absolute path of the running
// bundle; nothing else does. Absence of the var means we're running
// from a `.deb`, a dev build, or `cargo run` — none of which the
// updater plugin can patch.
// bundle; nothing else does. For `.deb`/`.rpm` we read the bundle-type
// marker the bundler patches into the binary at build time — the same
// marker the updater plugin keys its install path off, so this gate
// can't disagree with what the plugin would actually do. Dev builds
// and `cargo run` have no marker and report unsupported, as before.
use tauri::utils::{config::BundleType, platform::bundle_type};
std::env::var("APPIMAGE").is_ok()
|| matches!(bundle_type(), Some(BundleType::Deb | BundleType::Rpm))
}
#[cfg(target_os = "macos")]
{
Expand Down
Loading
Loading