From bd54e8831b8dffa2844b10400ada4273f735e008 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Mon, 8 Jun 2026 11:33:49 -0500 Subject: [PATCH] revert: "feat: add a Providers setting to connect Thuki to a local or remote Ollama" Signed-off-by: Logan Nguyen --- CHANGELOG.md | 6 +- CLAUDE.md | 6 +- docs/configurations.md | 63 +-- src-tauri/src/commands.rs | 183 ++---- src-tauri/src/config/defaults.rs | 27 +- src-tauri/src/config/loader.rs | 127 +---- src-tauri/src/config/migrate.rs | 47 -- src-tauri/src/config/mod.rs | 5 +- src-tauri/src/config/schema.rs | 137 +---- src-tauri/src/config/tests.rs | 523 +----------------- src-tauri/src/history.rs | 9 +- src-tauri/src/lib.rs | 65 +-- src-tauri/src/models.rs | 184 ++---- src-tauri/src/search/mod.rs | 29 +- src-tauri/src/search/pipeline.rs | 8 +- src-tauri/src/search/types.rs | 2 +- src-tauri/src/settings_commands.rs | 83 +-- src-tauri/src/settings_commands/tests.rs | 229 ++------ src-tauri/src/trace/mod.rs | 2 +- src-tauri/src/trace/registry.rs | 6 +- src-tauri/src/warmup.rs | 18 +- src/App.tsx | 8 +- src/__tests__/App.test.tsx | 158 +++--- src/components/ChatBubble.tsx | 4 +- src/components/ErrorCard.tsx | 12 +- src/components/__tests__/ChatBubble.test.tsx | 4 +- src/components/__tests__/ErrorCard.test.tsx | 10 +- src/contexts/ConfigContext.tsx | 27 +- src/contexts/__tests__/ConfigContext.test.tsx | 132 +---- .../__tests__/useConversationHistory.test.tsx | 2 +- .../{useModel.test.tsx => useOllama.test.tsx} | 240 ++++---- src/hooks/useConversationHistory.ts | 6 +- src/hooks/{useModel.ts => useOllama.ts} | 18 +- src/lib/__tests__/exportSerializer.test.ts | 2 +- src/lib/exportSerializer.ts | 2 +- src/settings/SettingsWindow.test.tsx | 18 +- src/settings/components/SaveField.test.tsx | 18 +- src/settings/configHelpers.test.ts | 4 +- src/settings/configHelpers.ts | 11 +- src/settings/hooks/useConfigSync.test.ts | 26 +- src/settings/hooks/useDebouncedSave.test.ts | 18 +- src/settings/tabs/ModelTab.tsx | 106 +--- src/settings/tabs/tabs.test.tsx | 284 +--------- src/settings/types.ts | 12 +- src/styles/settings.module.css | 33 -- src/testUtils/README.md | 4 +- src/utils/__tests__/isNonLocalUrl.test.ts | 51 -- src/utils/isNonLocalUrl.ts | 68 --- src/utils/replaceSelection.ts | 2 +- src/view/ConversationView.tsx | 2 +- 50 files changed, 573 insertions(+), 2468 deletions(-) delete mode 100644 src-tauri/src/config/migrate.rs rename src/hooks/__tests__/{useModel.test.tsx => useOllama.test.tsx} (92%) rename src/hooks/{useModel.ts => useOllama.ts} (98%) delete mode 100644 src/utils/__tests__/isNonLocalUrl.test.ts delete mode 100644 src/utils/isNonLocalUrl.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c7e62a38..e4aebf07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **BREAKING**: Renamed `[debug] search_trace_enabled` to `trace_enabled` (now covers both chat and search). Rename the field in your `config.toml` after upgrading. Trace file layout also changed to `traces/{chat,search}/.jsonl`. -- **Inference providers.** Thuki now reaches models through a typed provider list instead of a single hardcoded Ollama endpoint. The `[inference]` section gains `active_provider` and a `[[inference.providers]]` array (Built-in + Ollama in this release); each provider keeps its own selected model. Existing Ollama users are migrated automatically: a legacy flat `ollama_url` becomes the Ollama provider's `base_url`, and the previously selected model is carried over, so nothing changes for them. The Ollama provider is reached over its native API exactly as before; the Built-in (Thuki) engine is reserved for an upcoming version. Settings gains a Providers section (editable Ollama URL with a non-local-server warning, per-provider model picker). -- The internal inference command/hook/error model were renamed to be engine-agnostic: `ask_ollama` → `ask_model`, the `useOllama` hook → `useModel`, and `OllamaError`/`OllamaErrorKind` → `EngineError`/`EngineErrorKind` (the `NotRunning` variant is now `EngineUnreachable`). External callers that invoked `ask_ollama` directly must update to `ask_model`. -- The `ask_model`, `search_pipeline`, and `capture_full_screen_command` Tauri commands now require a `conversationId: String` argument (and `ask_model` additionally requires `isFirstTurn: bool` and `slashCommand: Option`). The frontend's `useModel` hook generates a stable trace id per session and threads it transparently. External callers that invoked these commands directly must update their `invoke()` calls. A new fire-and-forget `record_conversation_end` command lets the frontend signal end-of-conversation (used by `useModel.reset()` and `useModel.loadMessages()`) so the chat-domain trace file gets a clean closing line. -- **BREAKING**: Renamed the `[model]` section in `config.toml` to `[inference]` and reshaped it from a single `ollama_url` string into the providers schema described above. There is no backward-compatibility shim for the section name: if you had a custom `[model]` section, rename it to `[inference]` after upgrading; a flat `ollama_url` inside `[inference]` is migrated automatically. +- The `ask_ollama`, `search_pipeline`, and `capture_full_screen_command` Tauri commands now require a `conversationId: String` argument (and `ask_ollama` additionally requires `isFirstTurn: bool` and `slashCommand: Option`). The frontend's `useOllama` hook generates a stable trace id per session and threads it transparently. External callers that invoked these commands directly must update their `invoke()` calls. A new fire-and-forget `record_conversation_end` command lets the frontend signal end-of-conversation (used by `useOllama.reset()` and `useOllama.loadMessages()`) so the chat-domain trace file gets a clean closing line. +- **BREAKING**: Renamed the `[model]` section in `config.toml` to `[inference]`. The section still contains a single field, `ollama_url`, but the name now reflects what it actually configures (the inference daemon endpoint, not a model). There is no backward-compatibility shim: if you had a custom `[model]` section, rename it to `[inference]` after upgrading. - Active model selection is now strictly Option-typed end to end. Ollama's `/api/tags` is the single source of truth: when nothing is installed and nothing is persisted, Thuki refuses to dispatch requests and surfaces a "Pick a model" prompt instead of falling back to a hardcoded slug. The previous `DEFAULT_MODEL_NAME` constant has been removed. ## [0.14.1](https://github.com/quiet-node/thuki/compare/v0.14.0...v0.14.1) (2026-06-07) diff --git a/CLAUDE.md b/CLAUDE.md index 113158ee..2453fbad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ Thuki is a macOS-only desktop app, a floating AI secretary activated by double-t The UI morphs between two states: a compact spotlight-style input bar → an expanded chat window. This morphing is driven by Framer Motion and a single `isChatMode` boolean in `App.tsx`. - **`App.tsx`** — orchestrates all state: messages, streaming, window resizing via ResizeObserver + Tauri `setSize()` -- **`hooks/useModel.ts`** — Tauri Channel-based streaming hook (`useModel`); emits `Token`, `Done`, `Cancelled`, `Error` variants +- **`hooks/useOllama.ts`** — Tauri Channel-based streaming hook; emits `Token`, `Done`, `Cancelled`, `Error` variants - **`view/ConversationView.tsx`** — smart auto-scroll (pins to bottom unless user scrolls up) - **`view/AskBarView.tsx`** — auto-expanding textarea (max 144px), morphs logo size, renders slash command tab-completion suggestions - **`components/ChatBubble.tsx`** — markdown rendering via Streamdown (rehype-sanitize for XSS protection) @@ -67,8 +67,8 @@ User-facing reference for all commands lives in `docs/commands.md`. **Any new sl ### Backend (`src-tauri/src/`) - **`lib.rs`** — app setup: loads `AppConfig` via `config::load`, converts window to NSPanel (fullscreen overlay), registers tray, spawns hotkey listener, intercepts close events (hides instead of quits) -- **`config/`** — typed TOML-backed application configuration. Loaded once at startup from `~/Library/Application Support/com.quietnode.thuki/config.toml` (seeded with defaults on first run), installed as Tauri managed state, exposed to the frontend via the `get_config` command. Every subsystem that needs model, prompt, window, activation, or quote values reads from `State`. The `[inference]` section holds the typed providers list (`active_provider` + `[[inference.providers]]`, each `{id, kind, label, base_url, model}`); the loader migrates a legacy flat `ollama_url` onto a synthesized Ollama provider and `config/migrate.rs` folds the legacy SQLite `active_model` onto it at startup. See `docs/configurations.md` for the user-facing schema. -- **`commands.rs`** — `ask_model` Tauri command: routes by the active provider's kind (Phase 1 implements Ollama's native `/api/chat` only; a non-Ollama active provider returns a typed `EngineError`), streams newline-delimited JSON, and sends chunks via Tauri Channel. Reads the active provider (base URL + selected model) from `State>`, the resolved system prompt, and the in-memory `ActiveModelState`. +- **`config/`** — typed TOML-backed application configuration. Loaded once at startup from `~/Library/Application Support/com.quietnode.thuki/config.toml` (seeded with defaults on first run), installed as Tauri managed state, exposed to the frontend via the `get_config` command. Every subsystem that needs model, prompt, window, activation, or quote values reads from `State`. See `docs/configurations.md` for the user-facing schema. +- **`commands.rs`** — `ask_ollama` Tauri command: streams newline-delimited JSON from Ollama, sends chunks via Tauri Channel. Reads the active model, resolved system prompt, and Ollama URL from `State`. - **`screenshot.rs`** — `capture_full_screen_command` Tauri command: uses CoreGraphics FFI (`CGWindowListCreateImage`) to capture all displays excluding Thuki's own windows, writes a JPEG to a temp dir, and returns the path - **`activator.rs`** — Core Graphics event tap watching for double-tap Control key (400 ms window, 600 ms cooldown; timing is a compiled constant, not yet exposed through `AppConfig` because the event-tap callback runs in a thread that cannot trivially read Tauri managed state). The tap MUST use `CGEventTapLocation::HID` and `CGEventTapOptions::Default` — see the critical constraint note in "Key Design Constraints" below. diff --git a/docs/configurations.md b/docs/configurations.md index 16523c67..cf69ae23 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -27,36 +27,19 @@ open ~/Library/Application\ Support/com.quietnode.thuki/config.toml ```toml [inference] -# The provider Thuki sends inference to. Phase 1 ships the Ollama provider; -# the Built-in (Thuki) engine arrives in a later version. -active_provider = "ollama" -# Context window size in tokens sent to the active provider with every request. -# Warmup and chat share this value so Ollama reuses the same runner and its -# cached KV prefix for the system prompt. Raise to fit longer conversations; -# lower to reduce GPU memory use. Valid range: 2048-1048576. -num_ctx = 16384 +# Where Thuki finds your local Ollama server. The active model itself is +# selected from the in-app picker (which lists whatever is installed in +# Ollama via /api/tags) and is stored in Thuki's local database, not here. +ollama_url = "http://127.0.0.1:11434" # Minutes of inactivity before Thuki tells Ollama to release the model. # 0 = let Ollama manage (its own 5-minute default applies). -# -1 = never release. Applies to the Ollama provider only. +# -1 = never release (keep loaded until Ollama itself exits or you unload manually). keep_warm_inactivity_minutes = 0 - -# One block per provider. The built-in entry is always present. A provider's -# selected model lives on its own `model` field (empty until you pick one in -# the model picker). -[[inference.providers]] -id = "builtin" -kind = "builtin" -label = "Built-in (Thuki)" -model = "" - -[[inference.providers]] -id = "ollama" -kind = "ollama" -label = "Ollama" -# Where Thuki reaches your Ollama server. Defaults to this Mac; point it at -# another machine to use Ollama running elsewhere (one server at a time). -base_url = "http://127.0.0.1:11434" -model = "" +# Context window size in tokens sent to Ollama with every request. +# Warmup and chat share this value so Ollama reuses the same runner and its +# cached KV prefix for the system prompt. Raise to fit longer conversations; +# lower to reduce GPU memory use. Valid range: 2048–1048576. +num_ctx = 16384 [prompt] # The full secretary persona prompt. Seeded on first run so this file is the @@ -132,27 +115,15 @@ Every domain below is shown as a single table that lists **all** constants Thuki ### `[inference]` -Thuki reaches a model through a **provider**. `active_provider` names which one is used; each provider is described by a `[[inference.providers]]` block. Phase 1 ships two providers: **Ollama** (reached over HTTP at a configurable URL, local or remote) and a **Built-in (Thuki)** entry reserved for an upcoming bundled engine. A fresh install defaults to the Ollama provider. - -Each provider keeps its own selected `model`. Thuki discovers installed models live from Ollama's `/api/tags` endpoint and lets you pick one from the in-app model picker (or the Providers section of Settings); the choice is written to that provider's `model` field. When no model is installed and none has been chosen, Thuki refuses to dispatch a chat request and surfaces a "Pick a model" prompt. Pull a model with `ollama pull ` and select it. - -Upgrading from an older version is automatic: a pre-providers config with a flat `ollama_url` is migrated to an Ollama provider seeded with that URL, and the previously selected model (kept in SQLite) is moved onto it, so existing Ollama users are unaffected. - -| Constant | Default | Tunable? | Bounds | Description | -| :---------------- | :--------- | :------- | :------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `active_provider` | `"ollama"` | Yes | id of a provider | Which provider receives inference. Must match the `id` of one of the `[[inference.providers]]` entries; an empty or dangling value resets to `ollama`. Phase 1: leave this on `ollama` (the Built-in engine is not available yet). | -| `num_ctx` | `16384` | Yes | `[2048, 1048576]` | Context window size in tokens sent to the active provider with every request. Warmup and chat share this value so Ollama reuses the same runner instance and its cached KV prefix for the system prompt: they must match or Ollama creates a second runner and the warmup saves nothing. Ollama silently clamps this to the model's physical maximum. Raise to fit longer conversations: each doubling roughly doubles VRAM for the KV cache; lower to reclaim GPU memory. See [Tuning the Context Window](./tuning-context-window.md). | -| `keep_warm_inactivity_minutes` | `0` | Yes | `-1` or `[0, 1440]` | Minutes of inactivity before Thuki tells Ollama to release the model from VRAM. Applies to the Ollama provider only. `0` means do not manage: Ollama's own 5-minute default applies. `-1` means never release. Raise for longer sessions between uses; lower to reclaim VRAM sooner. | +Where to find your local Ollama server. The active model itself is **not** a TOML setting: Thuki discovers installed models live from Ollama's `/api/tags` endpoint, lets you pick one from the in-app model picker, and stores that selection in its local SQLite database (`app_config` table). Storing the active slug in TOML would duplicate ground truth from Ollama and break the moment you remove a model with `ollama rm`, so it lives next to the conversation history instead. -Each `[[inference.providers]]` block has these fields: +When no model is installed and no choice has been persisted, Thuki refuses to dispatch a chat request and surfaces a "Pick a model" prompt in the input area. Pull a model with `ollama pull ` and select it from the picker chip in the top-right of the overlay. -| Field | Description | -| :--------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | Stable identifier referenced by `active_provider`. The `builtin` and `ollama` ids are seeded automatically. | -| `kind` | `builtin` or `ollama`. Any other kind is dropped on load. Determines how Thuki talks to the provider (the Ollama kind uses Ollama's native API). | -| `label` | Human-readable name shown in Settings. | -| `base_url` | For the Ollama kind: where Thuki reaches the server (defaults to `http://127.0.0.1:11434`; point it at another machine to use remote Ollama). Empty for the built-in kind. A provider of kind `ollama` with an empty `base_url` is dropped and re-seeded at the localhost default. | -| `model` | The model selected for this provider, written when you pick one. Empty means "none chosen yet". | +| Constant | Default | Tunable? | Why not tunable | Bounds | Description | +| :----------- | :------------------------- | :------- | :-------------- | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ollama_url` | `"http://127.0.0.1:11434"` | Yes | — | non-empty URL | The web address where Thuki finds your local Ollama server. The default works if you run Ollama on this machine with its standard port. Change this only if you moved Ollama to a different port or another machine. | +| `keep_warm_inactivity_minutes` | `0` | Yes | — | `-1` or `[0, 1440]` | Minutes of inactivity before Thuki tells Ollama to release the model from VRAM. `0` means do not manage: Ollama's own 5-minute default applies. `-1` means never release (stays until Ollama exits or you unload manually). Raise for longer sessions between uses; lower to reclaim VRAM sooner. | +| `num_ctx` | `16384` | Yes | — | `[2048, 1048576]` | Context window size in tokens sent to Ollama with every request. Warmup and chat share this value so Ollama reuses the same runner instance and its cached KV prefix for the system prompt: they must match or Ollama creates a second runner and the warmup saves nothing. Ollama silently clamps this to the model's physical maximum, so values above the model's capacity are accepted but have no extra effect. Raise to fit longer conversations without the model forgetting early messages: each doubling roughly doubles VRAM for the KV cache; lower to reclaim GPU memory at the cost of a shorter effective history. 16384 is the default because it comfortably holds the full system prompt (~4000 tokens) plus many turns while staying within 8 GB GPU budgets. See [Tuning the Context Window](./tuning-context-window.md) for a 5-minute benchmark recipe to find the right value for your hardware. | If the active model has been removed from Ollama between launches, Thuki silently falls back to the first installed model the next time you open the picker. If no models are installed at all, the next request surfaces a "Model not found" error with the exact `ollama pull ` command to run. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4c162141..c0af4753 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -90,9 +90,9 @@ pub fn apply_capability_filter(messages: &mut [ChatMessage], caps: &Capabilities /// Used by the frontend to pick accent bar color and display copy. #[derive(Clone, Serialize, PartialEq, Debug)] #[serde(rename_all = "PascalCase")] -pub enum EngineErrorKind { +pub enum OllamaErrorKind { /// Ollama process is not running (connection refused / timeout). - EngineUnreachable, + NotRunning, /// The requested model has not been pulled yet (HTTP 404). ModelNotFound, /// No active model has been selected. The user must pick a model from @@ -105,42 +105,21 @@ pub enum EngineErrorKind { } /// Builds the structured error returned when `ActiveModelState` holds `None` -/// at the time `ask_model` is invoked. Pulled out as a free function so the +/// at the time `ask_ollama` is invoked. Pulled out as a free function so the /// exact title + body wording lives in one place and the branch is testable /// without a full Tauri runtime. -pub fn no_model_selected_error() -> EngineError { - EngineError { - kind: EngineErrorKind::NoModelSelected, +pub fn no_model_selected_error() -> OllamaError { + OllamaError { + kind: OllamaErrorKind::NoModelSelected, message: "No model selected\nPick a model in the picker.".to_string(), } } -/// Returns the error to emit when the active provider's kind has no Phase-1 -/// implementation, or `None` when the kind is the native Ollama path. Pure so -/// the routing decision is unit-tested even though `ask_model` is coverage-off. -/// In Phase 1 the only functional kind is `ollama`; the built-in engine and -/// the generic OpenAI-compatible kind arrive in Phase 2. -pub(crate) fn unsupported_provider_error(kind: &str, label: &str) -> Option { - use crate::config::defaults::PROVIDER_KIND_OLLAMA; - if kind == PROVIDER_KIND_OLLAMA { - return None; - } - let who = if label.trim().is_empty() { - "This provider" - } else { - label - }; - Some(EngineError { - kind: EngineErrorKind::EngineUnreachable, - message: format!("{who} is not available in this version of Thuki yet."), - }) -} - /// Structured error emitted over the streaming channel. /// Rust owns all user-facing copy; the frontend only uses `kind` for styling. #[derive(Clone, Serialize, Debug)] -pub struct EngineError { - pub kind: EngineErrorKind, +pub struct OllamaError { + pub kind: OllamaErrorKind, /// Final user-facing string. First line is the title, remainder is the subtitle. pub message: String, } @@ -162,15 +141,15 @@ pub fn extract_ollama_error_message(body: &str) -> Option { } /// Maps an HTTP status code (plus the response body for non-404 paths) to a -/// user-friendly `EngineError`. The `model_name` is woven into the +/// user-friendly `OllamaError`. The `model_name` is woven into the /// `ModelNotFound` hint so the user sees the exact command to run; for every /// other status we surface the concrete reason Ollama returned (e.g. "this /// model only supports one image while more than one image requested") so /// the user can act on it instead of staring at a bare HTTP code. -pub fn classify_http_error(status: u16, model_name: &str, body: &str) -> EngineError { +pub fn classify_http_error(status: u16, model_name: &str, body: &str) -> OllamaError { match status { - 404 => EngineError { - kind: EngineErrorKind::ModelNotFound, + 404 => OllamaError { + kind: OllamaErrorKind::ModelNotFound, message: format!("Model not found\nRun: ollama pull {model_name} in a terminal."), }, _ => { @@ -193,24 +172,24 @@ pub fn classify_http_error(status: u16, model_name: &str, body: &str) -> EngineE } else { format!("Something went wrong\n{detail}") }; - EngineError { - kind: EngineErrorKind::Other, + OllamaError { + kind: OllamaErrorKind::Other, message, } } } } -/// Maps a reqwest connection/transport error to a user-friendly `EngineError`. -pub fn classify_stream_error(e: &reqwest::Error) -> EngineError { +/// Maps a reqwest connection/transport error to a user-friendly `OllamaError`. +pub fn classify_stream_error(e: &reqwest::Error) -> OllamaError { if e.is_connect() || e.is_timeout() { - EngineError { - kind: EngineErrorKind::EngineUnreachable, + OllamaError { + kind: OllamaErrorKind::NotRunning, message: "Ollama isn't running\nStart Ollama and try again.".to_string(), } } else { - EngineError { - kind: EngineErrorKind::Other, + OllamaError { + kind: OllamaErrorKind::Other, message: "Something went wrong\nCould not reach Ollama.".to_string(), } } @@ -229,7 +208,7 @@ pub enum StreamChunk { /// The user explicitly cancelled generation. Cancelled, /// A structured, user-friendly error occurred during processing. - Error(EngineError), + Error(OllamaError), /// Emitted exactly once per turn, after the backend has cleared every /// pre-`ConversationStart` gate (no-model bail, model lookup, etc.) and /// committed to opening the trace for this `conversation_id`. Carries @@ -492,7 +471,7 @@ pub async fn stream_ollama_chat( } /// Mirrors a streaming chunk into the chat-domain trace recorder. Pulled out -/// of [`ask_model`] so the per-token routing logic and the token-count +/// of [`ask_ollama`] so the per-token routing logic and the token-count /// increment are exercised by the unit-test suite rather than the /// coverage-off Tauri command body. `Done`, `Cancelled`, and `Error` chunks /// are intentionally noops here: those terminal events are summarized by @@ -522,7 +501,7 @@ pub(crate) fn record_chunk_to_trace( } /// Emits `ConversationStart` to the trace recorder iff this is the first -/// turn of the conversation. Pulled out of [`ask_model`] and the search +/// turn of the conversation. Pulled out of [`ask_ollama`] and the search /// pipeline so the gate is covered by tests instead of the coverage-off /// Tauri command body. pub(crate) fn record_conversation_start_if_first_turn( @@ -553,7 +532,7 @@ pub(crate) fn record_conversation_start_if_first_turn( #[cfg_attr(coverage_nightly, coverage(off))] #[cfg_attr(not(coverage), tauri::command)] #[allow(clippy::too_many_arguments)] -pub async fn ask_model( +pub async fn ask_ollama( message: String, quoted_text: Option, image_paths: Option>, @@ -573,29 +552,9 @@ pub async fn ask_model( // Snapshot the config once so all downstream reads (endpoint, prompt, model) // see a consistent view even if the user edits Settings mid-stream. let config = config.read().clone(); - - // Route by provider kind. Phase 1 implements only the native Ollama path; - // a non-Ollama active provider (the built-in engine) cannot serve yet, so - // bail with a typed, provider-labeled error regardless of model selection. - { - let kind = config.inference.active_provider_kind(); - let label = config - .inference - .active() - .map(|p| p.label.as_str()) - .unwrap_or(""); - if let Some(err) = unsupported_provider_error(kind, label) { - let _ = on_event.send(StreamChunk::Error(err)); - return Ok(()); - } - } - let endpoint = format!( "{}/api/chat", - config - .inference - .active_provider_base_url() - .trim_end_matches('/') + config.inference.ollama_url.trim_end_matches('/') ); // Snapshot the active model slug; drop the guard before any `.await`. let model_name = { @@ -698,12 +657,11 @@ pub async fn ask_model( // stored history (`conv`) is never mutated. On a cache miss we leave // the payload untouched and trust Ollama to surface a structured error // through `classify_http_error`'s picker hint, which the user can act on. - let provider_id = config.inference.active_provider.clone(); let cache_hit = capabilities_cache .0 .lock() .ok() - .and_then(|guard| guard.get(&(provider_id, model_name.clone())).cloned()); + .and_then(|guard| guard.get(&model_name).cloned()); if let Some(caps) = cache_hit { let stats = apply_capability_filter(&mut messages, &caps); if stats.stripped_images > 0 { @@ -818,7 +776,7 @@ pub async fn cancel_generation(generation: State<'_, GenerationState>) -> Result } /// Clears the backend conversation history and increments the epoch counter. -/// The epoch increment prevents any in-flight `ask_model` from writing stale +/// The epoch increment prevents any in-flight `ask_ollama` from writing stale /// messages into the freshly cleared history. #[cfg_attr(coverage_nightly, coverage(off))] #[cfg_attr(not(coverage), tauri::command)] @@ -962,8 +920,8 @@ mod tests { assert_eq!(chunks.len(), 1); assert_eq!( std::mem::discriminant(&chunks[0]), - std::mem::discriminant(&StreamChunk::Error(EngineError { - kind: EngineErrorKind::Other, + std::mem::discriminant(&StreamChunk::Error(OllamaError { + kind: OllamaErrorKind::Other, message: String::new(), })) ); @@ -995,8 +953,8 @@ mod tests { assert_eq!(chunks.len(), 1); assert_eq!( std::mem::discriminant(&chunks[0]), - std::mem::discriminant(&StreamChunk::Error(EngineError { - kind: EngineErrorKind::Other, + std::mem::discriminant(&StreamChunk::Error(OllamaError { + kind: OllamaErrorKind::Other, message: String::new(), })) ); @@ -1205,7 +1163,7 @@ mod tests { _ => None, }); assert!(error.is_some()); - assert_eq!(error.unwrap().kind, EngineErrorKind::Other); + assert_eq!(error.unwrap().kind, OllamaErrorKind::Other); assert!(chunks .iter() .all(|chunk| !matches!(chunk, StreamChunk::Done))); @@ -1245,7 +1203,7 @@ mod tests { let chunks = chunks.lock().unwrap(); assert_eq!(chunks.len(), 1); assert!( - matches!(&chunks[0], StreamChunk::Error(e) if e.kind == EngineErrorKind::Other && e.message.contains("500")) + matches!(&chunks[0], StreamChunk::Error(e) if e.kind == OllamaErrorKind::Other && e.message.contains("500")) ); } @@ -1668,26 +1626,26 @@ mod tests { assert!(h.messages.lock().unwrap().is_empty()); } - // ─── EngineError classification ─────────────────────────────────────────── + // ─── OllamaError classification ─────────────────────────────────────────── #[test] fn classify_http_404_returns_model_not_found() { let err = classify_http_error(404, "gemma4:e2b", ""); - assert_eq!(err.kind, EngineErrorKind::ModelNotFound); + assert_eq!(err.kind, OllamaErrorKind::ModelNotFound); assert!(err.message.contains("gemma4:e2b")); } #[test] fn classify_http_404_includes_requested_model_name_in_hint() { let err = classify_http_error(404, "custom:model", ""); - assert_eq!(err.kind, EngineErrorKind::ModelNotFound); + assert_eq!(err.kind, OllamaErrorKind::ModelNotFound); assert!(err.message.contains("custom:model")); } #[test] fn classify_http_500_with_empty_body_falls_back_to_status_code() { let err = classify_http_error(500, "gemma4:e2b", ""); - assert_eq!(err.kind, EngineErrorKind::Other); + assert_eq!(err.kind, OllamaErrorKind::Other); assert!(err.message.contains("500")); } @@ -1696,7 +1654,7 @@ mod tests { let body = r#"{"error":"this model only supports one image while more than one image requested"}"#; let err = classify_http_error(500, "llama3.2-vision:11b", body); - assert_eq!(err.kind, EngineErrorKind::Other); + assert_eq!(err.kind, OllamaErrorKind::Other); assert!(err .message .contains("only supports one image while more than one image requested")); @@ -1706,21 +1664,21 @@ mod tests { #[test] fn classify_http_500_falls_back_to_status_when_body_is_not_json() { let err = classify_http_error(500, "any", "oops"); - assert_eq!(err.kind, EngineErrorKind::Other); + assert_eq!(err.kind, OllamaErrorKind::Other); assert!(err.message.contains("500")); } #[test] fn classify_http_500_falls_back_to_status_when_error_field_is_missing() { let err = classify_http_error(500, "any", r#"{"detail":"nope"}"#); - assert_eq!(err.kind, EngineErrorKind::Other); + assert_eq!(err.kind, OllamaErrorKind::Other); assert!(err.message.contains("500")); } #[test] fn classify_http_500_falls_back_to_status_when_error_field_is_blank() { let err = classify_http_error(500, "any", r#"{"error":" "}"#); - assert_eq!(err.kind, EngineErrorKind::Other); + assert_eq!(err.kind, OllamaErrorKind::Other); assert!(err.message.contains("500")); } @@ -1744,7 +1702,7 @@ mod tests { #[test] fn classify_http_401_returns_other_with_status() { let err = classify_http_error(401, "gemma4:e2b", ""); - assert_eq!(err.kind, EngineErrorKind::Other); + assert_eq!(err.kind, OllamaErrorKind::Other); assert!(err.message.contains("401")); } @@ -1755,7 +1713,7 @@ mod tests { // them down so accidental wording drift does not silently break // the recovery path. let err = no_model_selected_error(); - assert_eq!(err.kind, EngineErrorKind::NoModelSelected); + assert_eq!(err.kind, OllamaErrorKind::NoModelSelected); assert!( err.message.contains("Pick a model"), "message should steer the user to the picker, got: {}", @@ -1764,41 +1722,12 @@ mod tests { } #[test] - fn unsupported_provider_error_passes_ollama_and_flags_other_kinds() { - use crate::config::defaults::{PROVIDER_KIND_BUILTIN, PROVIDER_KIND_OLLAMA}; - // The native Ollama path proceeds (no error). - assert!(unsupported_provider_error(PROVIDER_KIND_OLLAMA, "Ollama").is_none()); - // The built-in kind has no Phase-1 implementation: typed error, labeled. - let err = unsupported_provider_error(PROVIDER_KIND_BUILTIN, "Built-in (Thuki)").unwrap(); - assert_eq!(err.kind, EngineErrorKind::EngineUnreachable); - assert!(err.message.contains("Built-in (Thuki)")); - // An empty label falls back to a generic noun. - let unlabeled = unsupported_provider_error(PROVIDER_KIND_BUILTIN, "").unwrap(); - assert!(unlabeled.message.contains("This provider")); - // Any non-Ollama kind is flagged, not just the built-in: the gate keys - // on `kind != ollama`, so a future provider kind also bails cleanly - // rather than falling through to the unreachable Ollama HTTP path. - let other = unsupported_provider_error("openai", "Cloud").unwrap(); - assert_eq!(other.kind, EngineErrorKind::EngineUnreachable); - assert!(other.message.contains("Cloud")); - } - - #[test] - fn engine_error_kinds_serialize_as_pascal_case() { - // Wire format contract: every kind must serialize verbatim in - // PascalCase so the React side (ErrorCard.barColors, useModel) can match - // on stable literal strings. Drift here silently breaks accent styling - // and error routing without failing any other test. - let cases = [ - (EngineErrorKind::EngineUnreachable, "EngineUnreachable"), - (EngineErrorKind::ModelNotFound, "ModelNotFound"), - (EngineErrorKind::NoModelSelected, "NoModelSelected"), - (EngineErrorKind::Other, "Other"), - ]; - for (kind, expected) in cases { - let v = serde_json::to_value(kind).unwrap(); - assert_eq!(v, serde_json::Value::String(expected.to_string())); - } + fn ollama_error_kind_no_model_selected_serializes_as_pascal_case() { + // Wire format check: NoModelSelected must serialize verbatim in + // PascalCase so the React side can match on a stable string in the + // OllamaError discriminator. + let v = serde_json::to_value(OllamaErrorKind::NoModelSelected).unwrap(); + assert_eq!(v, serde_json::Value::String("NoModelSelected".to_string())); } #[tokio::test] @@ -1825,7 +1754,7 @@ mod tests { let chunks = chunks.lock().unwrap(); assert_eq!(chunks.len(), 1); assert!( - matches!(&chunks[0], StreamChunk::Error(e) if e.kind == EngineErrorKind::EngineUnreachable) + matches!(&chunks[0], StreamChunk::Error(e) if e.kind == OllamaErrorKind::NotRunning) ); } @@ -1862,7 +1791,7 @@ mod tests { let chunks = chunks.lock().unwrap(); assert_eq!(chunks.len(), 1); assert!( - matches!(&chunks[0], StreamChunk::Error(e) if e.kind == EngineErrorKind::ModelNotFound) + matches!(&chunks[0], StreamChunk::Error(e) if e.kind == OllamaErrorKind::ModelNotFound) ); } @@ -1961,7 +1890,7 @@ mod tests { let chunks = chunks.lock().unwrap(); assert_eq!(chunks.len(), 1); assert!( - matches!(&chunks[0], StreamChunk::Error(e) if e.kind == EngineErrorKind::Other && e.message.contains("500")) + matches!(&chunks[0], StreamChunk::Error(e) if e.kind == OllamaErrorKind::Other && e.message.contains("500")) ); } @@ -2003,7 +1932,7 @@ mod tests { assert!(matches!( &chunks[0], StreamChunk::Error(e) - if e.kind == EngineErrorKind::Other + if e.kind == OllamaErrorKind::Other && e.message.contains("only supports one image") && !e.message.contains("HTTP 500") )); @@ -2314,7 +2243,7 @@ mod tests { fn classify_http_500_appends_picker_hint_when_body_mentions_image() { let body = r#"{"error":"this model only supports one image"}"#; let err = classify_http_error(500, "any", body); - assert_eq!(err.kind, EngineErrorKind::Other); + assert_eq!(err.kind, OllamaErrorKind::Other); assert!(err.message.contains("only supports one image")); assert!(err.message.contains("picker chip")); } @@ -2323,7 +2252,7 @@ mod tests { fn classify_http_500_appends_picker_hint_when_body_mentions_vision() { let body = r#"{"error":"vision capability required"}"#; let err = classify_http_error(500, "any", body); - assert_eq!(err.kind, EngineErrorKind::Other); + assert_eq!(err.kind, OllamaErrorKind::Other); assert!(err.message.contains("vision capability required")); assert!(err.message.contains("picker chip")); } @@ -2345,7 +2274,7 @@ mod tests { #[test] fn classify_http_404_does_not_append_picker_hint() { let err = classify_http_error(404, "vision-model", "image required"); - assert_eq!(err.kind, EngineErrorKind::ModelNotFound); + assert_eq!(err.kind, OllamaErrorKind::ModelNotFound); assert!(!err.message.contains("picker chip")); } diff --git a/src-tauri/src/config/defaults.rs b/src-tauri/src/config/defaults.rs index 6b9d5126..898483c6 100644 --- a/src-tauri/src/config/defaults.rs +++ b/src-tauri/src/config/defaults.rs @@ -5,30 +5,9 @@ //! Changing a default here propagates to a fresh first-run config file and to //! any field a user has left unset or left empty in their existing file. -/// Default Ollama HTTP endpoint (loopback, standard port). Seed value for the -/// Ollama provider's `base_url` on a fresh install or after a migration. +/// Default Ollama HTTP endpoint (loopback, standard port). pub const DEFAULT_OLLAMA_URL: &str = "http://127.0.0.1:11434"; -/// Stable provider ids. `active_provider` references one of these. -pub const PROVIDER_ID_BUILTIN: &str = "builtin"; -pub const PROVIDER_ID_OLLAMA: &str = "ollama"; - -/// Provider kinds understood by the loader. Providers with any other kind are -/// dropped during resolution. -pub const PROVIDER_KIND_BUILTIN: &str = "builtin"; -pub const PROVIDER_KIND_OLLAMA: &str = "ollama"; - -/// Human-readable provider labels shown in Settings. -pub const DEFAULT_BUILTIN_LABEL: &str = "Built-in (Thuki)"; -pub const DEFAULT_OLLAMA_LABEL: &str = "Ollama"; - -/// Provider Thuki sends inference to on a fresh install. -/// -/// Phase 1 ships no built-in engine, so a new install defaults to the Ollama -/// provider (the only functional kind in this phase). Phase 2 flips this to -/// `PROVIDER_ID_BUILTIN` when the bundled engine lands. -pub const DEFAULT_ACTIVE_PROVIDER: &str = PROVIDER_ID_OLLAMA; - /// Default inactivity window before Thuki tells Ollama to release the model. /// 0 means do not manage: Ollama's own 5-minute default applies. /// -1 means keep indefinitely. Positive values are minutes (1..=1440). @@ -319,8 +298,8 @@ pub const MAX_MODEL_SLUG_LEN: usize = 256; /// /// Order matches `AppConfig` field ordering for review-friendliness. pub const ALLOWED_FIELDS: &[(&str, &str)] = &[ - // [inference] — active_provider and the providers array are not flat fields; - // they are written via set_active_model / set_ollama_url, not set_config_field. + // [inference] + ("inference", "ollama_url"), ("inference", "keep_warm_inactivity_minutes"), ("inference", "num_ctx"), // [prompt] diff --git a/src-tauri/src/config/loader.rs b/src-tauri/src/config/loader.rs index e1212a9e..f13ab13a 100644 --- a/src-tauri/src/config/loader.rs +++ b/src-tauri/src/config/loader.rs @@ -135,8 +135,25 @@ fn rename_corrupt(path: &Path) { /// and composes the system prompt appendix into `prompt.resolved_system`. /// After this runs, every `AppConfig` field holds a usable value. pub(crate) fn resolve(config: &mut AppConfig) { - // Inference section: providers list, active pointer, migration, clamps. - resolve_inference(&mut config.inference); + // Inference section: only the Ollama endpoint is configurable here. The + // active model is runtime UI state owned by SQLite app_config, see + // crate::models::ActiveModelState. + if config.inference.ollama_url.trim().is_empty() { + config.inference.ollama_url = DEFAULT_OLLAMA_URL.to_string(); + } + // keep_warm_inactivity_minutes: -1 = never release, 0 = disabled (Ollama + // default), 1..=1440 = explicit timeout. Below -1 or above 1440: reset to default. + clamp_keep_warm_inactivity( + &mut config.inference.keep_warm_inactivity_minutes, + DEFAULT_KEEP_WARM_INACTIVITY_MINUTES, + "inference.keep_warm_inactivity_minutes", + ); + clamp_u32( + &mut config.inference.num_ctx, + BOUNDS_NUM_CTX, + DEFAULT_NUM_CTX, + "inference.num_ctx", + ); // Prompt section: if the user has never explicitly saved a system prompt // (system_customized is false) and the on-disk value is empty, restore @@ -306,112 +323,8 @@ pub(crate) fn resolve(config: &mut AppConfig) { } } -/// Resolves the inference section: migrates a pre-providers `ollama_url`, -/// clamps numerics, drops invalid providers, re-seeds the mandatory built-in -/// and Ollama entries, and repairs an empty/dangling `active_provider`. Never -/// panics on user input. -fn resolve_inference(inf: &mut crate::config::schema::InferenceSection) { - use crate::config::defaults::{ - DEFAULT_ACTIVE_PROVIDER, PROVIDER_KIND_BUILTIN, PROVIDER_KIND_OLLAMA, - }; - use crate::config::schema::{builtin_provider, ollama_provider}; - - // num_ctx + keep_warm: unchanged clamping (Ollama-path knobs). - clamp_u32( - &mut inf.num_ctx, - BOUNDS_NUM_CTX, - DEFAULT_NUM_CTX, - "inference.num_ctx", - ); - clamp_keep_warm_inactivity( - &mut inf.keep_warm_inactivity_minutes, - DEFAULT_KEEP_WARM_INACTIVITY_MINUTES, - "inference.keep_warm_inactivity_minutes", - ); - - // Migration: a pre-providers file has `ollama_url` and no `providers`. - // Carry the URL onto a synthesized Ollama provider; the active model is - // attached later during startup orchestration (it lives in SQLite). The - // active pointer is left to the dangling-pointer repair below: a migrated - // config either omits `active_provider` (serde defaults it to the Phase-1 - // default of `ollama`) or names something the repair resets to that same - // default, so existing Ollama users land on the Ollama provider either way. - if let Some(legacy) = inf.legacy_ollama_url.take() { - if inf.providers.is_empty() { - let url = if legacy.trim().is_empty() { - DEFAULT_OLLAMA_URL.to_string() - } else { - legacy - }; - inf.providers = vec![builtin_provider(), ollama_provider(&url)]; - } - } - - // Defense-in-depth: an Ollama provider's `base_url` is concatenated into - // the request endpoint and POSTed by the backend. Reject anything that is - // not an absolute http(s) URL (file://, a scheme-less host, a typo) by - // resetting it to the localhost default, mirroring how every other invalid - // field is healed. The frontend only *warns* about remote hosts; this is - // the backend's own guard against malformed or abusable schemes. - for p in inf.providers.iter_mut() { - if p.kind == PROVIDER_KIND_OLLAMA - && !p.base_url.trim().is_empty() - && !is_http_url(&p.base_url) - { - eprintln!( - "thuki: [config] provider '{}' base_url is not an http(s) URL; using default '{DEFAULT_OLLAMA_URL}'", - p.id - ); - p.base_url = DEFAULT_OLLAMA_URL.to_string(); - } - } - - // Drop unknown-kind providers and non-builtin providers with no base_url. - inf.providers.retain(|p| match p.kind.as_str() { - PROVIDER_KIND_BUILTIN => true, - PROVIDER_KIND_OLLAMA => !p.base_url.trim().is_empty(), - other => { - eprintln!("thuki: [config] dropping provider with unknown kind '{other}'"); - false - } - }); - - // Built-in is mandatory: re-seed if a user file omitted it. - if !inf - .providers - .iter() - .any(|p| p.kind == PROVIDER_KIND_BUILTIN) - { - inf.providers.insert(0, builtin_provider()); - } - // Ensure a functional Phase-1 provider exists: re-seed Ollama if absent. - if !inf.providers.iter().any(|p| p.kind == PROVIDER_KIND_OLLAMA) { - inf.providers.push(ollama_provider(DEFAULT_OLLAMA_URL)); - } - - // Empty/dangling active pointer -> default. - if !inf.providers.iter().any(|p| p.id == inf.active_provider) { - if !inf.active_provider.trim().is_empty() { - eprintln!( - "thuki: [config] active_provider '{}' not found; using default '{DEFAULT_ACTIVE_PROVIDER}'", - inf.active_provider - ); - } - inf.active_provider = DEFAULT_ACTIVE_PROVIDER.to_string(); - } -} - -/// True when `url` is an absolute http(s) URL. Used to keep a malformed or -/// non-http provider `base_url` (which the backend POSTs to) out of the -/// resolved config; invalid values are reset to the localhost default. Mirrors -/// the scheme guard in `commands::open_url`. -fn is_http_url(url: &str) -> bool { - let url = url.trim(); - url.starts_with("http://") || url.starts_with("https://") -} - /// Composes the user-editable base prompt with the generated slash-command -/// appendix. The result is what `ask_model` actually sends to Ollama. The +/// appendix. The result is what `ask_ollama` actually sends to Ollama. The /// file stores only the base; the appendix is never round-tripped. pub fn compose_system_prompt(base: &str, appendix: &str) -> String { let base = base.trim_end(); diff --git a/src-tauri/src/config/migrate.rs b/src-tauri/src/config/migrate.rs deleted file mode 100644 index 7b86856f..00000000 --- a/src-tauri/src/config/migrate.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Config migration helpers shared by the loader (TOML-shape migration) and -//! startup orchestration (SQLite active-model fold-in). Kept pure so both -//! halves are unit-tested without a Tauri app or a real SQLite connection. - -use super::schema::AppConfig; - -/// Attaches a legacy SQLite `active_model` onto the active provider's `model` -/// field when that provider has no model yet. Returns true if it mutated the -/// config (so startup can decide whether to persist). No-op when `legacy` is -/// empty/whitespace or the active provider already has a model. -pub fn attach_legacy_active_model(config: &mut AppConfig, legacy: Option<&str>) -> bool { - let Some(model) = legacy.map(str::trim).filter(|m| !m.is_empty()) else { - return false; - }; - let active_id = config.inference.active_provider.clone(); - if let Some(provider) = config - .inference - .providers - .iter_mut() - .find(|p| p.id == active_id) - { - if provider.model.trim().is_empty() { - provider.model = model.to_string(); - return true; - } - } - false -} - -/// True if the given config TOML text already carries a non-empty -/// `[[inference.providers]]` array (i.e. it is the new shape). Used by startup -/// to decide whether to perform the one-time old → new shape upgrade write. -/// Unparseable input is treated as "not the new shape". -pub fn toml_has_providers(toml_text: &str) -> bool { - toml_text - .parse::() - .ok() - .and_then(|table| { - table - .get("inference")? - .as_table()? - .get("providers")? - .as_array() - .map(|a| !a.is_empty()) - }) - .unwrap_or(false) -} diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index 5b2af16c..13fd8a1f 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -20,15 +20,12 @@ pub mod defaults; pub mod error; pub mod loader; -pub mod migrate; pub mod schema; pub mod writer; pub use error::ConfigError; pub use loader::load_from_path; -pub use schema::{ - AppConfig, InferenceSection, PromptSection, Provider, QuoteSection, WindowSection, -}; +pub use schema::{AppConfig, InferenceSection, PromptSection, QuoteSection, WindowSection}; pub use writer::{atomic_write, atomic_write_bytes}; /// File name of the user config file inside the OS config dir. diff --git a/src-tauri/src/config/schema.rs b/src-tauri/src/config/schema.rs index 8702c9f2..96963acc 100644 --- a/src-tauri/src/config/schema.rs +++ b/src-tauri/src/config/schema.rs @@ -14,10 +14,9 @@ use serde::{Deserialize, Serialize}; use super::defaults::{ - DEFAULT_ACTIVE_PROVIDER, DEFAULT_AUTO_CLOSE, DEFAULT_AUTO_REPLACE, DEFAULT_BUILTIN_LABEL, - DEFAULT_DEBUG_TRACE_ENABLED, DEFAULT_JUDGE_TIMEOUT_S, DEFAULT_KEEP_WARM_INACTIVITY_MINUTES, - DEFAULT_MAX_CHAT_HEIGHT, DEFAULT_MAX_IMAGES, DEFAULT_MAX_ITERATIONS, DEFAULT_NUM_CTX, - DEFAULT_OLLAMA_LABEL, DEFAULT_OLLAMA_URL, DEFAULT_OVERLAY_WIDTH, + DEFAULT_AUTO_CLOSE, DEFAULT_AUTO_REPLACE, DEFAULT_DEBUG_TRACE_ENABLED, DEFAULT_JUDGE_TIMEOUT_S, + DEFAULT_KEEP_WARM_INACTIVITY_MINUTES, DEFAULT_MAX_CHAT_HEIGHT, DEFAULT_MAX_IMAGES, + DEFAULT_MAX_ITERATIONS, DEFAULT_NUM_CTX, DEFAULT_OLLAMA_URL, DEFAULT_OVERLAY_WIDTH, DEFAULT_PIPELINE_WALL_CLOCK_BUDGET_S, DEFAULT_QUOTE_MAX_CONTEXT_LENGTH, DEFAULT_QUOTE_MAX_DISPLAY_CHARS, DEFAULT_QUOTE_MAX_DISPLAY_LINES, DEFAULT_READER_BATCH_TIMEOUT_S, DEFAULT_READER_PER_URL_TIMEOUT_S, DEFAULT_READER_URL, @@ -25,132 +24,46 @@ use super::defaults::{ DEFAULT_SEARXNG_URL, DEFAULT_SYSTEM_CUSTOMIZED, DEFAULT_SYSTEM_PROMPT_BASE, DEFAULT_TEXT_BASE_PX, DEFAULT_TEXT_FONT_WEIGHT, DEFAULT_TEXT_LETTER_SPACING_PX, DEFAULT_TEXT_LINE_HEIGHT, DEFAULT_TOP_K_URLS, DEFAULT_UPDATER_AUTO_CHECK, - DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, DEFAULT_UPDATER_MANIFEST_URL, PROVIDER_ID_BUILTIN, - PROVIDER_ID_OLLAMA, PROVIDER_KIND_BUILTIN, PROVIDER_KIND_OLLAMA, + DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, DEFAULT_UPDATER_MANIFEST_URL, }; -/// A single configured inference provider. Exactly one is active at a time -/// (see [`InferenceSection::active_provider`]). The built-in entry is always -/// present and cannot be removed; the loader re-seeds it if a user file omits -/// it. Per-provider `model` replaces the former single SQLite `active_model`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -#[serde(default)] -pub struct Provider { - /// Stable identifier referenced by `active_provider`. - pub id: String, - /// Provider kind: `"builtin"` or `"ollama"`. Unknown kinds are dropped by - /// the loader. - pub kind: String, - /// Human-readable name shown in Settings. - pub label: String, - /// Base URL for network providers (Ollama). Empty for the built-in engine. - pub base_url: String, - /// The model selected for this provider. Empty means "none chosen yet". - pub model: String, -} - -/// The built-in provider record (Thuki's own engine; no URL). -pub fn builtin_provider() -> Provider { - Provider { - id: PROVIDER_ID_BUILTIN.to_string(), - kind: PROVIDER_KIND_BUILTIN.to_string(), - label: DEFAULT_BUILTIN_LABEL.to_string(), - base_url: String::new(), - model: String::new(), - } -} - -/// An Ollama provider record seeded with the given base URL. -pub fn ollama_provider(base_url: &str) -> Provider { - Provider { - id: PROVIDER_ID_OLLAMA.to_string(), - kind: PROVIDER_KIND_OLLAMA.to_string(), - label: DEFAULT_OLLAMA_LABEL.to_string(), - base_url: base_url.to_string(), - model: String::new(), - } -} - -/// The default provider list: built-in first, then Ollama at localhost. -pub fn default_providers() -> Vec { - vec![builtin_provider(), ollama_provider(DEFAULT_OLLAMA_URL)] -} - -/// Static, user-tunable inference configuration. +/// Static, user-tunable inference daemon configuration. /// -/// Inference targets one of several `providers`; `active_provider` selects -/// which. Per-provider model selection lives on each [`Provider`] record -/// (replacing the former single SQLite `active_model`). `num_ctx` and -/// `keep_warm_inactivity_minutes` remain universal Ollama-path knobs. +/// The active model selection is NOT stored here. Active-model state is +/// runtime UI state owned by [`crate::models::ActiveModelState`] and +/// persisted in the SQLite `app_config` table under +/// [`crate::models::ACTIVE_MODEL_KEY`]. Storing a model slug in TOML would +/// duplicate ground truth from Ollama's `/api/tags` and create a staleness +/// trap: the file would happily reference a model the user has since +/// removed. This section keeps only the truly static knob, the Ollama +/// endpoint URL. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct InferenceSection { - /// Id of the provider Thuki currently sends inference to. The loader - /// repairs an empty or dangling pointer to `DEFAULT_ACTIVE_PROVIDER`. - pub active_provider: String, - /// Context window size (in tokens) sent to the active provider with every - /// request. Warmup and chat use the same value so Ollama reuses the same - /// runner instance and its cached KV prefix for the system prompt. Raise to - /// fit longer conversations in a single context; lower to use less VRAM. - /// Valid range: 2048..=1048576. - pub num_ctx: u32, + /// HTTP base URL of the local Ollama instance. + pub ollama_url: String, /// Minutes of inactivity before Thuki tells Ollama to release the model. - /// Applies to the Ollama provider only. 0 means do not manage (Ollama's - /// 5-minute default applies). -1 means keep indefinitely. Valid range: -1 - /// or 0..=1440. + /// 0 means do not manage (Ollama's 5-minute default applies). + /// -1 means keep indefinitely. Valid range: -1 or 0..=1440. pub keep_warm_inactivity_minutes: i32, - /// The configured providers. Always contains the built-in entry after - /// resolution. The field-level `#[serde(default)]` defaults a *missing* - /// `providers` key to an empty Vec (not the seeded pair), so the loader can - /// distinguish a pre-providers file (empty -> migrate from `ollama_url`) - /// from a new-shape file with an explicit list. `resolve` always re-seeds - /// the mandatory built-in and Ollama entries. - #[serde(default)] - pub providers: Vec, - /// Migration-only: the pre-providers `[inference] ollama_url` value. Read - /// from old config files, consumed by `loader::resolve`, never written back. - #[serde(default, rename = "ollama_url", skip_serializing)] - pub legacy_ollama_url: Option, + /// Context window size (in tokens) sent to Ollama with every request. + /// Warmup and chat use the same value so Ollama reuses the same runner + /// instance and its cached KV prefix for the system prompt. Raise to fit + /// longer conversations in a single context; lower to use less VRAM. + /// Valid range: 2048..=1048576. + pub num_ctx: u32, } impl Default for InferenceSection { fn default() -> Self { Self { - active_provider: DEFAULT_ACTIVE_PROVIDER.to_string(), - num_ctx: DEFAULT_NUM_CTX, + ollama_url: DEFAULT_OLLAMA_URL.to_string(), keep_warm_inactivity_minutes: DEFAULT_KEEP_WARM_INACTIVITY_MINUTES, - providers: default_providers(), - legacy_ollama_url: None, + num_ctx: DEFAULT_NUM_CTX, } } } -impl InferenceSection { - /// The active provider record, if `active_provider` resolves to one. - pub fn active(&self) -> Option<&Provider> { - self.providers.iter().find(|p| p.id == self.active_provider) - } - /// Base URL of the active provider (empty for the built-in / unresolved). - pub fn active_provider_base_url(&self) -> &str { - self.active().map(|p| p.base_url.as_str()).unwrap_or("") - } - /// The active provider's selected model (empty if none). - pub fn active_provider_model(&self) -> &str { - self.active().map(|p| p.model.as_str()).unwrap_or("") - } - /// The active provider's selected model as an `Option`, mapping an empty - /// model field to `None` so callers can feed it straight into the - /// active-model resolve helpers. - pub fn active_provider_model_opt(&self) -> Option<&str> { - let model = self.active_provider_model(); - (!model.is_empty()).then_some(model) - } - /// The active provider's kind (empty if unresolved). - pub fn active_provider_kind(&self) -> &str { - self.active().map(|p| p.kind.as_str()).unwrap_or("") - } -} - /// Prompt configuration. `system` holds the user-editable persona prompt; on /// first run it is seeded with the full built-in body so the file is the /// single source of truth. The slash-command appendix is composed at load diff --git a/src-tauri/src/config/tests.rs b/src-tauri/src/config/tests.rs index 1e374479..34d92e26 100644 --- a/src-tauri/src/config/tests.rs +++ b/src-tauri/src/config/tests.rs @@ -13,24 +13,23 @@ use std::path::PathBuf; use super::defaults::{ - DEFAULT_ACTIVE_PROVIDER, DEFAULT_AUTO_CLOSE, DEFAULT_AUTO_REPLACE, DEFAULT_DEBUG_TRACE_ENABLED, - DEFAULT_JUDGE_TIMEOUT_S, DEFAULT_KEEP_WARM_INACTIVITY_MINUTES, DEFAULT_MAX_CHAT_HEIGHT, - DEFAULT_MAX_IMAGES, DEFAULT_MAX_ITERATIONS, DEFAULT_NUM_CTX, DEFAULT_OLLAMA_URL, - DEFAULT_OVERLAY_WIDTH, DEFAULT_QUOTE_MAX_CONTEXT_LENGTH, DEFAULT_QUOTE_MAX_DISPLAY_CHARS, + DEFAULT_AUTO_CLOSE, DEFAULT_AUTO_REPLACE, DEFAULT_DEBUG_TRACE_ENABLED, DEFAULT_JUDGE_TIMEOUT_S, + DEFAULT_KEEP_WARM_INACTIVITY_MINUTES, DEFAULT_MAX_CHAT_HEIGHT, DEFAULT_MAX_IMAGES, + DEFAULT_MAX_ITERATIONS, DEFAULT_NUM_CTX, DEFAULT_OLLAMA_URL, DEFAULT_OVERLAY_WIDTH, + DEFAULT_QUOTE_MAX_CONTEXT_LENGTH, DEFAULT_QUOTE_MAX_DISPLAY_CHARS, DEFAULT_QUOTE_MAX_DISPLAY_LINES, DEFAULT_READER_BATCH_TIMEOUT_S, DEFAULT_READER_PER_URL_TIMEOUT_S, DEFAULT_READER_URL, DEFAULT_ROUTER_TIMEOUT_S, DEFAULT_SEARCH_TIMEOUT_S, DEFAULT_SEARXNG_MAX_RESULTS, DEFAULT_SEARXNG_URL, DEFAULT_SYSTEM_PROMPT_BASE, DEFAULT_TEXT_BASE_PX, DEFAULT_TEXT_FONT_WEIGHT, DEFAULT_TEXT_LETTER_SPACING_PX, DEFAULT_TEXT_LINE_HEIGHT, DEFAULT_TOP_K_URLS, - DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, DEFAULT_UPDATER_MANIFEST_URL, PROVIDER_ID_BUILTIN, - PROVIDER_ID_OLLAMA, PROVIDER_KIND_BUILTIN, PROVIDER_KIND_OLLAMA, SLASH_COMMAND_PROMPT_APPENDIX, + DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, DEFAULT_UPDATER_MANIFEST_URL, + SLASH_COMMAND_PROMPT_APPENDIX, }; use super::error::ConfigError; use super::loader::{compose_system_prompt, load_from_path}; -use super::migrate::{attach_legacy_active_model, toml_has_providers}; use super::schema::{ - AppConfig, BehaviorSection, DebugSection, InferenceSection, PromptSection, Provider, - QuoteSection, SearchSection, UpdaterSection, WindowSection, + AppConfig, BehaviorSection, DebugSection, InferenceSection, PromptSection, QuoteSection, + SearchSection, UpdaterSection, WindowSection, }; use super::writer::atomic_write; @@ -53,7 +52,7 @@ fn defaults_const_values_match_schema_defaults() { // Guard rail: a change to a default in defaults.rs must flow through to // AppConfig::default(). If this test fails, someone changed one but not both. let c = AppConfig::default(); - assert_eq!(c.inference.active_provider_base_url(), DEFAULT_OLLAMA_URL); + assert_eq!(c.inference.ollama_url, DEFAULT_OLLAMA_URL); assert_eq!( c.inference.keep_warm_inactivity_minutes, DEFAULT_KEEP_WARM_INACTIVITY_MINUTES @@ -103,8 +102,7 @@ fn defaults_prompt_base_is_nonempty() { #[test] fn section_defaults_are_sensible() { let m = InferenceSection::default(); - assert_eq!(m.active_provider, DEFAULT_ACTIVE_PROVIDER); - assert_eq!(m.active_provider_base_url(), DEFAULT_OLLAMA_URL); + assert_eq!(m.ollama_url, DEFAULT_OLLAMA_URL); let p = PromptSection::default(); assert_eq!(p.system, DEFAULT_SYSTEM_PROMPT_BASE); @@ -136,20 +134,13 @@ fn app_config_serde_round_trip_matches_defaults() { #[test] fn app_config_partial_file_fills_missing_fields_with_defaults() { - // Only declare one field; serde(default) fills the rest. A missing - // `providers` key defaults to an empty Vec (field-level default), distinct - // from the seeded pair, so the loader can detect a pre-providers file. + // Only declare one field; serde(default) fills the rest. let partial = r#" [inference] - num_ctx = 32768 + ollama_url = "http://localhost:9999" "#; let parsed: AppConfig = toml::from_str(partial).expect("partial file parses"); - assert_eq!(parsed.inference.num_ctx, 32768); - assert_eq!(parsed.inference.active_provider, DEFAULT_ACTIVE_PROVIDER); - assert!( - parsed.inference.providers.is_empty(), - "a missing providers key deserializes to an empty Vec, not the seeded pair" - ); + assert_eq!(parsed.inference.ollama_url, "http://localhost:9999"); assert_eq!(parsed.window.overlay_width, DEFAULT_OVERLAY_WIDTH); assert_eq!( parsed.quote.max_display_lines, @@ -206,10 +197,7 @@ fn load_missing_file_seeds_defaults_and_returns_them() { let config = load_from_path(&path).expect("seed on first run"); assert!(path.exists(), "file should be seeded"); - assert_eq!( - config.inference.active_provider_base_url(), - DEFAULT_OLLAMA_URL - ); + assert_eq!(config.inference.ollama_url, DEFAULT_OLLAMA_URL); // Resolved system prompt composed from default base plus appendix. assert!(config .prompt @@ -228,10 +216,7 @@ fn load_missing_file_in_missing_parent_dir_creates_dir() { let path = config_path_in(&nested); let config = load_from_path(&path).expect("creates parent dir and seeds"); assert!(path.exists()); - assert_eq!( - config.inference.active_provider_base_url(), - DEFAULT_OLLAMA_URL - ); + assert_eq!(config.inference.ollama_url, DEFAULT_OLLAMA_URL); } #[test] @@ -264,10 +249,7 @@ fn load_existing_valid_file_returns_resolved_config() { .unwrap(); let config = load_from_path(&path).unwrap(); - assert_eq!( - config.inference.active_provider_base_url(), - "http://localhost:99999" - ); + assert_eq!(config.inference.ollama_url, "http://localhost:99999"); } #[test] @@ -299,10 +281,7 @@ fn load_corrupt_file_is_renamed_and_reseeded() { std::fs::write(&path, "this is = definitely not [ valid toml").unwrap(); let config = load_from_path(&path).expect("recover from corrupt file"); - assert_eq!( - config.inference.active_provider_base_url(), - DEFAULT_OLLAMA_URL - ); + assert_eq!(config.inference.ollama_url, DEFAULT_OLLAMA_URL); // Original file renamed with .corrupt- prefix. let renamed_exists = std::fs::read_dir(&dir) @@ -344,10 +323,7 @@ fn load_unreadable_file_returns_in_memory_defaults() { } let config = load_from_path(&path).expect("fallback to in-memory defaults"); - assert_eq!( - config.inference.active_provider_base_url(), - DEFAULT_OLLAMA_URL - ); + assert_eq!(config.inference.ollama_url, DEFAULT_OLLAMA_URL); // Restore so cleanup works. let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)); } @@ -371,10 +347,7 @@ fn resolve_unknown_model_field_is_ignored() { ) .unwrap(); let config = load_from_path(&path).unwrap(); - assert_eq!( - config.inference.active_provider_base_url(), - "http://localhost:11434" - ); + assert_eq!(config.inference.ollama_url, "http://localhost:11434"); } #[test] @@ -545,10 +518,7 @@ fn resolve_empty_ollama_url_falls_back() { ) .unwrap(); let config = load_from_path(&path).unwrap(); - assert_eq!( - config.inference.active_provider_base_url(), - DEFAULT_OLLAMA_URL - ); + assert_eq!(config.inference.ollama_url, DEFAULT_OLLAMA_URL); } #[test] @@ -894,10 +864,7 @@ fn marker_write_failure_is_logged_but_does_not_block_recovery() { std::fs::create_dir(&blocker).unwrap(); let config = load_from_path(&path).expect("recover even when marker write fails"); - assert_eq!( - config.inference.active_provider_base_url(), - DEFAULT_OLLAMA_URL - ); + assert_eq!(config.inference.ollama_url, DEFAULT_OLLAMA_URL); // Marker squatter is still a directory: the failed write did not replace it. assert!(blocker.is_dir()); @@ -916,7 +883,7 @@ fn atomic_write_creates_file_with_defaults() { let contents = std::fs::read_to_string(&path).unwrap(); // resolved_system is not serialized (marked #[serde(skip)]). assert!(!contents.contains("resolved_system")); - assert!(contents.contains("active_provider")); + assert!(contents.contains("ollama_url")); } #[cfg(unix)] @@ -947,7 +914,7 @@ fn atomic_write_overwrites_existing_file_atomically() { std::fs::write(&path, "old contents").unwrap(); atomic_write(&path, &AppConfig::default()).unwrap(); let contents = std::fs::read_to_string(&path).unwrap(); - assert!(contents.contains("active_provider")); + assert!(contents.contains("ollama_url")); assert!(!contents.contains("old contents")); } @@ -1392,447 +1359,3 @@ fn updater_toml_roundtrip_preserves_fields() { let roundtripped: AppConfig = toml::from_str(&serialized).unwrap(); assert_eq!(roundtripped.updater, original.updater); } - -// ── inference providers: schema defaults ───────────────────────────────────── - -#[test] -fn inference_defaults_seed_builtin_and_ollama_providers() { - let c = AppConfig::default(); - assert_eq!(c.inference.active_provider, DEFAULT_ACTIVE_PROVIDER); - assert_eq!(c.inference.active_provider_kind(), PROVIDER_KIND_OLLAMA); - assert_eq!(c.inference.num_ctx, DEFAULT_NUM_CTX); - assert_eq!( - c.inference.keep_warm_inactivity_minutes, - DEFAULT_KEEP_WARM_INACTIVITY_MINUTES - ); - let ids: Vec<&str> = c - .inference - .providers - .iter() - .map(|p| p.id.as_str()) - .collect(); - assert_eq!(ids, vec![PROVIDER_ID_BUILTIN, PROVIDER_ID_OLLAMA]); - let ollama = c - .inference - .providers - .iter() - .find(|p| p.id == PROVIDER_ID_OLLAMA) - .unwrap(); - assert_eq!(ollama.base_url, DEFAULT_OLLAMA_URL); - assert_eq!(ollama.model, ""); - let builtin = c - .inference - .providers - .iter() - .find(|p| p.id == PROVIDER_ID_BUILTIN) - .unwrap(); - assert_eq!(builtin.base_url, ""); - assert_eq!(c.inference.legacy_ollama_url, None); -} - -#[test] -fn provider_constructors_carry_expected_fields() { - let b = super::schema::builtin_provider(); - assert_eq!(b.id, PROVIDER_ID_BUILTIN); - assert_eq!(b.kind, PROVIDER_KIND_BUILTIN); - assert!(b.base_url.is_empty()); - - let o = super::schema::ollama_provider("http://x:1"); - assert_eq!(o.id, PROVIDER_ID_OLLAMA); - assert_eq!(o.kind, PROVIDER_KIND_OLLAMA); - assert_eq!(o.base_url, "http://x:1"); -} - -#[test] -fn active_provider_accessors_handle_missing_active() { - // An InferenceSection whose active pointer matches no provider returns - // empty strings rather than panicking. - let inf = InferenceSection { - active_provider: "ghost".to_string(), - providers: vec![], - ..InferenceSection::default() - }; - assert!(inf.active().is_none()); - assert_eq!(inf.active_provider_base_url(), ""); - assert_eq!(inf.active_provider_model(), ""); - assert_eq!(inf.active_provider_model_opt(), None); - assert_eq!(inf.active_provider_kind(), ""); -} - -#[test] -fn active_provider_model_opt_maps_empty_to_none() { - // Empty model field -> None; a selected model -> Some(slug). Drives the - // active-model resolve helpers without re-deriving the empty check. - let mut c = AppConfig::default(); - assert_eq!(c.inference.active_provider_model_opt(), None); - if let Some(ollama) = c - .inference - .providers - .iter_mut() - .find(|p| p.id == PROVIDER_ID_OLLAMA) - { - ollama.model = "llama3.1:8b".to_string(); - } - assert_eq!(c.inference.active_provider_model_opt(), Some("llama3.1:8b")); -} - -// ── inference providers: migration matrix ──────────────────────────────────── - -#[test] -fn migrates_old_ollama_url_to_ollama_provider_and_activates_it() { - let dir = fresh_temp_dir(); - let path = config_path_in(&dir); - std::fs::write( - &path, - "[inference]\nollama_url = \"http://192.168.1.50:11434\"\n", - ) - .unwrap(); - let c = load_from_path(&path).unwrap(); - assert_eq!(c.inference.active_provider, PROVIDER_ID_OLLAMA); - let ollama = c - .inference - .providers - .iter() - .find(|p| p.id == PROVIDER_ID_OLLAMA) - .unwrap(); - assert_eq!(ollama.base_url, "http://192.168.1.50:11434"); - assert!(c - .inference - .providers - .iter() - .any(|p| p.id == PROVIDER_ID_BUILTIN)); - // legacy field is consumed by resolve and never re-serialized. - assert_eq!(c.inference.legacy_ollama_url, None); -} - -#[test] -fn migrates_old_empty_ollama_url_to_localhost_default() { - let dir = fresh_temp_dir(); - let path = config_path_in(&dir); - std::fs::write(&path, "[inference]\nollama_url = \"\"\n").unwrap(); - let c = load_from_path(&path).unwrap(); - let ollama = c - .inference - .providers - .iter() - .find(|p| p.id == PROVIDER_ID_OLLAMA) - .unwrap(); - assert_eq!(ollama.base_url, DEFAULT_OLLAMA_URL); -} - -#[test] -fn legacy_ollama_url_ignored_when_explicit_providers_present() { - // Defensive: a hand-edited file carrying BOTH the legacy `ollama_url` and - // an explicit providers list keeps the explicit providers; the legacy - // value is consumed and dropped rather than overwriting them. - let dir = fresh_temp_dir(); - let path = config_path_in(&dir); - std::fs::write( - &path, - r#" - [inference] - active_provider = "ollama" - ollama_url = "http://legacy-ignored:1" - [[inference.providers]] - id = "builtin" - kind = "builtin" - label = "Built-in (Thuki)" - [[inference.providers]] - id = "ollama" - kind = "ollama" - label = "Ollama" - base_url = "http://explicit:2" - "#, - ) - .unwrap(); - let c = load_from_path(&path).unwrap(); - let ollama = c - .inference - .providers - .iter() - .find(|p| p.id == PROVIDER_ID_OLLAMA) - .unwrap(); - assert_eq!(ollama.base_url, "http://explicit:2"); - assert_eq!(c.inference.legacy_ollama_url, None); - // The migration branch must be short-circuited entirely: no duplicate - // Ollama provider is synthesized alongside the explicit one. - assert_eq!( - c.inference - .providers - .iter() - .filter(|p| p.id == PROVIDER_ID_OLLAMA) - .count(), - 1 - ); -} - -#[test] -fn dangling_active_provider_falls_back_to_default() { - let dir = fresh_temp_dir(); - let path = config_path_in(&dir); - std::fs::write( - &path, - r#" - [inference] - active_provider = "nonexistent" - [[inference.providers]] - id = "builtin" - kind = "builtin" - label = "Built-in (Thuki)" - [[inference.providers]] - id = "ollama" - kind = "ollama" - label = "Ollama" - base_url = "http://127.0.0.1:11434" - "#, - ) - .unwrap(); - let c = load_from_path(&path).unwrap(); - assert_eq!(c.inference.active_provider, DEFAULT_ACTIVE_PROVIDER); -} - -#[test] -fn unknown_kind_provider_is_dropped_and_builtin_reseeded() { - let dir = fresh_temp_dir(); - let path = config_path_in(&dir); - std::fs::write( - &path, - r#" - [inference] - active_provider = "ollama" - [[inference.providers]] - id = "weird" - kind = "anthropic" - label = "Cloud" - base_url = "https://example.com" - [[inference.providers]] - id = "ollama" - kind = "ollama" - label = "Ollama" - base_url = "http://127.0.0.1:11434" - "#, - ) - .unwrap(); - let c = load_from_path(&path).unwrap(); - assert!(!c.inference.providers.iter().any(|p| p.id == "weird")); - assert!(c - .inference - .providers - .iter() - .any(|p| p.kind == PROVIDER_KIND_BUILTIN)); -} - -#[test] -fn ollama_provider_with_empty_base_url_is_dropped_then_reseeded() { - let dir = fresh_temp_dir(); - let path = config_path_in(&dir); - std::fs::write( - &path, - r#" - [inference] - active_provider = "ollama" - [[inference.providers]] - id = "ollama" - kind = "ollama" - label = "Ollama" - base_url = " " - "#, - ) - .unwrap(); - let c = load_from_path(&path).unwrap(); - let ollama = c - .inference - .providers - .iter() - .find(|p| p.kind == PROVIDER_KIND_OLLAMA) - .unwrap(); - assert_eq!(ollama.base_url, DEFAULT_OLLAMA_URL); -} - -#[test] -fn ollama_provider_with_non_http_base_url_is_reset_to_default() { - // Defense-in-depth: a non-http(s) scheme (or a scheme-less host) would be - // POSTed verbatim by the backend, so the loader resets it to the default. - let dir = fresh_temp_dir(); - let path = config_path_in(&dir); - std::fs::write( - &path, - r#" - [inference] - active_provider = "ollama" - [[inference.providers]] - id = "ollama" - kind = "ollama" - label = "Ollama" - base_url = "file:///etc/passwd" - "#, - ) - .unwrap(); - let c = load_from_path(&path).unwrap(); - let ollama = c - .inference - .providers - .iter() - .find(|p| p.kind == PROVIDER_KIND_OLLAMA) - .unwrap(); - assert_eq!(ollama.base_url, DEFAULT_OLLAMA_URL); -} - -#[test] -fn ollama_provider_with_https_base_url_is_preserved() { - let dir = fresh_temp_dir(); - let path = config_path_in(&dir); - std::fs::write( - &path, - r#" - [inference] - active_provider = "ollama" - [[inference.providers]] - id = "ollama" - kind = "ollama" - label = "Ollama" - base_url = "https://ollama.example.com:11434" - "#, - ) - .unwrap(); - let c = load_from_path(&path).unwrap(); - let ollama = c - .inference - .providers - .iter() - .find(|p| p.kind == PROVIDER_KIND_OLLAMA) - .unwrap(); - assert_eq!(ollama.base_url, "https://ollama.example.com:11434"); -} - -#[test] -fn missing_builtin_provider_is_reseeded() { - let dir = fresh_temp_dir(); - let path = config_path_in(&dir); - std::fs::write( - &path, - r#" - [inference] - active_provider = "ollama" - [[inference.providers]] - id = "ollama" - kind = "ollama" - label = "Ollama" - base_url = "http://127.0.0.1:11434" - "#, - ) - .unwrap(); - let c = load_from_path(&path).unwrap(); - assert!(c - .inference - .providers - .iter() - .any(|p| p.kind == PROVIDER_KIND_BUILTIN)); -} - -#[test] -fn new_shape_with_model_roundtrips_through_toml() { - let dir = fresh_temp_dir(); - let path = config_path_in(&dir); - let mut c = AppConfig::default(); - if let Some(p) = c - .inference - .providers - .iter_mut() - .find(|p| p.id == PROVIDER_ID_OLLAMA) - { - p.model = "llama3.1:8b".to_string(); - } - atomic_write(&path, &c).unwrap(); - let reloaded = load_from_path(&path).unwrap(); - let ollama = reloaded - .inference - .providers - .iter() - .find(|p| p.id == PROVIDER_ID_OLLAMA) - .unwrap(); - assert_eq!(ollama.model, "llama3.1:8b"); - assert_eq!( - reloaded.inference.active_provider, - c.inference.active_provider - ); -} - -// ── inference providers: migrate helpers ───────────────────────────────────── - -#[test] -fn attach_legacy_active_model_sets_model_on_active_provider() { - let mut c = AppConfig::default(); // active = ollama, model empty - assert!(attach_legacy_active_model(&mut c, Some("phi4:14b"))); - assert_eq!(c.inference.active_provider_model(), "phi4:14b"); - // idempotent: a second call with a different model does not overwrite - assert!(!attach_legacy_active_model(&mut c, Some("other:1b"))); - assert_eq!(c.inference.active_provider_model(), "phi4:14b"); -} - -#[test] -fn attach_legacy_active_model_targets_the_active_provider_only() { - // The legacy slug must land on the *active* provider, never on some other - // provider that merely happens to have an empty model. Make the built-in - // active (empty) and give Ollama a pre-existing model: attach writes the - // built-in and leaves Ollama untouched. - let mut c = AppConfig::default(); - c.inference.active_provider = PROVIDER_ID_BUILTIN.to_string(); - if let Some(ollama) = c - .inference - .providers - .iter_mut() - .find(|p| p.id == PROVIDER_ID_OLLAMA) - { - ollama.model = "existing:7b".to_string(); - } - assert!(attach_legacy_active_model(&mut c, Some("legacy:1b"))); - let builtin = c - .inference - .providers - .iter() - .find(|p| p.id == PROVIDER_ID_BUILTIN) - .unwrap(); - assert_eq!(builtin.model, "legacy:1b"); - let ollama = c - .inference - .providers - .iter() - .find(|p| p.id == PROVIDER_ID_OLLAMA) - .unwrap(); - assert_eq!(ollama.model, "existing:7b"); -} - -#[test] -fn attach_legacy_active_model_ignores_empty_and_missing_provider() { - let mut c = AppConfig::default(); - assert!(!attach_legacy_active_model(&mut c, None)); - assert!(!attach_legacy_active_model(&mut c, Some(" "))); - assert_eq!(c.inference.active_provider_model(), ""); - - // No matching active provider -> no-op (defensive). - let mut orphan = AppConfig::default(); - orphan.inference.active_provider = "ghost".to_string(); - assert!(!attach_legacy_active_model(&mut orphan, Some("x"))); -} - -#[test] -fn toml_has_providers_detects_shape() { - assert!(!toml_has_providers( - "[inference]\nollama_url = \"http://x\"\n" - )); - assert!(toml_has_providers( - "[inference]\nactive_provider=\"ollama\"\n[[inference.providers]]\nid=\"ollama\"\nkind=\"ollama\"\nbase_url=\"http://x\"\n" - )); - assert!(!toml_has_providers("not valid toml [")); - assert!(!toml_has_providers("[inference]\n")); -} - -#[test] -fn provider_struct_default_is_all_empty() { - let p = Provider::default(); - assert!(p.id.is_empty()); - assert!(p.kind.is_empty()); - assert!(p.base_url.is_empty()); - assert!(p.model.is_empty()); -} diff --git a/src-tauri/src/history.rs b/src-tauri/src/history.rs index f631cd1f..863c1f15 100644 --- a/src-tauri/src/history.rs +++ b/src-tauri/src/history.rs @@ -182,7 +182,7 @@ pub fn list_conversations( } /// Loads all messages for a conversation and syncs them into the backend -/// `ConversationHistory` so subsequent `ask_model` calls include context. +/// `ConversationHistory` so subsequent `ask_ollama` calls include context. #[cfg_attr(coverage_nightly, coverage(off))] #[cfg_attr(not(coverage), tauri::command)] pub fn load_conversation( @@ -194,7 +194,7 @@ pub fn load_conversation( let persisted = database::load_messages(&conn, &conversation_id).map_err(|e| e.to_string())?; // Bump the epoch before replacing messages - same invariant as - // `reset_conversation`. This prevents any in-flight `ask_model` + // `reset_conversation`. This prevents any in-flight `ask_ollama` // stream from appending stale tokens into the freshly loaded history. history .epoch @@ -296,10 +296,7 @@ pub async fn generate_title( let endpoint = format!( "{}/api/chat", - app_config - .inference - .active_provider_base_url() - .trim_end_matches('/') + app_config.inference.ollama_url.trim_end_matches('/') ); let cancel_token = tokio_util::sync::CancellationToken::new(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8b882e3b..95ed0f2f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -267,10 +267,7 @@ fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationCo .clone(); let endpoint = format!( "{}/api/chat", - warmup_config - .inference - .active_provider_base_url() - .trim_end_matches('/') + warmup_config.inference.ollama_url.trim_end_matches('/') ); let system_prompt = warmup_config.prompt.resolved_system.clone(); let keep_alive = if warmup_config.inference.keep_warm_inactivity_minutes == 0 { @@ -1460,18 +1457,6 @@ fn refresh_tray(app: &tauri::AppHandle) { /// # Panics /// /// Panics if the Tauri runtime fails to initialise. -/// Thin filesystem wrapper: true when the on-disk config already carries an -/// `[[inference.providers]]` array (the new shape). Used by startup to decide -/// whether to perform the one-time old -> new shape upgrade write. The parse -/// logic it delegates to (`migrate::toml_has_providers`) is unit-tested; this -/// wrapper only does the file read and is excluded from coverage. -#[cfg_attr(coverage_nightly, coverage(off))] -fn config_file_has_providers(path: &std::path::Path) -> bool { - std::fs::read_to_string(path) - .map(|s| crate::config::migrate::toml_has_providers(&s)) - .unwrap_or(false) -} - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let mut builder = tauri::Builder::default(); @@ -1774,39 +1759,18 @@ pub fn run() { let db_conn = database::open_database(&app_data_dir) .expect("failed to initialise SQLite database"); - // ── Active model: migrate the legacy SQLite slug onto the active - // provider, then seed the in-memory ActiveModelState ────────── - // Pre-providers builds persisted the active model in SQLite under - // ACTIVE_MODEL_KEY. It now lives on the active provider's `model` - // field in config.toml. Read the legacy value once; if the active - // provider has no model yet, fold it in, and for an old-shape file - // upgrade the file to the providers shape (a one-time write). - // After this the model persists to config and SQLite active_model - // is never written again. The installed list isn't queried here - // (no async runtime yet); get_model_picker_state reconciles against - // the live /api/tags inventory on first picker open. - let legacy_active_model = database::get_config(&db_conn, models::ACTIVE_MODEL_KEY) - .expect("failed to read legacy active_model from app_config"); - let config_file_path = app - .path() - .app_config_dir() - .expect("failed to resolve app config dir") - .join(crate::config::CONFIG_FILE_NAME); - let initial_active_model = { - let state = app.state::>(); - let mut cfg = state.write(); - let pre_providers = !config_file_has_providers(&config_file_path); - let attached = crate::config::migrate::attach_legacy_active_model( - &mut cfg, - legacy_active_model.as_deref(), - ); - if pre_providers || attached { - if let Err(e) = crate::config::writer::atomic_write(&config_file_path, &cfg) { - eprintln!("thuki: [config] active-model migration write failed: {e}"); - } - } - models::resolve_seed_active_model(Some(cfg.inference.active_provider_model())) - }; + // ── Active-model state: seed from SQLite app_config table ── + // The installed list isn't queried here (no async runtime yet). + // get_model_picker_state reconciles against the live /api/tags + // inventory on first picker open and may replace this seed. + // When nothing is persisted the seed is `None`: there is no + // compiled fallback. The Phase 3 onboarding gate refuses to + // open the overlay until a real installed model is selected, + // so an unset slug never reaches `ask_ollama`. + let persisted_active = database::get_config(&db_conn, models::ACTIVE_MODEL_KEY) + .expect("failed to read active_model from app_config"); + let initial_active_model = + models::resolve_seed_active_model(persisted_active.as_deref()); app.manage(models::ActiveModelState(std::sync::Mutex::new( initial_active_model, ))); @@ -1828,7 +1792,7 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ #[cfg(not(coverage))] - commands::ask_model, + commands::ask_ollama, #[cfg(not(coverage))] commands::cancel_generation, #[cfg(not(coverage))] @@ -1841,7 +1805,6 @@ pub fn run() { commands::record_conversation_end, settings_commands::get_config, settings_commands::set_config_field, - settings_commands::set_ollama_url, settings_commands::reset_config, settings_commands::reload_config_from_disk, settings_commands::get_corrupt_marker, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index c9abf511..858b3b9e 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -1,12 +1,10 @@ /*! * Active-model state module. * - * The "active" model is whichever slug the user last picked via the picker - * popup. It is persisted across launches on the active provider's `model` - * field in `config.toml` (see [`crate::config::schema::Provider`]) and mirrored - * in [`ActiveModelState`] for fast reads from Tauri commands. The legacy SQLite - * [`ACTIVE_MODEL_KEY`] is read once at startup and folded onto the active - * provider by `crate::config::migrate`; it is no longer written. + * Single source of truth for the locally-selected Ollama model. The "active" + * model is whichever slug the user last picked via the picker popup, + * persisted across launches in `app_config` under [`ACTIVE_MODEL_KEY`] and + * mirrored in [`ActiveModelState`] for fast reads from Tauri commands. * * The backend treats Ollama's `/api/tags` response as authoritative: a * persisted model is only honored if it still appears in the live installed @@ -26,10 +24,10 @@ use crate::config::defaults::{ MAX_MODEL_SLUG_LEN, MAX_OLLAMA_SHOW_BODY_BYTES, MAX_OLLAMA_TAGS_BODY_BYTES, }; use crate::config::AppConfig; +use crate::database::{get_config, set_config}; +use crate::history::Database; -/// Legacy SQLite `app_config` key that older builds used to persist the -/// selected model slug. Now read once at startup and folded onto the active -/// provider's `model` field by `crate::config::migrate`; never written anymore. +/// `app_config` key used to persist the user's selected model slug. pub const ACTIVE_MODEL_KEY: &str = "active_model"; /// Shared error-message prefix used when a requested slug is not present in @@ -245,32 +243,37 @@ async fn fetch_installed_model_names_inner( #[cfg_attr(coverage_nightly, coverage(off))] #[cfg_attr(not(coverage), tauri::command)] pub async fn get_model_picker_state( - app: tauri::AppHandle, client: tauri::State<'_, reqwest::Client>, + db: tauri::State<'_, Database>, active_model: tauri::State<'_, ActiveModelState>, config: tauri::State<'_, parking_lot::RwLock>, ) -> Result { - let (ollama_url, active_id, persisted) = read_provider_model_context(&config); + let ollama_url = config.read().inference.ollama_url.clone(); let fetch_result = fetch_installed_model_names(&client, &ollama_url).await; let installed = match fetch_result { Ok(installed) => installed, Err(_) => { // Mirror the `None` active into the in-memory state so downstream - // callers (ask_model, search_pipeline) see the same truth as the - // frontend: with the provider unreachable, no model is active. + // callers (ask_ollama, search_pipeline) see the same truth as the + // frontend: with Ollama unreachable, no model is active. let mut guard = active_model.0.lock().map_err(|e| e.to_string())?; *guard = None; return Ok(build_picker_state_payload(None, &[], false)); } }; - let resolved = resolve_active_model(persisted.as_deref(), &installed); - if let Some(slug) = resolved.as_deref() { - if should_persist_resolved(&installed, persisted.as_deref(), slug) { - persist_active_provider_model(&app, &config, &active_id, slug)?; + let resolved = { + let conn = db.0.lock().map_err(|e| e.to_string())?; + let persisted = get_config(&conn, ACTIVE_MODEL_KEY).map_err(|e| e.to_string())?; + let resolved = resolve_active_model(persisted.as_deref(), &installed); + if let Some(slug) = resolved.as_deref() { + if should_persist_resolved(&installed, persisted.as_deref(), slug) { + set_config(&conn, ACTIVE_MODEL_KEY, slug).map_err(|e| e.to_string())?; + } } - } + resolved + }; { let mut guard = active_model.0.lock().map_err(|e| e.to_string())?; @@ -284,39 +287,6 @@ pub async fn get_model_picker_state( )) } -/// Snapshots the active provider's base URL, id, and selected model from the -/// shared config. Returns the model as `Option` (empty -> `None`) so -/// callers can feed it straight into the resolve helpers. -#[cfg_attr(coverage_nightly, coverage(off))] -fn read_provider_model_context( - config: &parking_lot::RwLock, -) -> (String, String, Option) { - let c = config.read(); - ( - c.inference.active_provider_base_url().to_string(), - c.inference.active_provider.clone(), - c.inference.active_provider_model_opt().map(str::to_string), - ) -} - -/// Writes `slug` onto the active provider's `model` field in config.toml and -/// swaps the resolved result into the shared in-memory config. Replaces the -/// former SQLite `set_config(ACTIVE_MODEL_KEY, ...)` persistence. -#[cfg_attr(coverage_nightly, coverage(off))] -fn persist_active_provider_model( - app: &tauri::AppHandle, - config: &parking_lot::RwLock, - provider_id: &str, - slug: &str, -) -> Result<(), String> { - let path = crate::settings_commands::config_path(app).map_err(|e| e.to_string())?; - let resolved = - crate::settings_commands::write_provider_field_to_disk(&path, provider_id, "model", slug) - .map_err(|e| e.to_string())?; - *config.write() = resolved; - Ok(()) -} - /// Pure helper that shapes the `get_model_picker_state` payload. Extracted so /// the three states (unreachable, reachable + empty, reachable + populated) /// can be unit-tested without spinning up a Tauri runtime or an HTTP server. @@ -343,18 +313,21 @@ pub fn build_picker_state_payload( #[cfg_attr(not(coverage), tauri::command)] pub async fn set_active_model( model: String, - app: tauri::AppHandle, client: tauri::State<'_, reqwest::Client>, + db: tauri::State<'_, Database>, active_model: tauri::State<'_, ActiveModelState>, config: tauri::State<'_, parking_lot::RwLock>, ) -> Result<(), String> { validate_model_slug(&model)?; - let (ollama_url, active_id, _persisted) = read_provider_model_context(&config); + let ollama_url = config.read().inference.ollama_url.clone(); let installed = fetch_installed_model_names(&client, &ollama_url).await?; validate_model_installed(&model, &installed)?; - persist_active_provider_model(&app, &config, &active_id, &model)?; + { + let conn = db.0.lock().map_err(|e| e.to_string())?; + set_config(&conn, ACTIVE_MODEL_KEY, &model).map_err(|e| e.to_string())?; + } { let mut guard = active_model.0.lock().map_err(|e| e.to_string())?; @@ -442,7 +415,7 @@ pub fn derive_model_setup_state( /// the case where a user removed their previously-selected model with /// `ollama rm` between launches. /// 2. Mirror the resolved slug into the in-memory [`ActiveModelState`] so -/// `ask_model` and `search_pipeline` see it on the next request +/// `ask_ollama` and `search_pipeline` see it on the next request /// without an extra DB read. /// /// Both writes are gated through [`should_persist_resolved`] which @@ -452,14 +425,19 @@ pub fn derive_model_setup_state( #[cfg_attr(coverage_nightly, coverage(off))] #[cfg_attr(not(coverage), tauri::command)] pub async fn check_model_setup( - app: tauri::AppHandle, client: tauri::State<'_, reqwest::Client>, + db: tauri::State<'_, Database>, active_model: tauri::State<'_, ActiveModelState>, config: tauri::State<'_, parking_lot::RwLock>, ) -> Result { - let (ollama_url, active_id, persisted) = read_provider_model_context(&config); + let ollama_url = config.read().inference.ollama_url.clone(); let installed_result = fetch_installed_model_names(&client, &ollama_url).await; + let persisted = { + let conn = db.0.lock().map_err(|e| e.to_string())?; + get_config(&conn, ACTIVE_MODEL_KEY).map_err(|e| e.to_string())? + }; + let state = derive_model_setup_state(installed_result, persisted.as_deref()); if let ModelSetupState::Ready { @@ -468,7 +446,8 @@ pub async fn check_model_setup( } = state { if should_persist_resolved(installed, persisted.as_deref(), active_slug) { - persist_active_provider_model(&app, &config, &active_id, active_slug)?; + let conn = db.0.lock().map_err(|e| e.to_string())?; + set_config(&conn, ACTIVE_MODEL_KEY, active_slug).map_err(|e| e.to_string())?; } let mut guard = active_model.0.lock().map_err(|e| e.to_string())?; *guard = Some(active_slug.clone()); @@ -688,14 +667,14 @@ async fn fetch_model_capabilities_inner( Ok(caps) } -/// In-memory cache of capabilities keyed by `(provider_id, model)`. The same -/// model slug can resolve to different capabilities on different providers, so -/// the provider id is part of the key. Populated lazily the first time a model -/// is queried; cleared on app restart, which is the simplest valid invalidation -/// strategy (capabilities for a given provider+slug pair never change during a -/// process lifetime). +/// In-memory cache of capabilities keyed by model slug. Populated lazily +/// the first time a model is queried. Cleared on app restart, which is +/// the simplest valid invalidation strategy: re-pulling a model under the +/// same slug requires a process restart anyway because Tauri's reqwest +/// client is process-scoped, and capabilities for a given (slug, digest) +/// pair never change. #[derive(Default)] -pub struct ModelCapabilitiesCache(pub Mutex>); +pub struct ModelCapabilitiesCache(pub Mutex>); /// Fetches `/api/tags` for the installed list, then returns a map of /// `model name -> Capabilities` covering every installed model. Uses the @@ -715,15 +694,9 @@ pub async fn get_model_capabilities( cache: tauri::State<'_, ModelCapabilitiesCache>, config: tauri::State<'_, parking_lot::RwLock>, ) -> Result, String> { - let (provider_id, base_url) = { - let c = config.read(); - ( - c.inference.active_provider.clone(), - c.inference.active_provider_base_url().to_string(), - ) - }; + let base_url = config.read().inference.ollama_url.clone(); let installed = fetch_installed_model_names(&client, &base_url).await?; - Ok(reconcile_capabilities(&client, &cache, &provider_id, &base_url, &installed).await) + Ok(reconcile_capabilities(&client, &cache, &base_url, &installed).await) } /// Pure-ish helper extracted so tests can drive the cache + fetch loop @@ -748,7 +721,6 @@ pub async fn get_model_capabilities( async fn reconcile_capabilities( client: &reqwest::Client, cache: &ModelCapabilitiesCache, - provider_id: &str, base_url: &str, installed: &[String], ) -> HashMap { @@ -757,7 +729,7 @@ async fn reconcile_capabilities( match cache.0.lock() { Ok(guard) => { for name in installed { - if let Some(c) = guard.get(&(provider_id.to_string(), name.clone())) { + if let Some(c) = guard.get(name) { hits.insert(name.clone(), c.clone()); } else { misses.push(name.clone()); @@ -776,7 +748,7 @@ async fn reconcile_capabilities( } if let Ok(caps) = fetch_model_capabilities(client, base_url, name).await { if let Ok(mut guard) = cache.0.lock() { - guard.insert((provider_id.to_string(), name.clone()), caps.clone()); + guard.insert(name.clone(), caps.clone()); } hits.insert(name.clone(), caps); } @@ -789,10 +761,6 @@ async fn reconcile_capabilities( #[cfg(test)] mod tests { use super::*; - // The generic SQLite config helpers are no longer used by the production - // commands (model selection persists to config.toml), but the DB layer - // itself is still covered here via the ACTIVE_MODEL_KEY round-trip test. - use crate::database::{get_config, set_config}; // ── build_picker_state_payload ─────────────────────────────────────────── @@ -1883,14 +1851,14 @@ mod tests { async fn reconcile_returns_cached_entries_without_network() { let cache = ModelCapabilitiesCache::default(); cache.0.lock().unwrap().insert( - ("ollama".to_string(), "a".to_string()), + "a".to_string(), Capabilities { vision: true, ..Default::default() }, ); cache.0.lock().unwrap().insert( - ("ollama".to_string(), "b".to_string()), + "b".to_string(), Capabilities { thinking: true, ..Default::default() @@ -1898,8 +1866,7 @@ mod tests { ); let client = reqwest::Client::new(); let installed = vec!["a".to_string(), "b".to_string()]; - let result = - reconcile_capabilities(&client, &cache, "ollama", "http://unused", &installed).await; + let result = reconcile_capabilities(&client, &cache, "http://unused", &installed).await; assert_eq!(result.len(), 2); assert!(result["a"].vision); assert!(result["b"].thinking); @@ -1909,7 +1876,7 @@ mod tests { async fn reconcile_with_empty_installed_returns_empty_map() { let cache = ModelCapabilitiesCache::default(); let client = reqwest::Client::new(); - let result = reconcile_capabilities(&client, &cache, "ollama", "http://unused", &[]).await; + let result = reconcile_capabilities(&client, &cache, "http://unused", &[]).await; assert!(result.is_empty()); } @@ -1926,20 +1893,19 @@ mod tests { let cache = ModelCapabilitiesCache::default(); let client = reqwest::Client::new(); let installed = vec!["fresh".to_string()]; - let result = - reconcile_capabilities(&client, &cache, "ollama", &server.url(), &installed).await; + let result = reconcile_capabilities(&client, &cache, &server.url(), &installed).await; assert!(result["fresh"].vision); // Cache must now hold the fetched entry. let guard = cache.0.lock().unwrap(); - assert!(guard.contains_key(&("ollama".to_string(), "fresh".to_string()))); - assert!(guard[&("ollama".to_string(), "fresh".to_string())].vision); + assert!(guard.contains_key("fresh")); + assert!(guard["fresh"].vision); } #[tokio::test] async fn reconcile_drops_unreachable_misses_without_failing() { let cache = ModelCapabilitiesCache::default(); cache.0.lock().unwrap().insert( - ("ollama".to_string(), "cached".to_string()), + "cached".to_string(), Capabilities { vision: true, ..Default::default() @@ -1949,8 +1915,7 @@ mod tests { let installed = vec!["cached".to_string(), "missing".to_string()]; // Point base_url at a port nothing listens on so misses fail fast. let result = - reconcile_capabilities(&client, &cache, "ollama", "http://127.0.0.1:1", &installed) - .await; + reconcile_capabilities(&client, &cache, "http://127.0.0.1:1", &installed).await; assert!(result.contains_key("cached")); assert!(!result.contains_key("missing")); } @@ -1971,8 +1936,7 @@ mod tests { let cache = ModelCapabilitiesCache::default(); let client = reqwest::Client::new(); let installed = vec!["bad name".to_string(), "bad$(whoami)".to_string()]; - let result = - reconcile_capabilities(&client, &cache, "ollama", &server.url(), &installed).await; + let result = reconcile_capabilities(&client, &cache, &server.url(), &installed).await; assert!(result.is_empty()); m.assert_async().await; } @@ -1996,39 +1960,9 @@ mod tests { }); let client = reqwest::Client::new(); let installed = vec!["x".to_string()]; - let result = - reconcile_capabilities(&client, &cache, "ollama", &server.url(), &installed).await; + let result = reconcile_capabilities(&client, &cache, &server.url(), &installed).await; // Cache writes silently fail on the poisoned lock, but the // result map still carries the freshly-fetched value. assert!(result["x"].vision); } - - #[tokio::test] - async fn reconcile_keys_capabilities_by_provider() { - // The same slug under two providers holds two distinct cache entries; - // a reconcile scoped to one provider only sees that provider's entry. - let cache = ModelCapabilitiesCache::default(); - cache.0.lock().unwrap().insert( - ("ollama".to_string(), "shared:slug".to_string()), - Capabilities { - vision: true, - ..Default::default() - }, - ); - cache.0.lock().unwrap().insert( - ("builtin".to_string(), "shared:slug".to_string()), - Capabilities { - thinking: true, - ..Default::default() - }, - ); - let client = reqwest::Client::new(); - let installed = vec!["shared:slug".to_string()]; - let ollama = - reconcile_capabilities(&client, &cache, "ollama", "http://unused", &installed).await; - let builtin = - reconcile_capabilities(&client, &cache, "builtin", "http://unused", &installed).await; - assert!(ollama["shared:slug"].vision && !ollama["shared:slug"].thinking); - assert!(builtin["shared:slug"].thinking && !builtin["shared:slug"].vision); - } } diff --git a/src-tauri/src/search/mod.rs b/src-tauri/src/search/mod.rs index ea21c1bb..2ab7a400 100644 --- a/src-tauri/src/search/mod.rs +++ b/src-tauri/src/search/mod.rs @@ -73,26 +73,6 @@ pub async fn search_pipeline( // Snapshot the config once so the entire pipeline sees a consistent view // even if the user edits Settings while a search is in flight. let app_config = app_config.read().clone(); - - // Route by provider kind, mirroring `ask_model`. Phase 1 implements only - // the native Ollama path; a non-Ollama active provider cannot serve a - // search turn, so surface the same typed "not available yet" error the - // chat path emits instead of building a hostless `/api/chat` endpoint. - { - let kind = app_config.inference.active_provider_kind(); - let label = app_config - .inference - .active() - .map(|p| p.label.as_str()) - .unwrap_or(""); - if let Some(err) = crate::commands::unsupported_provider_error(kind, label) { - let _ = on_event.send(SearchEvent::Error { - message: err.message, - }); - return Ok(()); - } - } - // Resolve the runtime search view from the loaded TOML. The single // source of truth lives in `config::defaults`; the loader has already // clamped and resolved every field by the time we read it here. @@ -135,10 +115,7 @@ pub async fn search_pipeline( let ollama_endpoint = format!( "{}/api/chat", - app_config - .inference - .active_provider_base_url() - .trim_end_matches('/') + app_config.inference.ollama_url.trim_end_matches('/') ); let cancel_token = CancellationToken::new(); generation.set_token(cancel_token.clone()); @@ -165,8 +142,8 @@ pub async fn search_pipeline( // Mirror the user-perceived turn into the chat-domain trace so the // `traces/chat/.jsonl` file is the canonical // user-facing timeline regardless of whether a turn used `/search` - // or hit `ask_model` directly. Symmetric with what - // `commands::ask_model` records at its hook sites; the deep + // or hit `ask_ollama` directly. Symmetric with what + // `commands::ask_ollama` records at its hook sites; the deep // search-pipeline internals (LLM calls, judge verdicts, SearXNG // queries) stay in the search-domain file via the same conv id. crate::commands::record_conversation_start_if_first_turn( diff --git a/src-tauri/src/search/pipeline.rs b/src-tauri/src/search/pipeline.rs index 25f89d44..07573fd0 100644 --- a/src-tauri/src/search/pipeline.rs +++ b/src-tauri/src/search/pipeline.rs @@ -535,7 +535,7 @@ pub(super) fn translate_chunk(chunk: StreamChunk) -> SearchEvent { StreamChunk::Cancelled => SearchEvent::Cancelled, StreamChunk::Error(e) => SearchEvent::Error { message: e.message }, // `TurnAccepted` is a top-level handshake emitted by `commands:: - // ask_model` and `search::search_pipeline` themselves; the + // ask_ollama` and `search::search_pipeline` themselves; the // synthesis-pump path that feeds `translate_chunk` only ever // receives the streaming variants above. Forward it as the // matching pipeline event so the type stays exhaustive without @@ -2646,7 +2646,7 @@ fn split_into_stream_pieces(s: &str) -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::commands::{EngineError, EngineErrorKind}; + use crate::commands::{OllamaError, OllamaErrorKind}; use crate::config::defaults::DEFAULT_NUM_CTX; use std::sync::{Arc, Mutex}; @@ -2729,8 +2729,8 @@ mod tests { #[test] fn translate_chunk_error_maps_to_error_event() { - let out = translate_chunk(StreamChunk::Error(EngineError { - kind: EngineErrorKind::Other, + let out = translate_chunk(StreamChunk::Error(OllamaError { + kind: OllamaErrorKind::Other, message: "boom".into(), })); assert_eq!( diff --git a/src-tauri/src/search/types.rs b/src-tauri/src/search/types.rs index 3f2b7003..0cc3d515 100644 --- a/src-tauri/src/search/types.rs +++ b/src-tauri/src/search/types.rs @@ -166,7 +166,7 @@ pub enum SearchEvent { /// generic error bubble. SandboxUnavailable, /// No active model is selected. Mirrors the chat-side - /// `EngineErrorKind::NoModelSelected` bail so the frontend can keep its + /// `OllamaErrorKind::NoModelSelected` bail so the frontend can keep its /// `is_first_turn` flag pristine when the backend bails before /// `ConversationStart` fires. NoModelSelected, diff --git a/src-tauri/src/settings_commands.rs b/src-tauri/src/settings_commands.rs index 64b2f502..6c219fd8 100644 --- a/src-tauri/src/settings_commands.rs +++ b/src-tauri/src/settings_commands.rs @@ -53,7 +53,7 @@ use crate::config::{ /// has been replaced. Subscribers (the main overlay's `ConfigProvider` and /// the Settings window) refetch via `get_config` so React state matches the /// authoritative `RwLock` snapshot. Without this broadcast, only -/// backend-side consumers (e.g. `ask_model` reading `State>` per +/// backend-side consumers (e.g. `ask_ollama` reading `State>` per /// invocation) see config edits; frontend-driven values like window dims /// stay frozen at the mount-time snapshot. pub const CONFIG_UPDATED_EVENT: &str = "thuki://config-updated"; @@ -71,7 +71,7 @@ fn emit_config_updated(app: &AppHandle) { /// across the settings commands. On a successful lookup the returned path /// matches the path the loader uses, so writes round-trip cleanly. #[cfg_attr(coverage_nightly, coverage(off))] -pub(crate) fn config_path(app: &AppHandle) -> Result { +fn config_path(app: &AppHandle) -> Result { let dir = app .path() .app_config_dir() @@ -153,34 +153,6 @@ pub fn set_config_field( Ok(resolved) } -/// Sets the Ollama provider's `base_url` and returns the resolved `AppConfig`. -/// -/// The Ollama URL is not a flat `set_config_field` key: it lives on the -/// `[[inference.providers]]` Ollama entry, so it has its own command. Mirrors -/// `set_config_field`'s lock + persist + broadcast contract. -#[tauri::command] -#[cfg_attr(coverage_nightly, coverage(off))] -pub fn set_ollama_url( - base_url: String, - app: AppHandle, - state: State<'_, RwLock>, -) -> Result { - let path = config_path(&app)?; - let resolved = { - let mut guard = state.write(); - let resolved = write_provider_field_to_disk( - &path, - crate::config::defaults::PROVIDER_ID_OLLAMA, - "base_url", - base_url.trim(), - )?; - *guard = resolved.clone(); - resolved - }; - emit_config_updated(&app); - Ok(resolved) -} - /// Patches one `(section, key)` to disk and returns the resolved `AppConfig` /// the loader produces from the new file. Pulled out of the Tauri wrapper so /// the allowlist guard, document patch, atomic write, and post-write reload @@ -234,57 +206,6 @@ pub(crate) fn write_field_to_disk( config::load_from_path(path) } -/// Patches a single field (`model` or `base_url`) on the -/// `[[inference.providers]]` entry whose `id` matches `provider_id`, preserving -/// the rest of the file via `toml_edit`, then reloads + resolves. Backs the -/// `set_active_model` (model) and `set_ollama_url` (base_url) write paths. -/// Pulled out of the Tauri wrappers so the field allowlist, table lookup, -/// atomic write, and post-write reload are exercised without an `AppHandle`. -pub(crate) fn write_provider_field_to_disk( - path: &Path, - provider_id: &str, - field: &str, - value: &str, -) -> Result { - if !matches!(field, "model" | "base_url") { - return Err(ConfigError::UnknownField { - section: "inference.providers".to_string(), - key: field.to_string(), - }); - } - let mut doc = read_document(path)?; - let providers = doc - .get_mut("inference") - .and_then(|i| i.get_mut("providers")) - .and_then(|p| p.as_array_of_tables_mut()); - let Some(providers) = providers else { - return Err(ConfigError::UnknownSection { - section: "inference.providers".to_string(), - }); - }; - let mut patched = false; - for table in providers.iter_mut() { - if table.get("id").and_then(|v| v.as_str()) == Some(provider_id) { - table.insert(field, toml_value(value)); - patched = true; - break; - } - } - if !patched { - return Err(ConfigError::UnknownField { - section: "inference.providers".to_string(), - key: provider_id.to_string(), - }); - } - config::atomic_write_bytes(path, doc.to_string().as_bytes()).map_err(|source| { - ConfigError::IoError { - path: path.to_path_buf(), - source, - } - })?; - config::load_from_path(path) -} - /// Resets one section (or the whole file when `section` is `None`) to the /// compiled defaults, returning the resulting `AppConfig`. /// diff --git a/src-tauri/src/settings_commands/tests.rs b/src-tauri/src/settings_commands/tests.rs index 86247dfe..03a88e2e 100644 --- a/src-tauri/src/settings_commands/tests.rs +++ b/src-tauri/src/settings_commands/tests.rs @@ -14,7 +14,7 @@ use toml_edit::DocumentMut; use super::{ coerce_json_to_toml, is_allowed_field, is_allowed_section, json_type_name, json_value_to_toml_item, patch_document, read_document, reset_section_on_disk, - trace_enabled_changed, write_field_to_disk, write_provider_field_to_disk, + trace_enabled_changed, write_field_to_disk, }; use crate::config::defaults::{ALLOWED_FIELDS, ALLOWED_SECTIONS}; use crate::config::{AppConfig, ConfigError}; @@ -56,46 +56,21 @@ fn parse_sample() -> DocumentMut { SAMPLE_CONFIG.parse().expect("sample config parses") } -/// New-shape config carrying an explicit `[[inference.providers]]` array, used -/// to exercise `write_provider_field_to_disk`. -const PROVIDERS_CONFIG: &str = r#" -[inference] -active_provider = "ollama" -num_ctx = 16384 -keep_warm_inactivity_minutes = 0 - -[[inference.providers]] -id = "builtin" -kind = "builtin" -label = "Built-in (Thuki)" -model = "" - -[[inference.providers]] -id = "ollama" -kind = "ollama" -label = "Ollama" -base_url = "http://127.0.0.1:11434" -model = "" -"#; - // ─── ALLOWED_FIELDS / ALLOWED_SECTIONS ────────────────────────────────────── #[test] fn allowed_fields_count_matches_schema_field_count() { - // Hand-counted from `AppConfig`: inference(2) + prompt(1) + window(7) + quote(3) - // + behavior(2) + search(11) + debug(1) + updater(3) = 30 tunable flat fields. - // The inference section's `active_provider` and `providers` array are NOT flat - // fields: they are written through the dedicated `set_active_model` / - // `set_ollama_url` commands, not the generic `set_config_field` path, so they - // are intentionally absent from ALLOWED_FIELDS. The collapsed bar height and - // hide-commit delay are baked into the frontend (see `WindowSection` doc) - // because they have no perceptible effect across their usable range. - // `prompt.system_customized` is an internal migration flag co-written by - // set_config_field when prompt.system is saved; it is not directly user-tunable - // and is intentionally absent from ALLOWED_FIELDS. If this assertion fails, the - // schema has drifted from the allowlist and someone added a field without - // extending ALLOWED_FIELDS. - assert_eq!(ALLOWED_FIELDS.len(), 30); + // Hand-counted from `AppConfig`: inference(3) + prompt(1) + window(7) + quote(3) + // + behavior(2) + search(11) + debug(1) + updater(3) = 31 tunable fields. The + // active model slug lives in the SQLite app_config table via ActiveModelState, + // not in TOML. The collapsed bar height and hide-commit delay are baked into the + // frontend (see `WindowSection` doc) because they have no perceptible effect across + // their usable range. `prompt.system_customized` is an internal migration flag + // co-written by set_config_field when prompt.system is saved; it is not + // directly user-tunable and is intentionally absent from ALLOWED_FIELDS. + // If this assertion fails, the schema has drifted from the allowlist and + // someone added a field without extending ALLOWED_FIELDS. + assert_eq!(ALLOWED_FIELDS.len(), 31); } #[test] @@ -117,7 +92,7 @@ fn allowed_sections_match_app_config_top_level_keys() { #[test] fn is_allowed_field_accepts_known_pair() { - assert!(is_allowed_field("inference", "num_ctx")); + assert!(is_allowed_field("inference", "ollama_url")); assert!(is_allowed_field("search", "router_timeout_s")); assert!(is_allowed_field("search", "pipeline_wall_clock_budget_s")); } @@ -657,15 +632,15 @@ fn write_field_to_disk_persists_and_returns_resolved_config() { let resolved = write_field_to_disk( &path, - "search", - "searxng_url", - json!("http://10.0.0.1:25017"), + "inference", + "ollama_url", + json!("http://10.0.0.1:11434"), ) .unwrap(); - assert_eq!(resolved.search.searxng_url, "http://10.0.0.1:25017"); + assert_eq!(resolved.inference.ollama_url, "http://10.0.0.1:11434"); let on_disk = std::fs::read_to_string(&path).unwrap(); - assert!(on_disk.contains("http://10.0.0.1:25017")); + assert!(on_disk.contains("http://10.0.0.1:11434")); } #[test] @@ -762,7 +737,7 @@ fn write_field_to_disk_rejects_unknown_field() { fn write_field_to_disk_propagates_read_error_for_missing_file() { let dir = tempdir(); let path = dir.join("missing.toml"); - let err = write_field_to_disk(&path, "search", "searxng_url", json!("http://x")).unwrap_err(); + let err = write_field_to_disk(&path, "inference", "ollama_url", json!("http://x")).unwrap_err(); matches!(err, ConfigError::IoError { .. }); } @@ -771,8 +746,8 @@ fn write_field_to_disk_propagates_patch_error_for_type_mismatch() { let dir = tempdir(); let path = dir.join("config.toml"); std::fs::write(&path, SAMPLE_CONFIG).unwrap(); - let err = write_field_to_disk(&path, "search", "searxng_url", json!(42)).unwrap_err(); - matches_type_mismatch(&err, "search", "searxng_url"); + let err = write_field_to_disk(&path, "inference", "ollama_url", json!(42)).unwrap_err(); + matches_type_mismatch(&err, "inference", "ollama_url"); } #[cfg(unix)] @@ -791,9 +766,9 @@ fn write_field_to_disk_propagates_io_error_when_parent_dir_is_readonly() { let err = write_field_to_disk( &path, - "search", - "searxng_url", - json!("http://10.0.0.1:25017"), + "inference", + "ollama_url", + json!("http://10.0.0.1:11434"), ) .unwrap_err(); @@ -805,152 +780,25 @@ fn write_field_to_disk_propagates_io_error_when_parent_dir_is_readonly() { matches!(err, ConfigError::IoError { .. }); } -// ─── write_provider_field_to_disk ─────────────────────────────────────────── - -#[test] -fn write_provider_field_patches_base_url_and_preserves_builtin() { - let dir = tempdir(); - let path = dir.join("config.toml"); - std::fs::write(&path, PROVIDERS_CONFIG).unwrap(); - - let resolved = - write_provider_field_to_disk(&path, "ollama", "base_url", "http://10.0.0.2:11434").unwrap(); - let ollama = resolved - .inference - .providers - .iter() - .find(|p| p.id == "ollama") - .unwrap(); - assert_eq!(ollama.base_url, "http://10.0.0.2:11434"); - assert!(resolved - .inference - .providers - .iter() - .any(|p| p.id == "builtin")); - - let on_disk = std::fs::read_to_string(&path).unwrap(); - assert!(on_disk.contains("http://10.0.0.2:11434")); -} - -#[test] -fn write_provider_field_patches_model() { - let dir = tempdir(); - let path = dir.join("config.toml"); - std::fs::write(&path, PROVIDERS_CONFIG).unwrap(); - - let resolved = write_provider_field_to_disk(&path, "ollama", "model", "llama3.1:8b").unwrap(); - let ollama = resolved - .inference - .providers - .iter() - .find(|p| p.id == "ollama") - .unwrap(); - assert_eq!(ollama.model, "llama3.1:8b"); -} - -#[test] -fn write_provider_field_rejects_unknown_field() { - let dir = tempdir(); - let path = dir.join("config.toml"); - std::fs::write(&path, PROVIDERS_CONFIG).unwrap(); - let err = write_provider_field_to_disk(&path, "ollama", "label", "x").unwrap_err(); - match err { - ConfigError::UnknownField { key, .. } => assert_eq!(key, "label"), - other => panic!("expected UnknownField, got {other:?}"), - } -} - -#[test] -fn write_provider_field_rejects_unknown_provider() { - let dir = tempdir(); - let path = dir.join("config.toml"); - std::fs::write(&path, PROVIDERS_CONFIG).unwrap(); - let err = write_provider_field_to_disk(&path, "ghost", "model", "x").unwrap_err(); - match err { - ConfigError::UnknownField { key, .. } => assert_eq!(key, "ghost"), - other => panic!("expected UnknownField, got {other:?}"), - } -} - -#[test] -fn write_provider_field_errors_when_no_providers_array() { - // SAMPLE_CONFIG is the pre-providers shape (no [[inference.providers]]). - let dir = tempdir(); - let path = dir.join("config.toml"); - std::fs::write(&path, SAMPLE_CONFIG).unwrap(); - let err = write_provider_field_to_disk(&path, "ollama", "base_url", "http://x").unwrap_err(); - match err { - ConfigError::UnknownSection { section } => assert_eq!(section, "inference.providers"), - other => panic!("expected UnknownSection, got {other:?}"), - } -} - -#[test] -fn write_provider_field_propagates_read_error_for_missing_file() { - let dir = tempdir(); - let path = dir.join("missing.toml"); - let err = write_provider_field_to_disk(&path, "ollama", "model", "x").unwrap_err(); - matches!(err, ConfigError::IoError { .. }); -} - -#[cfg(unix)] -#[test] -fn write_provider_field_propagates_io_error_when_parent_dir_is_readonly() { - use std::os::unix::fs::PermissionsExt; - let dir = tempdir(); - let path = dir.join("config.toml"); - std::fs::write(&path, PROVIDERS_CONFIG).unwrap(); - - // Read-only directory: the patch succeeds in memory but the atomic write - // cannot stage its temp file alongside the target. - let mut perms = std::fs::metadata(&dir).unwrap().permissions(); - perms.set_mode(0o500); - std::fs::set_permissions(&dir, perms.clone()).unwrap(); - - let err = write_provider_field_to_disk(&path, "ollama", "base_url", "http://10.0.0.2:11434") - .unwrap_err(); - - // Restore writability so the OS can clean up the tempdir later. - let mut restore = perms; - restore.set_mode(0o700); - std::fs::set_permissions(&dir, restore).unwrap(); - - matches!(err, ConfigError::IoError { .. }); -} - // ─── reset_section_on_disk ────────────────────────────────────────────────── #[test] fn reset_section_on_disk_replaces_named_section_with_defaults() { let dir = tempdir(); let path = dir.join("config.toml"); - // SAMPLE_CONFIG's [inference] is the legacy shape (ollama_url + available, - // no providers). Resetting the section must restore the new providers shape: - // active_provider + the built-in/Ollama array-of-tables. std::fs::write(&path, SAMPLE_CONFIG).unwrap(); - let resolved = reset_section_on_disk(&path, Some("inference")).unwrap(); - assert_eq!(resolved.inference.active_provider, "ollama"); - assert!(resolved - .inference - .providers - .iter() - .any(|p| p.id == "builtin")); - let ollama = resolved - .inference - .providers - .iter() - .find(|p| p.id == "ollama") - .unwrap(); - assert_eq!(ollama.base_url, "http://127.0.0.1:11434"); + // Mutate the field first so reset has something to revert. + write_field_to_disk( + &path, + "inference", + "ollama_url", + json!("http://10.0.0.1:11434"), + ) + .unwrap(); - // The reset wrote a `[[inference.providers]]` array-of-tables to disk that - // round-trips back through the loader. - let on_disk = std::fs::read_to_string(&path).unwrap(); - assert!( - on_disk.contains("[[inference.providers]]"), - "section reset must persist the providers array-of-tables: {on_disk}" - ); + let resolved = reset_section_on_disk(&path, Some("inference")).unwrap(); + assert_eq!(resolved.inference.ollama_url, "http://127.0.0.1:11434"); } #[test] @@ -959,7 +807,14 @@ fn reset_section_on_disk_preserves_other_sections() { let path = dir.join("config.toml"); std::fs::write(&path, SAMPLE_CONFIG).unwrap(); - // Change the search section, then reset only inference. + // Change two sections. + write_field_to_disk( + &path, + "inference", + "ollama_url", + json!("http://10.0.0.1:11434"), + ) + .unwrap(); write_field_to_disk(&path, "search", "max_iterations", json!(7)).unwrap(); // Reset only inference; search.max_iterations should still be 7. diff --git a/src-tauri/src/trace/mod.rs b/src-tauri/src/trace/mod.rs index ee6e51a8..fd034b0a 100644 --- a/src-tauri/src/trace/mod.rs +++ b/src-tauri/src/trace/mod.rs @@ -36,7 +36,7 @@ pub use registry::RegistryRecorder; /// `Arc` so call sites can emit events without /// threading the conversation id through every function signature. /// -/// Constructed by `commands::ask_model` and `search::search_pipeline` +/// Constructed by `commands::ask_ollama` and `search::search_pipeline` /// once at the start of each turn from managed state, then handed down /// through the streaming or pipeline machinery as /// `Arc`. Cheap to clone (single `Arc`). diff --git a/src-tauri/src/trace/registry.rs b/src-tauri/src/trace/registry.rs index 2ae40670..55148c61 100644 --- a/src-tauri/src/trace/registry.rs +++ b/src-tauri/src/trace/registry.rs @@ -19,7 +19,7 @@ //! The `Arc` returned from the registry can be cached by //! callers in their per-conversation context to skip the registry //! lookup entirely on hot paths (e.g., per-token `AssistantTokens` -//! emission). `commands::ask_model` does exactly this. +//! emission). `commands::ask_ollama` does exactly this. //! //! # Eviction and late-event tolerance //! @@ -80,7 +80,7 @@ impl RegistryRecorder { /// Returns the recorder for `(domain, conversation_id)`, creating /// it lazily if needed. Public for hot-path callers (e.g. - /// `commands::ask_model`) that want to cache the `Arc` once and + /// `commands::ask_ollama`) that want to cache the `Arc` once and /// skip the registry lookup on every subsequent emit. /// /// Equivalent to a `record()` of `()`: read-locks the map, returns @@ -359,7 +359,7 @@ mod tests { #[test] fn recorder_for_can_be_cached_for_hot_path() { // Simulates the per-streaming-task caching pattern that - // `commands::ask_model` uses to bypass per-token registry + // `commands::ask_ollama` uses to bypass per-token registry // lookup. let root = fresh_dir(); let reg = RegistryRecorder::new(&root); diff --git a/src-tauri/src/warmup.rs b/src-tauri/src/warmup.rs index 7eea6050..d1b5a6a9 100644 --- a/src-tauri/src/warmup.rs +++ b/src-tauri/src/warmup.rs @@ -157,9 +157,7 @@ pub fn warm_up_model( let cfg = config.read(); let endpoint = format!( "{}/api/chat", - cfg.inference - .active_provider_base_url() - .trim_end_matches('/') + cfg.inference.ollama_url.trim_end_matches('/') ); let system_prompt = cfg.prompt.resolved_system.clone(); let keep_alive = if cfg.inference.keep_warm_inactivity_minutes == 0 { @@ -229,11 +227,7 @@ pub async fn get_loaded_model( if let Some(model) = model { let endpoint = format!( "{}/api/ps", - config - .read() - .inference - .active_provider_base_url() - .trim_end_matches('/') + config.read().inference.ollama_url.trim_end_matches('/') ); get_loaded_model_request(&endpoint, &model, client.inner()).await } else { @@ -282,11 +276,7 @@ pub async fn evict_model( if let Some(model) = model { let endpoint = format!( "{}/api/generate", - config - .read() - .inference - .active_provider_base_url() - .trim_end_matches('/') + config.read().inference.ollama_url.trim_end_matches('/') ); evict_model_request(&endpoint, &model, client.inner()).await?; // Suppress any in-flight warmup callback so a slow warmup that @@ -332,7 +322,7 @@ pub fn spawn_vram_poller(app_handle: tauri::AppHandle) { .state::>() .read() .inference - .active_provider_base_url() + .ollama_url .trim_end_matches('/') ); let client = app_handle.state::().inner().clone(); diff --git a/src/App.tsx b/src/App.tsx index 2ff79701..bfee5862 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,8 +17,8 @@ import { availableMonitors, } from '@tauri-apps/api/window'; import { LogicalSize } from '@tauri-apps/api/dpi'; -import { useModel } from './hooks/useModel'; -import type { Message } from './hooks/useModel'; +import { useOllama } from './hooks/useOllama'; +import type { Message } from './hooks/useOllama'; import { useConversationHistory } from './hooks/useConversationHistory'; import { useModelSelection } from './hooks/useModelSelection'; import { useModelCapabilities } from './hooks/useModelCapabilities'; @@ -486,7 +486,7 @@ function App() { /** * Persist a completed user/assistant turn to SQLite if the conversation - * has been saved. Passed as `onTurnComplete` to `useModel`. When + * has been saved. Passed as `onTurnComplete` to `useOllama`. When * auto-replace is enabled and the turn was a `/rewrite` or `/refine` over a * selection, dismiss the overlay and write the result back into the source * app (the same flow as the manual Replace button). @@ -518,7 +518,7 @@ function App() { loadMessages, getTraceConversationId, addOcrTurn, - } = useModel(activeModel, handleTurnComplete); + } = useOllama(activeModel, handleTurnComplete); /** * Mirror of `messages` as a ref so export handlers (and any future diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 7f2840ae..29479401 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -930,7 +930,7 @@ describe('App', () => { fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); }); - // Wait for invoke to be called (ask_model) + // Wait for invoke to be called (ask_ollama) await act(async () => {}); // Simulate streaming tokens @@ -1033,8 +1033,8 @@ describe('App', () => { await act(async () => {}); - // ask_model should NOT have been called - expect(invoke).not.toHaveBeenCalledWith('ask_model', expect.anything()); + // ask_ollama should NOT have been called + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); }); it('lets the user keep drafting while a response streams, without sending', async () => { @@ -1052,7 +1052,7 @@ describe('App', () => { await act(async () => {}); const askCalls = () => - vi.mocked(invoke).mock.calls.filter((c) => c[0] === 'ask_model').length; + vi.mocked(invoke).mock.calls.filter((c) => c[0] === 'ask_ollama').length; expect(askCalls()).toBe(1); // While streaming, the composer (now in chat mode) stays editable. @@ -1178,7 +1178,7 @@ describe('App', () => { // Backend receives the message and quoted text separately expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: 'my question', quotedText: 'selected snippet', @@ -2939,9 +2939,9 @@ describe('App', () => { }); await act(async () => {}); - // ask_model should be called with imagePaths + // ask_ollama should be called with imagePaths expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: 'describe this', imagePaths: ['/tmp/staged/img1.jpg'], @@ -2980,9 +2980,9 @@ describe('App', () => { }); await act(async () => {}); - // ask_model should be called with empty message but imagePaths + // ask_ollama should be called with empty message but imagePaths expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: '', imagePaths: ['/tmp/staged/img1.jpg'], @@ -3635,7 +3635,7 @@ describe('App', () => { ollamaReachable: true, }; if (args && 'onEvent' in args) { - // Accept channel for ask_model + // Accept channel for ask_ollama } if (cmd === 'save_image_command') { const p = new Promise((resolve) => { @@ -3786,10 +3786,10 @@ describe('App', () => { }); const calls = invoke.mock.calls.filter( - (c) => c[0] === 'ask_model' || c[0] === 'search_pipeline', + (c) => c[0] === 'ask_ollama' || c[0] === 'search_pipeline', ); const last = calls[calls.length - 1]; - expect(last[0]).toBe('ask_model'); + expect(last[0]).toBe('ask_ollama'); expect(last[1]).toMatchObject({ message: 'hello' }); }); @@ -3863,8 +3863,8 @@ describe('App', () => { screen.getByRole('list', { name: /attached images/i }), ).toBeInTheDocument(); - // ask_model should never have been called - expect(invoke).not.toHaveBeenCalledWith('ask_model', expect.anything()); + // ask_ollama should never have been called + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); }); it('waits for all images before firing deferred submit', async () => { @@ -4025,8 +4025,8 @@ describe('App', () => { ).toBeNull(); }); - // ask_model should never have been called - expect(invoke).not.toHaveBeenCalledWith('ask_model', expect.anything()); + // ask_ollama should never have been called + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); // The "Processing images" button should be gone - back to normal send expect( @@ -4136,9 +4136,9 @@ describe('App', () => { expect(screen.getByTestId('capability-mismatch-strip')).toHaveTextContent( 'llama3 reads text only', ); - // ask_model is NOT invoked. + // ask_ollama is NOT invoked. const askInvocations = invoke.mock.calls.filter( - (call) => call[0] === 'ask_model', + (call) => call[0] === 'ask_ollama', ); expect(askInvocations.length).toBe(0); // Compose state survives. @@ -4430,7 +4430,7 @@ describe('App', () => { expect.objectContaining({ conversationId: expect.any(String) }), ); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ imagePaths: ['/tmp/screen.jpg'], message: '/screen', @@ -4459,7 +4459,7 @@ describe('App', () => { await act(async () => {}); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: '/screen what is this error?', imagePaths: ['/tmp/screen.jpg'], @@ -4492,7 +4492,7 @@ describe('App', () => { expect.objectContaining({ conversationId: expect.any(String) }), ); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: 'hello /screen there', imagePaths: ['/tmp/screen.jpg'], @@ -4534,7 +4534,7 @@ describe('App', () => { 'capture_full_screen_command', expect.objectContaining({ conversationId: expect.any(String) }), ); - expect(invoke).not.toHaveBeenCalledWith('ask_model', expect.anything()); + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); // The actual Rust error message is surfaced directly. expect(screen.getByText('Permission denied')).toBeInTheDocument(); }); @@ -4695,7 +4695,7 @@ describe('App', () => { expect.objectContaining({ conversationId: expect.any(String) }), ); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: '/screen describe', imagePaths: ['/tmp/attached.jpg', '/tmp/screen.jpg'], @@ -4737,7 +4737,7 @@ describe('App', () => { await act(async () => {}); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: '/screen explain', quotedText: 'some context', @@ -4777,9 +4777,9 @@ describe('App', () => { }); await act(async () => {}); - // After capture resolves: ask_model should be called + // After capture resolves: ask_ollama should be called expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: '/screen check this' }), ); }); @@ -4820,7 +4820,7 @@ describe('App', () => { it('defers /screen submit when an attached image is still processing and runs once it resolves', async () => { // Regression guard: submitting /screen with a still-processing image - // used to drop the image silently and ask_model was called with only + // used to drop the image silently and ask_ollama was called with only // the screenshot. The unified pre-flight gate now defers the submit // until every attached image has a resolved filePath, so both paths // make it into the request. @@ -4862,7 +4862,7 @@ describe('App', () => { 'capture_full_screen_command', expect.anything(), ); - expect(invoke).not.toHaveBeenCalledWith('ask_model', expect.anything()); + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); // Resolve the image; the deferred /screen submit fires. act(() => { @@ -4880,7 +4880,7 @@ describe('App', () => { }); await vi.waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ imagePaths: ['/tmp/staged/img1.jpg', '/tmp/screen.jpg'], }), @@ -4922,8 +4922,8 @@ describe('App', () => { }); await act(async () => {}); - // ask_model must NOT be called since the user cancelled - expect(invoke).not.toHaveBeenCalledWith('ask_model', expect.anything()); + // ask_ollama must NOT be called since the user cancelled + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); }); it('/screen combined with utility command applies the prompt template via OCR', async () => { @@ -4954,7 +4954,7 @@ describe('App', () => { await vi.waitFor(() => { const askCall = vi .mocked(invoke) - .mock.calls.find((c) => c[0] === 'ask_model'); + .mock.calls.find((c) => c[0] === 'ask_ollama'); expect(askCall).toBeDefined(); const args = askCall![1] as Record; expect(args.message).toContain('Explain the following in plain'); @@ -4991,7 +4991,7 @@ describe('App', () => { await vi.waitFor(() => { const askCall = vi .mocked(invoke) - .mock.calls.find((c) => c[0] === 'ask_model'); + .mock.calls.find((c) => c[0] === 'ask_ollama'); expect(askCall).toBeDefined(); const args = askCall![1] as Record; expect(args.message).toContain('Explain the following in plain'); @@ -5024,7 +5024,7 @@ describe('App', () => { await vi.waitFor(() => { const askCall = vi .mocked(invoke) - .mock.calls.find((c) => c[0] === 'ask_model'); + .mock.calls.find((c) => c[0] === 'ask_ollama'); expect(askCall).toBeDefined(); const args = askCall![1] as Record; // No template applied: raw message sent @@ -5061,7 +5061,7 @@ describe('App', () => { await vi.waitFor(() => { const askCall = vi .mocked(invoke) - .mock.calls.find((c) => c[0] === 'ask_model'); + .mock.calls.find((c) => c[0] === 'ask_ollama'); expect(askCall).toBeDefined(); const args = askCall![1] as Record; expect(args.message).toContain('Explain the following in plain'); @@ -5094,7 +5094,7 @@ describe('App', () => { await vi.waitFor(() => { const askCall = vi .mocked(invoke) - .mock.calls.find((c) => c[0] === 'ask_model'); + .mock.calls.find((c) => c[0] === 'ask_ollama'); expect(askCall).toBeDefined(); const args = askCall![1] as Record; expect(args.message).toContain('Target language: Vietnamese'); @@ -5143,7 +5143,7 @@ describe('App', () => { 'extract_text_command', expect.anything(), ); - expect(invoke).not.toHaveBeenCalledWith('ask_model', expect.anything()); + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); expect( screen.getByText( 'Attach an image or add /screen to extract text from.', @@ -5185,7 +5185,7 @@ describe('App', () => { expect(invoke).toHaveBeenCalledWith('extract_text_command', { imagePaths: ['/tmp/staged/img1.jpg'], }); - expect(invoke).not.toHaveBeenCalledWith('ask_model', expect.anything()); + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); await vi.waitFor(() => { expect(screen.getByText(/Hello World/)).toBeInTheDocument(); }); @@ -5226,7 +5226,7 @@ describe('App', () => { invoke.mockImplementation( async (cmd: string, args?: Record) => { if (args && 'onEvent' in args) { - // channel capture - no-op; we only verify ask_model was called + // channel capture - no-op; we only verify ask_ollama was called } if (cmd === 'get_model_picker_state') return { @@ -5263,7 +5263,7 @@ describe('App', () => { await act(async () => {}); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: expect.stringContaining('Extract all text'), imagePaths: ['/tmp/screen.jpg'], @@ -5311,7 +5311,7 @@ describe('App', () => { }); await act(async () => {}); - expect(invoke).not.toHaveBeenCalledWith('ask_model', expect.anything()); + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); await vi.waitFor(() => { expect( screen.getByText(/OCR failed: OCR error text/), @@ -5727,7 +5727,7 @@ describe('App', () => { await act(async () => {}); // No vision capability → shows error rather than falling back to Ollama. - expect(invoke).not.toHaveBeenCalledWith('ask_model', expect.anything()); + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); await vi.waitFor(() => { expect(screen.getByText(/OCR failed/)).toBeInTheDocument(); }); @@ -5737,7 +5737,7 @@ describe('App', () => { // ─── /think command ───────────────────────────────────────────────────────── describe('/think command', () => { - it('sends think:true to ask_model and keeps /think prefix in message', async () => { + it('sends think:true to ask_ollama and keeps /think prefix in message', async () => { enableChannelCapture(); render(); @@ -5756,7 +5756,7 @@ describe('App', () => { await act(async () => {}); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: '/think why is the sky blue?', think: true, @@ -5822,7 +5822,7 @@ describe('App', () => { await act(async () => {}); - expect(invoke).not.toHaveBeenCalledWith('ask_model', expect.anything()); + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); }); it('detects /think anywhere in the message, not just at start', async () => { @@ -5844,7 +5844,7 @@ describe('App', () => { await act(async () => {}); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: 'hello /think world', think: true, @@ -5871,7 +5871,7 @@ describe('App', () => { await act(async () => {}); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: '/think explain this code', quotedText: 'some selected text', @@ -5899,7 +5899,7 @@ describe('App', () => { await act(async () => {}); // "/think " with only a space after prefix, no actual query, no images => no submit - expect(invoke).not.toHaveBeenCalledWith('ask_model', expect.anything()); + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); }); }); @@ -5931,7 +5931,7 @@ describe('App', () => { expect.objectContaining({ conversationId: expect.any(String) }), ); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: '/screen /think explain this', imagePaths: ['/tmp/screen.jpg'], @@ -5965,7 +5965,7 @@ describe('App', () => { expect.objectContaining({ conversationId: expect.any(String) }), ); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: '/think /screen explain this', imagePaths: ['/tmp/screen.jpg'], @@ -5978,7 +5978,7 @@ describe('App', () => { // ─── Utility commands ─────────────────────────────────────────────────────── describe('Utility commands (buildPrompt routing)', () => { - it('routes /rewrite command through buildPrompt and calls ask_model with composed prompt', async () => { + it('routes /rewrite command through buildPrompt and calls ask_ollama with composed prompt', async () => { enableChannelCapture(); render(); @@ -5999,7 +5999,7 @@ describe('App', () => { await vi.waitFor(() => { const askCall = vi .mocked(invoke) - .mock.calls.find((c) => c[0] === 'ask_model'); + .mock.calls.find((c) => c[0] === 'ask_ollama'); expect(askCall).toBeDefined(); const args = askCall![1] as Record; expect(args.message).toContain( @@ -6030,7 +6030,7 @@ describe('App', () => { await vi.waitFor(() => { const askCall = vi .mocked(invoke) - .mock.calls.find((c) => c[0] === 'ask_model'); + .mock.calls.find((c) => c[0] === 'ask_ollama'); expect(askCall).toBeDefined(); const args = askCall![1] as Record; expect(args.message).toContain('Target language: jpn'); @@ -6059,7 +6059,7 @@ describe('App', () => { await vi.waitFor(() => { const askCall = vi .mocked(invoke) - .mock.calls.find((c) => c[0] === 'ask_model'); + .mock.calls.find((c) => c[0] === 'ask_ollama'); expect(askCall).toBeDefined(); const args = askCall![1] as Record; expect(args.message).toContain('Summarize the following text'); @@ -6086,7 +6086,7 @@ describe('App', () => { await act(async () => {}); - expect(invoke).not.toHaveBeenCalledWith('ask_model', expect.anything()); + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); await vi.waitFor(() => { expect( screen.getByText('Provide text or attach an image to use /rewrite.'), @@ -6144,7 +6144,7 @@ describe('App', () => { await vi.waitFor(() => { const askCall = vi .mocked(invoke) - .mock.calls.find((c) => c[0] === 'ask_model'); + .mock.calls.find((c) => c[0] === 'ask_ollama'); expect(askCall).toBeDefined(); const args = askCall![1] as Record; expect(args.message).toContain('Explain the following in plain'); @@ -6153,7 +6153,7 @@ describe('App', () => { }); }); - it('/translate with only an image and no text does not call ask_model', async () => { + it('/translate with only an image and no text does not call ask_ollama', async () => { // /translate needs a language code from typed text; image fallback is skipped for it. enableChannelCaptureWithResponses({ save_image_command: '/tmp/staged/img.jpg', @@ -6193,7 +6193,7 @@ describe('App', () => { }); await act(async () => {}); - expect(invoke).not.toHaveBeenCalledWith('ask_model', expect.anything()); + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); }); it('utility command with only a language code (no text) shakes and shows error', async () => { @@ -6216,7 +6216,7 @@ describe('App', () => { await act(async () => {}); - expect(invoke).not.toHaveBeenCalledWith('ask_model', expect.anything()); + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); await vi.waitFor(() => { expect( screen.getByText( @@ -6250,7 +6250,7 @@ describe('App', () => { await vi.waitFor(() => { const askCall = vi .mocked(invoke) - .mock.calls.find((c) => c[0] === 'ask_model'); + .mock.calls.find((c) => c[0] === 'ask_ollama'); expect(askCall).toBeDefined(); const args = askCall![1] as Record; expect(args.message).toContain( @@ -6312,7 +6312,7 @@ describe('App', () => { }); const askCall = vi .mocked(invoke) - .mock.calls.find((c) => c[0] === 'ask_model'); + .mock.calls.find((c) => c[0] === 'ask_ollama'); expect(askCall).toBeDefined(); const args = askCall![1] as Record; // OCR text is $INPUT; selectedContext used as quotedText display @@ -6397,7 +6397,7 @@ describe('App', () => { }); const askCall = vi .mocked(invoke) - .mock.calls.find((c) => c[0] === 'ask_model'); + .mock.calls.find((c) => c[0] === 'ask_ollama'); expect(askCall).toBeDefined(); const args = askCall![1] as Record; expect(args.message).toContain( @@ -6427,7 +6427,7 @@ describe('App', () => { ollamaReachable: true, }; if (args && 'onEvent' in args) { - // Accept channel for ask_model + // Accept channel for ask_ollama } if (cmd === 'save_image_command') { return new Promise((resolve) => { @@ -6514,7 +6514,7 @@ describe('App', () => { ollamaReachable: true, }; if (args && 'onEvent' in args) { - // Accept channel for ask_model + // Accept channel for ask_ollama } if (cmd === 'save_image_command') { const p = new Promise((resolve) => { @@ -6594,7 +6594,7 @@ describe('App', () => { }); } - it('/tldr with attached image: OCR then ask_model with tldr prompt and no image paths', async () => { + it('/tldr with attached image: OCR then ask_ollama with tldr prompt and no image paths', async () => { enableChannelCaptureWithResponses({ save_image_command: '/tmp/staged/img1.jpg', extract_text_command: 'Some article text here', @@ -6632,7 +6632,7 @@ describe('App', () => { await vi.waitFor(() => { const askCall = vi .mocked(invoke) - .mock.calls.find((c) => c[0] === 'ask_model'); + .mock.calls.find((c) => c[0] === 'ask_ollama'); expect(askCall).toBeDefined(); const args = askCall![1] as Record; expect(args.message).toContain('Summarize the following text'); @@ -6675,7 +6675,7 @@ describe('App', () => { await vi.waitFor(() => { const askCall = vi .mocked(invoke) - .mock.calls.find((c) => c[0] === 'ask_model'); + .mock.calls.find((c) => c[0] === 'ask_ollama'); expect(askCall).toBeDefined(); const args = askCall![1] as Record; expect(args.message).toContain('Target language: french'); @@ -6684,7 +6684,7 @@ describe('App', () => { }); }); - it('/screen /tldr: capture then OCR then ask_model with no image paths', async () => { + it('/screen /tldr: capture then OCR then ask_ollama with no image paths', async () => { enableChannelCaptureWithResponses({ capture_full_screen_command: '/tmp/screen.jpg', extract_text_command: 'Screen article text', @@ -6714,7 +6714,7 @@ describe('App', () => { await vi.waitFor(() => { const askCall = vi .mocked(invoke) - .mock.calls.find((c) => c[0] === 'ask_model'); + .mock.calls.find((c) => c[0] === 'ask_ollama'); expect(askCall).toBeDefined(); const args = askCall![1] as Record; expect(args.message).toContain('Summarize the following text'); @@ -6723,7 +6723,7 @@ describe('App', () => { }); }); - it('shows captureError and does not call ask_model when OCR returns [No text detected]', async () => { + it('shows captureError and does not call ask_ollama when OCR returns [No text detected]', async () => { enableChannelCaptureWithResponses({ save_image_command: '/tmp/staged/img1.jpg', extract_text_command: '[No text detected]', @@ -6754,7 +6754,7 @@ describe('App', () => { }); await act(async () => {}); - expect(invoke).not.toHaveBeenCalledWith('ask_model', expect.anything()); + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); await vi.waitFor(() => { expect( screen.getByText('No readable text found in the image.'), @@ -6814,7 +6814,7 @@ describe('App', () => { }); await act(async () => {}); - expect(invoke).not.toHaveBeenCalledWith('ask_model', expect.anything()); + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); await vi.waitFor(() => { expect( screen.getByText('OCR failed: OCR engine failed'), @@ -6929,7 +6929,7 @@ describe('App', () => { 'extract_text_command', expect.anything(), ); - expect(invoke).not.toHaveBeenCalledWith('ask_model', expect.anything()); + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); }); it('/screen /tldr shows captureError and restores input when capture_full_screen_command throws', async () => { @@ -6968,7 +6968,7 @@ describe('App', () => { 'extract_text_command', expect.anything(), ); - expect(invoke).not.toHaveBeenCalledWith('ask_model', expect.anything()); + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); await vi.waitFor(() => { expect(screen.getByText('Screen capture denied')).toBeInTheDocument(); }); @@ -7008,7 +7008,7 @@ describe('App', () => { await vi.waitFor(() => { const askCall = vi .mocked(invoke) - .mock.calls.find((c) => c[0] === 'ask_model'); + .mock.calls.find((c) => c[0] === 'ask_ollama'); expect(askCall).toBeDefined(); const args = askCall![1] as Record; expect(args.message).toContain('Target language: Vietnamese'); @@ -7040,7 +7040,7 @@ describe('App', () => { await vi.waitFor(() => { const askCall = vi .mocked(invoke) - .mock.calls.find((c) => c[0] === 'ask_model'); + .mock.calls.find((c) => c[0] === 'ask_ollama'); expect(askCall).toBeDefined(); const args = askCall![1] as Record; expect(args.message).toContain('Summarize the following text'); @@ -7362,7 +7362,7 @@ describe('App', () => { }); }); - it('drops searchActive after a final Token+Done turn so the next submit uses ask_model', async () => { + it('drops searchActive after a final Token+Done turn so the next submit uses ask_ollama', async () => { enableChannelCapture(); render(); await act(async () => {}); @@ -7395,10 +7395,10 @@ describe('App', () => { }); const calls = invoke.mock.calls.filter( - (c) => c[0] === 'ask_model' || c[0] === 'search_pipeline', + (c) => c[0] === 'ask_ollama' || c[0] === 'search_pipeline', ); const last = calls[calls.length - 1]; - expect(last[0]).toBe('ask_model'); + expect(last[0]).toBe('ask_ollama'); expect(last[1]).toMatchObject({ message: 'hello' }); }); diff --git a/src/components/ChatBubble.tsx b/src/components/ChatBubble.tsx index 11749565..b70caf20 100644 --- a/src/components/ChatBubble.tsx +++ b/src/components/ChatBubble.tsx @@ -11,7 +11,7 @@ import { formatQuotedText } from '../utils/formatQuote'; import { useConfig } from '../contexts/ConfigContext'; import { COMMANDS, SCREEN_CAPTURE_PLACEHOLDER } from '../config/commands'; import { SearchWarningIcon } from './SearchWarningIcon'; -import type { EngineErrorKind } from '../hooks/useModel'; +import type { OllamaErrorKind } from '../hooks/useOllama'; import type { SearchResultPreview, SearchTraceStep, @@ -238,7 +238,7 @@ interface ChatBubbleProps { /** Whether this bubble is actively streaming content from the LLM. */ isStreaming?: boolean; /** When set, renders an ErrorCard callout instead of markdown. */ - errorKind?: EngineErrorKind; + errorKind?: OllamaErrorKind; /** Accumulated thinking/reasoning content from the model, if thinking mode was used. */ thinkingContent?: string; /** Whether a `/think` turn is waiting for the first thinking tokens. */ diff --git a/src/components/ErrorCard.tsx b/src/components/ErrorCard.tsx index 29a7dd4d..a09d456c 100644 --- a/src/components/ErrorCard.tsx +++ b/src/components/ErrorCard.tsx @@ -1,12 +1,16 @@ -import type { EngineErrorKind } from '../hooks/useModel'; +export type OllamaErrorKind = + | 'NotRunning' + | 'ModelNotFound' + | 'NoModelSelected' + | 'Other'; interface ErrorCardProps { - kind: EngineErrorKind; + kind: OllamaErrorKind; message: string; } -const barColors: Record = { - EngineUnreachable: '#ef4444', +const barColors: Record = { + NotRunning: '#ef4444', ModelNotFound: '#f59e0b', // Same accent as ModelNotFound: this is a configuration/setup nudge, // not a daemon failure, so the warning hue (amber) is the right read. diff --git a/src/components/__tests__/ChatBubble.test.tsx b/src/components/__tests__/ChatBubble.test.tsx index 555dc306..27a112cf 100644 --- a/src/components/__tests__/ChatBubble.test.tsx +++ b/src/components/__tests__/ChatBubble.test.tsx @@ -439,7 +439,7 @@ describe('ChatBubble', () => { role="assistant" content={"Ollama isn't running\nStart Ollama and try again."} index={0} - errorKind="EngineUnreachable" + errorKind="NotRunning" />, ); expect(container.querySelector('[data-error-bar]')).not.toBeNull(); @@ -451,7 +451,7 @@ describe('ChatBubble', () => { role="assistant" content={"Ollama isn't running\nStart Ollama and try again."} index={0} - errorKind="EngineUnreachable" + errorKind="NotRunning" />, ); // MarkdownRenderer would produce a

or streamdown elements; ErrorCard does not diff --git a/src/components/__tests__/ErrorCard.test.tsx b/src/components/__tests__/ErrorCard.test.tsx index 3b083f6f..22a1eacd 100644 --- a/src/components/__tests__/ErrorCard.test.tsx +++ b/src/components/__tests__/ErrorCard.test.tsx @@ -6,7 +6,7 @@ describe('ErrorCard', () => { it('renders the title (first line of message)', () => { render( , ); @@ -16,7 +16,7 @@ describe('ErrorCard', () => { it('renders the subtitle (second line of message)', () => { render( , ); @@ -28,16 +28,16 @@ describe('ErrorCard', () => { expect(screen.getByText('Something went wrong')).toBeInTheDocument(); }); - it('applies red accent bar for EngineUnreachable', () => { + it('applies red accent bar for NotRunning', () => { const { container } = render( , ); const bar = container.querySelector('[data-error-bar]'); expect(bar).not.toBeNull(); - expect(bar?.getAttribute('data-kind')).toBe('EngineUnreachable'); + expect(bar?.getAttribute('data-kind')).toBe('NotRunning'); }); it('applies amber accent bar for ModelNotFound', () => { diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index f8178a43..593b16c6 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -36,17 +36,10 @@ const CONFIG_UPDATED_EVENT = 'thuki://config-updated'; */ const OVERLAY_VISIBILITY_EVENT = 'thuki://visibility'; -/** Shape returned by the Rust `get_config` command (snake_case). Only the - * fields the runtime tree consumes are declared. */ -interface RawProvider { - id: string; - kind: string; - base_url: string; -} +/** Shape returned by the Rust `get_config` command (snake_case). */ interface RawAppConfig { inference: { - active_provider: string; - providers: RawProvider[]; + ollama_url: string; }; prompt: { system: string; @@ -74,9 +67,6 @@ interface RawAppConfig { /** Camel-cased, frontend-friendly view of the configuration. */ export interface AppConfig { inference: { - /** Id of the active provider (e.g. `'ollama'`). */ - activeProvider: string; - /** Base URL of the Ollama provider, derived from the providers list. */ ollamaUrl: string; }; prompt: { @@ -105,20 +95,10 @@ export interface AppConfig { }; } -/** Derives the Ollama provider's base URL from the providers list. Empty when - * no Ollama provider is configured (the loader always seeds one, so this only - * falls back in test contexts with a partial list). */ -function ollamaBaseUrl(raw: RawAppConfig): string { - return ( - raw.inference.providers.find((p) => p.kind === 'ollama')?.base_url ?? '' - ); -} - function transform(raw: RawAppConfig): AppConfig { return { inference: { - activeProvider: raw.inference.active_provider, - ollamaUrl: ollamaBaseUrl(raw), + ollamaUrl: raw.inference.ollama_url, }, prompt: { system: raw.prompt.system, @@ -263,7 +243,6 @@ export function ConfigProviderForTest({ */ export const DEFAULT_CONFIG: AppConfig = { inference: { - activeProvider: 'ollama', ollamaUrl: 'http://127.0.0.1:11434', }, prompt: { system: '' }, diff --git a/src/contexts/__tests__/ConfigContext.test.tsx b/src/contexts/__tests__/ConfigContext.test.tsx index 55a015a0..2ad9c090 100644 --- a/src/contexts/__tests__/ConfigContext.test.tsx +++ b/src/contexts/__tests__/ConfigContext.test.tsx @@ -64,7 +64,6 @@ describe('ConfigContext', () => { const custom: AppConfig = { ...DEFAULT_CONFIG, inference: { - activeProvider: 'ollama', ollamaUrl: 'http://example.test:11434', }, }; @@ -83,14 +82,7 @@ describe('ConfigContext', () => { it('hydrates from the backend and transforms snake_case to camelCase', async () => { invoke.mockResolvedValueOnce({ inference: { - active_provider: 'ollama', - providers: [ - { - id: 'ollama', - kind: 'ollama', - base_url: 'http://127.0.0.1:11434', - }, - ], + ollama_url: 'http://127.0.0.1:11434', }, prompt: { system: 'custom base prompt' }, window: { @@ -142,40 +134,6 @@ describe('ConfigContext', () => { expect(screen.getByTestId('auto-close').textContent).toBe('true'); }); - it('derives an empty Ollama URL when no Ollama provider is configured', async () => { - invoke.mockResolvedValueOnce({ - inference: { - active_provider: 'builtin', - providers: [{ id: 'builtin', kind: 'builtin', base_url: '' }], - }, - prompt: { system: '' }, - window: { - overlay_width: 600, - max_chat_height: 648, - max_images: 3, - text_base_px: 15, - text_line_height: 1.5, - text_letter_spacing_px: 0, - text_font_weight: 500, - }, - quote: { - max_display_lines: 4, - max_display_chars: 300, - max_context_length: 4096, - }, - behavior: { auto_replace: false, auto_close: false }, - }); - - render( - - - , - ); - await act(async () => {}); - - expect(screen.getByTestId('ollama-url').textContent).toBe(''); - }); - it('falls back to DEFAULT_CONFIG when invoke returns nullish', async () => { invoke.mockResolvedValueOnce(undefined); @@ -226,16 +184,7 @@ describe('ConfigContext', () => { it('refetches and updates state when thuki://config-updated fires', async () => { const initial = { - inference: { - active_provider: 'ollama', - providers: [ - { - id: 'ollama', - kind: 'ollama', - base_url: 'http://127.0.0.1:11434', - }, - ], - }, + inference: { ollama_url: 'http://127.0.0.1:11434' }, prompt: { system: '' }, window: { overlay_width: 600, @@ -279,16 +228,7 @@ describe('ConfigContext', () => { it('refetches config when the overlay shows', async () => { const initial = { - inference: { - active_provider: 'ollama', - providers: [ - { - id: 'ollama', - kind: 'ollama', - base_url: 'http://127.0.0.1:11434', - }, - ], - }, + inference: { ollama_url: 'http://127.0.0.1:11434' }, prompt: { system: '' }, window: { overlay_width: 600, max_chat_height: 648, max_images: 3 }, quote: { @@ -321,16 +261,7 @@ describe('ConfigContext', () => { it('does not refetch on non-show or payloadless visibility events', async () => { const initial = { - inference: { - active_provider: 'ollama', - providers: [ - { - id: 'ollama', - kind: 'ollama', - base_url: 'http://127.0.0.1:11434', - }, - ], - }, + inference: { ollama_url: 'http://127.0.0.1:11434' }, prompt: { system: '' }, window: { overlay_width: 600, max_chat_height: 648, max_images: 3 }, quote: { @@ -362,16 +293,7 @@ describe('ConfigContext', () => { it('keeps last good config when a refresh invoke rejects', async () => { const initial = { - inference: { - active_provider: 'ollama', - providers: [ - { - id: 'ollama', - kind: 'ollama', - base_url: 'http://127.0.0.1:11434', - }, - ], - }, + inference: { ollama_url: 'http://127.0.0.1:11434' }, prompt: { system: 'p' }, window: { overlay_width: 700, @@ -415,16 +337,7 @@ describe('ConfigContext', () => { it('unsubscribes on unmount', async () => { invoke.mockResolvedValue({ - inference: { - active_provider: 'ollama', - providers: [ - { - id: 'ollama', - kind: 'ollama', - base_url: 'http://127.0.0.1:11434', - }, - ], - }, + inference: { ollama_url: 'http://127.0.0.1:11434' }, prompt: { system: '' }, window: { overlay_width: 600, @@ -458,16 +371,7 @@ describe('ConfigContext', () => { it('survives a listen() rejection without crashing initial hydrate', async () => { listen.mockRejectedValueOnce(new Error('event bridge missing')); invoke.mockResolvedValueOnce({ - inference: { - active_provider: 'ollama', - providers: [ - { - id: 'ollama', - kind: 'ollama', - base_url: 'http://127.0.0.1:11434', - }, - ], - }, + inference: { ollama_url: 'http://127.0.0.1:11434' }, prompt: { system: '' }, window: { overlay_width: 600, @@ -512,16 +416,7 @@ describe('ConfigContext', () => { // No assertion on output (provider gone); the run is the coverage signal. await act(async () => { resolveInvoke!({ - inference: { - active_provider: 'ollama', - providers: [ - { - id: 'ollama', - kind: 'ollama', - base_url: 'http://127.0.0.1:11434', - }, - ], - }, + inference: { ollama_url: 'http://127.0.0.1:11434' }, prompt: { system: '' }, window: { overlay_width: 600, @@ -566,16 +461,7 @@ describe('ConfigContext', () => { }), ); invoke.mockResolvedValueOnce({ - inference: { - active_provider: 'ollama', - providers: [ - { - id: 'ollama', - kind: 'ollama', - base_url: 'http://127.0.0.1:11434', - }, - ], - }, + inference: { ollama_url: 'http://127.0.0.1:11434' }, prompt: { system: '' }, window: { overlay_width: 600, diff --git a/src/hooks/__tests__/useConversationHistory.test.tsx b/src/hooks/__tests__/useConversationHistory.test.tsx index 60d85b96..df688016 100644 --- a/src/hooks/__tests__/useConversationHistory.test.tsx +++ b/src/hooks/__tests__/useConversationHistory.test.tsx @@ -2,7 +2,7 @@ import { renderHook, act } from '@testing-library/react'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { useConversationHistory } from '../useConversationHistory'; import { invoke } from '../../testUtils/mocks/tauri'; -import type { Message } from '../useModel'; +import type { Message } from '../useOllama'; const MODEL = 'gemma4:e2b'; diff --git a/src/hooks/__tests__/useModel.test.tsx b/src/hooks/__tests__/useOllama.test.tsx similarity index 92% rename from src/hooks/__tests__/useModel.test.tsx rename to src/hooks/__tests__/useOllama.test.tsx index 78767644..3083b801 100644 --- a/src/hooks/__tests__/useModel.test.tsx +++ b/src/hooks/__tests__/useOllama.test.tsx @@ -1,6 +1,6 @@ import { renderHook, act } from '@testing-library/react'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { ignoreTraceIpcError, useModel } from '../useModel'; +import { ignoreTraceIpcError, useOllama } from '../useOllama'; import { invoke, enableChannelCapture, @@ -26,7 +26,7 @@ describe('ignoreTraceIpcError', () => { }); }); -describe('useModel', () => { +describe('useOllama', () => { beforeEach(() => { invoke.mockClear(); enableChannelCapture(); @@ -37,14 +37,14 @@ describe('useModel', () => { describe('ask()', () => { it('sends message via invoke with correct command name and args', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('hello world'); }); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: 'hello world', quotedText: null, @@ -67,7 +67,7 @@ describe('useModel', () => { }, ); - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); // Start ask but don't await so we can read state while in-flight act(() => { @@ -84,7 +84,7 @@ describe('useModel', () => { }); it('adds user message and empty assistant placeholder immediately on ask', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('my question'); @@ -107,7 +107,7 @@ describe('useModel', () => { }); it('stores quotedText on user message when provided', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('what is this?', 'code snippet'); @@ -123,14 +123,14 @@ describe('useModel', () => { }); it('sends quotedText to invoke when provided', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('summarize', 'selected text'); }); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: 'summarize', quotedText: 'selected text', @@ -139,7 +139,7 @@ describe('useModel', () => { }); it('accumulates streaming tokens into the assistant message', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('hello'); @@ -160,7 +160,7 @@ describe('useModel', () => { }); it('keeps assistant message in place on Done chunk', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('hello'); @@ -184,7 +184,7 @@ describe('useModel', () => { }); it('does nothing for empty prompt', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask(''); @@ -195,7 +195,7 @@ describe('useModel', () => { }); it('does nothing for whitespace-only prompt', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask(' '); @@ -217,7 +217,7 @@ describe('useModel', () => { }, ); - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); // Start the first ask (stalls) act(() => { @@ -242,7 +242,7 @@ describe('useModel', () => { }); it('sends promptOverride as message to backend when provided', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask( @@ -255,7 +255,7 @@ describe('useModel', () => { }); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: 'composed prompt for model', }), @@ -267,14 +267,14 @@ describe('useModel', () => { }); it('sends displayContent as message when no promptOverride provided', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('hello world'); }); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: 'hello world', }), @@ -282,7 +282,7 @@ describe('useModel', () => { }); it('sends displayContent when promptOverride is undefined', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask( @@ -295,7 +295,7 @@ describe('useModel', () => { }); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: 'hello world', }), @@ -307,7 +307,7 @@ describe('useModel', () => { describe('imagePaths handling', () => { it('allows ask() with empty text but valid imagePaths', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('', undefined, ['/tmp/img1.jpg']); @@ -323,7 +323,7 @@ describe('useModel', () => { }), ); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: '', imagePaths: ['/tmp/img1.jpg'], @@ -332,7 +332,7 @@ describe('useModel', () => { }); it('returns early for empty text AND no imagePaths', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('', undefined, undefined); @@ -343,7 +343,7 @@ describe('useModel', () => { }); it('returns early for empty text AND empty imagePaths array', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('', undefined, []); @@ -354,7 +354,7 @@ describe('useModel', () => { }); it('includes imagePaths in message and invoke when text AND imagePaths are provided', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('describe this', undefined, [ @@ -371,7 +371,7 @@ describe('useModel', () => { }), ); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ message: 'describe this', imagePaths: ['/tmp/img1.jpg', '/tmp/img2.jpg'], @@ -380,7 +380,7 @@ describe('useModel', () => { }); it('sets message.imagePaths to undefined and invoke imagePaths to null when no imagePaths', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('hello'); @@ -388,7 +388,7 @@ describe('useModel', () => { expect(result.current.messages[0].imagePaths).toBeUndefined(); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ imagePaths: null, }), @@ -396,7 +396,7 @@ describe('useModel', () => { }); it('displayImagePaths shows in bubble but imagePaths=undefined keeps null in backend call', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask( @@ -415,7 +415,7 @@ describe('useModel', () => { ]); // Backend must NOT receive image bytes (OCR path: model only sees text). expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ imagePaths: null, }), @@ -427,7 +427,7 @@ describe('useModel', () => { describe('error handling', () => { it('Error chunk sets isGenerating to false', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('test'); @@ -452,7 +452,7 @@ describe('useModel', () => { it('invoke rejection sets isGenerating to false', async () => { invoke.mockRejectedValueOnce(new Error('connection refused')); - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('test'); @@ -462,7 +462,7 @@ describe('useModel', () => { }); it('Error chunk updates assistant placeholder with errorKind', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('test'); @@ -475,7 +475,7 @@ describe('useModel', () => { channel!.simulateMessage({ type: 'Error', data: { - kind: 'EngineUnreachable', + kind: 'NotRunning', message: "Ollama isn't running\nStart Ollama and try again.", }, }); @@ -484,14 +484,14 @@ describe('useModel', () => { const assistantMsg = result.current.messages.find( (m) => m.role === 'assistant', ); - expect(assistantMsg?.errorKind).toBe('EngineUnreachable'); + expect(assistantMsg?.errorKind).toBe('NotRunning'); expect(assistantMsg?.content).toBe( "Ollama isn't running\nStart Ollama and try again.", ); }); it('Error chunk with partial tokens replaces content with error', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('test'); @@ -518,7 +518,7 @@ describe('useModel', () => { it('invoke rejection creates assistant message with Other errorKind', async () => { invoke.mockRejectedValueOnce(new Error('network error')); - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('test'); @@ -536,7 +536,7 @@ describe('useModel', () => { describe('streaming edge cases', () => { it('handles Token with empty string', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('hello'); @@ -557,7 +557,7 @@ describe('useModel', () => { }); it('drops the placeholder when only an empty ThinkingToken arrives before cancellation', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('hello', undefined, undefined, true); @@ -589,7 +589,7 @@ describe('useModel', () => { latestChannel = args.onEvent as ReturnType; } - if (cmd === 'ask_model') { + if (cmd === 'ask_ollama') { askMessages.push(String(args?.message ?? '')); if (askMessages.length === 1) { return new Promise((resolve) => { @@ -606,7 +606,7 @@ describe('useModel', () => { } }); - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let secondAsk!: Promise; let thirdAsk!: Promise; @@ -652,7 +652,7 @@ describe('useModel', () => { let rejectInvoke!: (error: Error) => void; invoke.mockImplementation(async (cmd, args) => { - if (cmd === 'ask_model') { + if (cmd === 'ask_ollama') { channel = args?.onEvent as ReturnType; return new Promise((_, reject) => { rejectInvoke = reject; @@ -660,7 +660,7 @@ describe('useModel', () => { } }); - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); act(() => { void result.current.ask('late failure'); @@ -702,7 +702,7 @@ describe('useModel', () => { }, ); - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); act(() => { void result.current.ask('hello'); @@ -747,7 +747,7 @@ describe('useModel', () => { }, ); - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); act(() => { void result.current.askSearch('rust'); @@ -791,7 +791,7 @@ describe('useModel', () => { }); it('does nothing when not generating', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.cancel(); @@ -806,7 +806,7 @@ describe('useModel', () => { describe('Cancelled chunk', () => { it('keeps partial content as assistant message on Cancelled', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('hello'); @@ -831,7 +831,7 @@ describe('useModel', () => { }); it('removes assistant placeholder when cancelled with no tokens', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('hello'); @@ -855,7 +855,7 @@ describe('useModel', () => { describe('reset()', () => { it('clears all state', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); // Build up some state await act(async () => { @@ -881,7 +881,7 @@ describe('useModel', () => { }); it('fires record_conversation_end with user_reset when a turn was accepted', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('hello'); }); @@ -908,7 +908,7 @@ describe('useModel', () => { describe('onTurnComplete callback', () => { it('is called with user and assistant messages on Done', async () => { const onTurnComplete = vi.fn(); - const { result } = renderHook(() => useModel('', onTurnComplete)); + const { result } = renderHook(() => useOllama('', onTurnComplete)); await act(async () => { await result.current.ask('ping'); @@ -931,7 +931,7 @@ describe('useModel', () => { it('is not called when Cancelled', async () => { const onTurnComplete = vi.fn(); - const { result } = renderHook(() => useModel('', onTurnComplete)); + const { result } = renderHook(() => useOllama('', onTurnComplete)); await act(async () => { await result.current.ask('ping'); @@ -948,7 +948,7 @@ describe('useModel', () => { it('is not called when an Error chunk is received', async () => { const onTurnComplete = vi.fn(); - const { result } = renderHook(() => useModel('', onTurnComplete)); + const { result } = renderHook(() => useOllama('', onTurnComplete)); await act(async () => { await result.current.ask('ping'); @@ -972,7 +972,7 @@ describe('useModel', () => { it('stamps the assistant message with activeModel on ask() completion', async () => { const onTurnComplete = vi.fn(); const { result } = renderHook(() => - useModel('gemma4:e2b', onTurnComplete), + useOllama('gemma4:e2b', onTurnComplete), ); await act(async () => { @@ -995,7 +995,7 @@ describe('useModel', () => { it('leaves modelName undefined when activeModel is null', async () => { const onTurnComplete = vi.fn(); - const { result } = renderHook(() => useModel(null, onTurnComplete)); + const { result } = renderHook(() => useOllama(null, onTurnComplete)); await act(async () => { await result.current.ask('hi'); @@ -1014,7 +1014,7 @@ describe('useModel', () => { it('stamps the assistant message with activeModel on askSearch() turns', async () => { const onTurnComplete = vi.fn(); const { result } = renderHook(() => - useModel('qwen2.5:7b', onTurnComplete), + useOllama('qwen2.5:7b', onTurnComplete), ); let pending: Promise | undefined; @@ -1038,7 +1038,7 @@ describe('useModel', () => { it('leaves modelName undefined when activeModel is null on askSearch()', async () => { const onTurnComplete = vi.fn(); - const { result } = renderHook(() => useModel(null, onTurnComplete)); + const { result } = renderHook(() => useOllama(null, onTurnComplete)); let pending: Promise | undefined; await act(async () => { @@ -1064,7 +1064,7 @@ describe('useModel', () => { describe('loadMessages()', () => { it('replaces messages state with provided array', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('original question'); @@ -1089,7 +1089,7 @@ describe('useModel', () => { it('clears generating state when loading messages', async () => { invoke.mockRejectedValueOnce(new Error('boom')); - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('fail'); @@ -1104,7 +1104,7 @@ describe('useModel', () => { }); it('fires record_conversation_end with history_load when a turn was accepted', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('original'); }); @@ -1131,7 +1131,7 @@ describe('useModel', () => { describe('ThinkingToken handling', () => { it('marks the assistant placeholder as a /think turn when think is true', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('hello', undefined, undefined, true); @@ -1144,7 +1144,7 @@ describe('useModel', () => { }); it('accumulates ThinkingTokens into thinkingContent', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('hello', undefined, undefined, true); @@ -1165,14 +1165,14 @@ describe('useModel', () => { }); it('passes think parameter to invoke', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('hello', undefined, undefined, true); }); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ think: true, }), @@ -1180,14 +1180,14 @@ describe('useModel', () => { }); it('passes think as false by default', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('hello'); }); expect(invoke).toHaveBeenCalledWith( - 'ask_model', + 'ask_ollama', expect.objectContaining({ think: false, }), @@ -1196,7 +1196,7 @@ describe('useModel', () => { it('includes thinkingContent in onTurnComplete on Done', async () => { const onTurnComplete = vi.fn(); - const { result } = renderHook(() => useModel('', onTurnComplete)); + const { result } = renderHook(() => useOllama('', onTurnComplete)); await act(async () => { await result.current.ask('hello', undefined, undefined, true); @@ -1221,7 +1221,7 @@ describe('useModel', () => { it('does not set thinkingContent when no thinking happened', async () => { const onTurnComplete = vi.fn(); - const { result } = renderHook(() => useModel('', onTurnComplete)); + const { result } = renderHook(() => useOllama('', onTurnComplete)); await act(async () => { await result.current.ask('hello'); @@ -1239,7 +1239,7 @@ describe('useModel', () => { }); it('preserves thinking content when cancelled with thinking but no regular tokens', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('hello', undefined, undefined, true); @@ -1269,7 +1269,7 @@ describe('useModel', () => { describe('history', () => { it('maintains message history across multiple sequential asks', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); // First ask + response await act(async () => { @@ -1311,7 +1311,7 @@ describe('useModel', () => { describe('askSearch()', () => { it('invokes search_pipeline with the trimmed query', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch(' rust async '); @@ -1331,7 +1331,7 @@ describe('useModel', () => { }); it('stores quotedText on the /search user message when provided', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch( @@ -1358,7 +1358,7 @@ describe('useModel', () => { }); it('resolves immediately with final=true on empty query', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let outcome: { final: boolean } | undefined; await act(async () => { outcome = await result.current.askSearch(' '); @@ -1368,7 +1368,7 @@ describe('useModel', () => { }); it('resolves with final=true when a token is received followed by Done', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); const metadata = { iterations: [ { @@ -1411,7 +1411,7 @@ describe('useModel', () => { it('resolves with final=false when a clarify trace is followed by question tokens and Done', async () => { const onTurnComplete = vi.fn(); - const { result } = renderHook(() => useModel('', onTurnComplete)); + const { result } = renderHook(() => useOllama('', onTurnComplete)); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('who is him'); @@ -1448,7 +1448,7 @@ describe('useModel', () => { }); it('updates searchStage through the pipeline phases', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -1499,7 +1499,7 @@ describe('useModel', () => { }); it('handles FetchingUrl, finalizes traces on IterationComplete, and ignores empty tokens', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending!: Promise<{ final: boolean }>; await act(async () => { @@ -1561,7 +1561,7 @@ describe('useModel', () => { }); it('ignores IterationComplete events when no trace steps have started', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending!: Promise<{ final: boolean }>; await act(async () => { @@ -1596,7 +1596,7 @@ describe('useModel', () => { }); it('drops the empty placeholder on Cancelled with no content', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -1616,7 +1616,7 @@ describe('useModel', () => { }); it('keeps partial content on Cancelled after tokens arrived', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -1637,7 +1637,7 @@ describe('useModel', () => { it('renders an Error event as an error bubble', async () => { const onTurnComplete = vi.fn(); - const { result } = renderHook(() => useModel('', onTurnComplete)); + const { result } = renderHook(() => useOllama('', onTurnComplete)); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -1661,7 +1661,7 @@ describe('useModel', () => { }); it('guards against concurrent invocations', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let firstPending!: Promise<{ final: boolean }>; await act(async () => { firstPending = result.current.askSearch('first'); @@ -1710,7 +1710,7 @@ describe('useModel', () => { } }); - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let firstPending!: Promise<{ final: boolean }>; let secondPending!: Promise<{ final: boolean }>; @@ -1756,7 +1756,7 @@ describe('useModel', () => { invoke.mockImplementationOnce(async () => { throw new Error('ipc failed'); }); - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let outcome: { final: boolean } | undefined; await act(async () => { outcome = await result.current.askSearch('q'); @@ -1789,7 +1789,7 @@ describe('useModel', () => { } }); - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending!: Promise<{ final: boolean }>; act(() => { @@ -1820,7 +1820,7 @@ describe('useModel', () => { it('does not persist an empty turn on Done', async () => { const onTurnComplete = vi.fn(); - const { result } = renderHook(() => useModel('', onTurnComplete)); + const { result } = renderHook(() => useOllama('', onTurnComplete)); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -1838,7 +1838,7 @@ describe('useModel', () => { it('persists searchSources to the assistant message on Sources + Token + Done', async () => { const onTurnComplete = vi.fn(); - const { result } = renderHook(() => useModel('', onTurnComplete)); + const { result } = renderHook(() => useOllama('', onTurnComplete)); const metadata = { iterations: [ { @@ -1884,7 +1884,7 @@ describe('useModel', () => { }); it('Warning event accumulates into message.searchWarnings while streaming continues', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -1909,7 +1909,7 @@ describe('useModel', () => { it('askSearch accumulates warnings from Warning events into the persisted turn', async () => { const onTurnComplete = vi.fn(); - const { result } = renderHook(() => useModel('', onTurnComplete)); + const { result } = renderHook(() => useOllama('', onTurnComplete)); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -1941,7 +1941,7 @@ describe('useModel', () => { it('askSearch passes multiple warnings through in order', async () => { const onTurnComplete = vi.fn(); - const { result } = renderHook(() => useModel('', onTurnComplete)); + const { result } = renderHook(() => useOllama('', onTurnComplete)); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -1971,7 +1971,7 @@ describe('useModel', () => { }); it('Trace events accumulate steps on the assistant message', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -2018,7 +2018,7 @@ describe('useModel', () => { }); it('Trace updates replace earlier steps with the same id', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -2067,7 +2067,7 @@ describe('useModel', () => { it('Trace events are passed to onTurnComplete', async () => { const onTurnComplete = vi.fn(); - const { result } = renderHook(() => useModel('', onTurnComplete)); + const { result } = renderHook(() => useOllama('', onTurnComplete)); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -2102,7 +2102,7 @@ describe('useModel', () => { it('preserves completed traces on Done when no running steps need finalization', async () => { const onTurnComplete = vi.fn(); - const { result } = renderHook(() => useModel('', onTurnComplete)); + const { result } = renderHook(() => useOllama('', onTurnComplete)); let pending!: Promise<{ final: boolean }>; await act(async () => { @@ -2144,7 +2144,7 @@ describe('useModel', () => { it('searchTraces is undefined when no Trace event is received', async () => { const onTurnComplete = vi.fn(); - const { result } = renderHook(() => useModel('', onTurnComplete)); + const { result } = renderHook(() => useOllama('', onTurnComplete)); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -2166,7 +2166,7 @@ describe('useModel', () => { describe('search state cleanup', () => { it('reset clears the search stage indicator', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -2189,7 +2189,7 @@ describe('useModel', () => { }); it('loadMessages clears the search stage indicator', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -2212,7 +2212,7 @@ describe('useModel', () => { }); it('Searching after RefiningSearch sets gap:true stage', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -2239,7 +2239,7 @@ describe('useModel', () => { }); it('ReadingSources after RefiningSearch sets gap:true stage', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -2267,7 +2267,7 @@ describe('useModel', () => { it('SandboxUnavailable event sets sandboxUnavailable on assistant message', async () => { const onTurnComplete = vi.fn(); - const { result } = renderHook(() => useModel('', onTurnComplete)); + const { result } = renderHook(() => useOllama('', onTurnComplete)); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -2288,7 +2288,7 @@ describe('useModel', () => { }); it('SandboxUnavailable event does not set errorKind', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -2306,7 +2306,7 @@ describe('useModel', () => { it('NoModelSelected event renders no-model error and resolves final', async () => { const onTurnComplete = vi.fn(); - const { result } = renderHook(() => useModel('', onTurnComplete)); + const { result } = renderHook(() => useOllama('', onTurnComplete)); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -2331,14 +2331,14 @@ describe('useModel', () => { // ─── is_first_turn flag retention across pre-ConversationStart bails ──────── // - // The chat backend's `ask_model` and the search backend's `search_pipeline` + // The chat backend's `ask_ollama` and the search backend's `search_pipeline` // both bail BEFORE recording `ConversationStart` on no-model and (search // only) sandbox-unavailable paths. Frontend must keep `isFirstTurnRef` // armed across those bails so the next attempt opens the trace correctly. describe('is_first_turn flag retention across bails', () => { it('chat NoModelSelected error keeps the flag armed for the next turn', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('first'); }); @@ -2349,19 +2349,21 @@ describe('useModel', () => { data: { kind: 'NoModelSelected', message: 'no model' }, }); }); - const firstCall = invoke.mock.calls.find(([cmd]) => cmd === 'ask_model'); + const firstCall = invoke.mock.calls.find(([cmd]) => cmd === 'ask_ollama'); expect(firstCall?.[1]).toMatchObject({ isFirstTurn: true }); invoke.mockClear(); await act(async () => { await result.current.ask('second'); }); - const secondCall = invoke.mock.calls.find(([cmd]) => cmd === 'ask_model'); + const secondCall = invoke.mock.calls.find( + ([cmd]) => cmd === 'ask_ollama', + ); expect(secondCall?.[1]).toMatchObject({ isFirstTurn: true }); }); it('chat TurnAccepted retires the flag for the next turn', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('first'); }); @@ -2376,7 +2378,9 @@ describe('useModel', () => { await act(async () => { await result.current.ask('second'); }); - const secondCall = invoke.mock.calls.find(([cmd]) => cmd === 'ask_model'); + const secondCall = invoke.mock.calls.find( + ([cmd]) => cmd === 'ask_ollama', + ); expect(secondCall?.[1]).toMatchObject({ isFirstTurn: false }); }); @@ -2387,7 +2391,7 @@ describe('useModel', () => { // and a stale `Cancelled` chunk lands after `activeGenerationRef` // is cleared. The flag must still retire so the next turn does // NOT trigger a duplicate `ConversationStart`. - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); await act(async () => { await result.current.ask('first'); }); @@ -2408,12 +2412,14 @@ describe('useModel', () => { await act(async () => { await result.current.ask('second'); }); - const secondCall = invoke.mock.calls.find(([cmd]) => cmd === 'ask_model'); + const secondCall = invoke.mock.calls.find( + ([cmd]) => cmd === 'ask_ollama', + ); expect(secondCall?.[1]).toMatchObject({ isFirstTurn: false }); }); it('search SandboxUnavailable keeps the flag armed for the next turn', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending1!: Promise<{ final: boolean }>; await act(async () => { pending1 = result.current.askSearch('q1'); @@ -2451,7 +2457,7 @@ describe('useModel', () => { }); it('search NoModelSelected keeps the flag armed for the next turn', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending1!: Promise<{ final: boolean }>; await act(async () => { pending1 = result.current.askSearch('q1'); @@ -2489,7 +2495,7 @@ describe('useModel', () => { // event lands after activeGenerationRef is cleared. The flag // must still retire so the next /search does not duplicate // ConversationStart. - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('first'); @@ -2529,7 +2535,7 @@ describe('useModel', () => { it('search TurnAccepted retires the flag for a follow-up chat turn (cross-domain)', async () => { // The flag is shared across chat and search; once /search opens // the trace, a subsequent chat ask() must see is_first_turn=false. - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); let pending!: Promise<{ final: boolean }>; await act(async () => { pending = result.current.askSearch('q'); @@ -2548,7 +2554,7 @@ describe('useModel', () => { await act(async () => { await result.current.ask('chat after search'); }); - const chatCall = invoke.mock.calls.find(([cmd]) => cmd === 'ask_model'); + const chatCall = invoke.mock.calls.find(([cmd]) => cmd === 'ask_ollama'); expect(chatCall?.[1]).toMatchObject({ isFirstTurn: false }); }); }); @@ -2557,7 +2563,7 @@ describe('useModel', () => { describe('addOcrTurn', () => { it('appends user and assistant messages to the conversation', async () => { - const { result } = renderHook(() => useModel('')); + const { result } = renderHook(() => useOllama('')); act(() => { result.current.addOcrTurn( @@ -2583,7 +2589,7 @@ describe('useModel', () => { it('calls onTurnComplete with the user and assistant messages', async () => { const onTurnComplete = vi.fn(); - const { result } = renderHook(() => useModel('', onTurnComplete)); + const { result } = renderHook(() => useOllama('', onTurnComplete)); act(() => { result.current.addOcrTurn( diff --git a/src/hooks/useConversationHistory.ts b/src/hooks/useConversationHistory.ts index f6689d00..fdd0b7fa 100644 --- a/src/hooks/useConversationHistory.ts +++ b/src/hooks/useConversationHistory.ts @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react'; import { invoke } from '@tauri-apps/api/core'; -import type { Message } from './useModel'; +import type { Message } from './useOllama'; import type { IterationTrace, SearchMetadata, @@ -252,7 +252,7 @@ function fromPersisted(msg: PersistedMessage): Message { * Tracks whether the active conversation has been saved to SQLite and provides * typed wrappers around all history-related Tauri commands. Intentionally has * no knowledge of streaming state or window management - those live in App.tsx - * and `useModel`. + * and `useOllama`. * * @returns An object containing the current persistence state and all * history operation callbacks. @@ -422,7 +422,7 @@ export function useConversationHistory() { * Clears the local persistence state, marking the session as unsaved. * * Does NOT call `reset_conversation` on the backend. When clearing the - * full session (new conversation), call `useModel.reset()` alongside this + * full session (new conversation), call `useOllama.reset()` alongside this * so the backend history is also wiped. When only marking a conversation as * unsaved while keeping messages visible (e.g. after deletion from history), * calling this alone is correct - `persistTurn` will no-op and the backend diff --git a/src/hooks/useModel.ts b/src/hooks/useOllama.ts similarity index 98% rename from src/hooks/useModel.ts rename to src/hooks/useOllama.ts index a89d3d07..95d84502 100644 --- a/src/hooks/useModel.ts +++ b/src/hooks/useOllama.ts @@ -9,9 +9,9 @@ import type { SearchWarning, } from '../types/search'; -/** Mirrors the Rust EngineErrorKind enum sent over IPC. */ -export type EngineErrorKind = - | 'EngineUnreachable' +/** Mirrors the Rust OllamaErrorKind enum sent over IPC. */ +export type OllamaErrorKind = + | 'NotRunning' | 'ModelNotFound' | 'NoModelSelected' | 'Other'; @@ -31,7 +31,7 @@ export interface Message { /** Absolute file paths of images attached to this message, if any. */ imagePaths?: string[]; /** Present on assistant messages that represent an Ollama error callout. */ - errorKind?: EngineErrorKind; + errorKind?: OllamaErrorKind; /** Accumulated thinking content from the model, if thinking mode was used. */ thinkingContent?: string; /** Marks an assistant message produced through the `/search` pipeline. */ @@ -60,7 +60,7 @@ type RawStreamChunk = | { type: 'ThinkingToken'; data: string } | { type: 'Done' } | { type: 'Cancelled' } - | { type: 'Error'; data: { kind: EngineErrorKind; message: string } } + | { type: 'Error'; data: { kind: OllamaErrorKind; message: string } } | { type: 'TurnAccepted' }; /** @@ -75,7 +75,7 @@ type StreamChunk = | { type: 'ThinkingToken'; content: string } | { type: 'Done' } | { type: 'Cancelled' } - | { type: 'Error'; error: { kind: EngineErrorKind; message: string } } + | { type: 'Error'; error: { kind: OllamaErrorKind; message: string } } | { type: 'TurnAccepted' }; /** @@ -158,7 +158,7 @@ function finalizeSearchTraceSteps( * attribution chip is rendered rather than a blank one. * @param onTurnComplete Optional callback invoked after each completed turn. */ -export function useModel( +export function useOllama( activeModel: string | null, onTurnComplete?: (userMsg: Message, assistantMsg: Message) => void, ) { @@ -404,7 +404,7 @@ export function useModel( // on no-model bails that return before `ConversationStart` fires, // leaving the next attempt without an opening trace event. try { - await invoke('ask_model', { + await invoke('ask_ollama', { message: promptOverride ?? displayContent, quotedText: quotedText ?? null, imagePaths: imagePaths && imagePaths.length > 0 ? imagePaths : null, @@ -658,7 +658,7 @@ export function useModel( } case 'NoModelSelected': { errored = true; - // Mirror the chat path's `EngineErrorKind::NoModelSelected` + // Mirror the chat path's `OllamaErrorKind::NoModelSelected` // bubble copy verbatim so the user sees a single canonical // call-to-action regardless of which command tripped the gate. updateAssistant({ diff --git a/src/lib/__tests__/exportSerializer.test.ts b/src/lib/__tests__/exportSerializer.test.ts index 5f1efab3..4dc40ece 100644 --- a/src/lib/__tests__/exportSerializer.test.ts +++ b/src/lib/__tests__/exportSerializer.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import type { Message } from '../../hooks/useModel'; +import type { Message } from '../../hooks/useOllama'; import { defaultExportFilename, defaultImageLoader, diff --git a/src/lib/exportSerializer.ts b/src/lib/exportSerializer.ts index 189eab7a..de675eed 100644 --- a/src/lib/exportSerializer.ts +++ b/src/lib/exportSerializer.ts @@ -25,7 +25,7 @@ */ import { convertFileSrc } from '@tauri-apps/api/core'; -import type { Message } from '../hooks/useModel'; +import type { Message } from '../hooks/useOllama'; /** Configuration relevant to a file export. */ export interface FileExportContext { diff --git a/src/settings/SettingsWindow.test.tsx b/src/settings/SettingsWindow.test.tsx index c649da16..a026b6a2 100644 --- a/src/settings/SettingsWindow.test.tsx +++ b/src/settings/SettingsWindow.test.tsx @@ -17,25 +17,9 @@ const invokeMock = invoke as unknown as ReturnType; const SAMPLE: RawAppConfig = { inference: { - active_provider: 'ollama', + ollama_url: 'http://127.0.0.1:11434', keep_warm_inactivity_minutes: 0, num_ctx: 16384, - providers: [ - { - id: 'builtin', - kind: 'builtin', - label: 'Built-in (Thuki)', - base_url: '', - model: '', - }, - { - id: 'ollama', - kind: 'ollama', - label: 'Ollama', - base_url: 'http://127.0.0.1:11434', - model: '', - }, - ], }, prompt: { system: '' }, window: { diff --git a/src/settings/components/SaveField.test.tsx b/src/settings/components/SaveField.test.tsx index 76e99590..f4e6b26b 100644 --- a/src/settings/components/SaveField.test.tsx +++ b/src/settings/components/SaveField.test.tsx @@ -10,25 +10,9 @@ const invokeMock = invoke as unknown as ReturnType; const SAMPLE: RawAppConfig = { inference: { - active_provider: 'ollama', + ollama_url: 'http://127.0.0.1:11434', keep_warm_inactivity_minutes: 0, num_ctx: 16384, - providers: [ - { - id: 'builtin', - kind: 'builtin', - label: 'Built-in (Thuki)', - base_url: '', - model: '', - }, - { - id: 'ollama', - kind: 'ollama', - label: 'Ollama', - base_url: 'http://127.0.0.1:11434', - model: '', - }, - ], }, prompt: { system: '' }, window: { diff --git a/src/settings/configHelpers.test.ts b/src/settings/configHelpers.test.ts index e288d2f1..cded72a4 100644 --- a/src/settings/configHelpers.test.ts +++ b/src/settings/configHelpers.test.ts @@ -5,7 +5,7 @@ import { configHelp } from './configHelpers'; describe('configHelp', () => { it('returns the doc-mirrored helper string for every section', () => { // One probe per section is enough to exercise the typed lookup branches. - expect(configHelp('inference', 'ollama_base_url')).toMatch(/Ollama server/); + expect(configHelp('inference', 'ollama_url')).toMatch(/Ollama server/); expect(configHelp('prompt', 'system')).toMatch(/custom personality/); expect(configHelp('window', 'overlay_width')).toMatch(/in pixels/); expect(configHelp('quote', 'max_display_lines')).toMatch( @@ -16,7 +16,7 @@ describe('configHelp', () => { it('returns a non-empty string for every documented field', () => { const fields: Array<[Parameters[0], string]> = [ - ['inference', 'ollama_base_url'], + ['inference', 'ollama_url'], ['prompt', 'system'], ['window', 'overlay_width'], ['window', 'max_chat_height'], diff --git a/src/settings/configHelpers.ts b/src/settings/configHelpers.ts index 75578628..76d556fd 100644 --- a/src/settings/configHelpers.ts +++ b/src/settings/configHelpers.ts @@ -6,16 +6,15 @@ * same story. When you add or change a tunable, update both this file * and the matching table row in the docs in the same commit. * - * Indexed by a `(section, key)` pair. Most keys are the canonical TOML field - * names from the backend `set_config_field` allowlist; a few (e.g. - * `inference.ollama_base_url`, `inference.keep_warm`) are display-only keys - * for values written through dedicated commands such as `set_ollama_url`. + * Indexed by the same `(section, key)` pair the backend's + * `set_config_field` allowlist uses, so the keys here are guaranteed to + * be the canonical TOML field names. */ const HELPERS = { inference: { - ollama_base_url: - 'The address where Thuki reaches your Ollama server. The default works if you run Ollama on this Mac with its standard port. Point it at another machine to use Ollama running elsewhere (one server at a time).', + ollama_url: + 'The web address where Thuki finds your local Ollama server. The default works if you run Ollama on this machine with its standard port. Change this only if you moved Ollama to a different port or another machine.', keep_warm: 'When on, Thuki tells Ollama to keep the active model loaded in GPU memory between conversations, saving the cold-load wait on every open. Set "Release after" to −1 to keep it warm indefinitely, or pick a timeout in minutes so GPU memory is reclaimed when you stop using Thuki for a while.', num_ctx: diff --git a/src/settings/hooks/useConfigSync.test.ts b/src/settings/hooks/useConfigSync.test.ts index 70bd093f..e6370331 100644 --- a/src/settings/hooks/useConfigSync.test.ts +++ b/src/settings/hooks/useConfigSync.test.ts @@ -14,25 +14,9 @@ const invokeMock = invoke as unknown as ReturnType; const CONFIG_A: RawAppConfig = { inference: { - active_provider: 'ollama', + ollama_url: 'http://127.0.0.1:11434', keep_warm_inactivity_minutes: 0, num_ctx: 16384, - providers: [ - { - id: 'builtin', - kind: 'builtin', - label: 'Built-in (Thuki)', - base_url: '', - model: '', - }, - { - id: 'ollama', - kind: 'ollama', - label: 'Ollama', - base_url: 'http://127.0.0.1:11434', - model: '', - }, - ], }, prompt: { system: '' }, window: { @@ -72,13 +56,7 @@ const CONFIG_A: RawAppConfig = { const CONFIG_B: RawAppConfig = { ...CONFIG_A, - inference: { - ...CONFIG_A.inference, - providers: [ - CONFIG_A.inference.providers[0], - { ...CONFIG_A.inference.providers[1], base_url: 'http://10.0.0.1:11434' }, - ], - }, + inference: { ...CONFIG_A.inference, ollama_url: 'http://10.0.0.1:11434' }, }; beforeEach(() => { diff --git a/src/settings/hooks/useDebouncedSave.test.ts b/src/settings/hooks/useDebouncedSave.test.ts index 714a4105..3e39d2ca 100644 --- a/src/settings/hooks/useDebouncedSave.test.ts +++ b/src/settings/hooks/useDebouncedSave.test.ts @@ -11,25 +11,9 @@ const invokeMock = invoke as unknown as ReturnType; const SAMPLE_CONFIG: RawAppConfig = { inference: { - active_provider: 'ollama', + ollama_url: 'http://127.0.0.1:11434', keep_warm_inactivity_minutes: 0, num_ctx: 16384, - providers: [ - { - id: 'builtin', - kind: 'builtin', - label: 'Built-in (Thuki)', - base_url: '', - model: '', - }, - { - id: 'ollama', - kind: 'ollama', - label: 'Ollama', - base_url: 'http://127.0.0.1:11434', - model: '', - }, - ], }, prompt: { system: '' }, window: { diff --git a/src/settings/tabs/ModelTab.tsx b/src/settings/tabs/ModelTab.tsx index 24bc74b6..cc0d7f39 100644 --- a/src/settings/tabs/ModelTab.tsx +++ b/src/settings/tabs/ModelTab.tsx @@ -12,11 +12,9 @@ import { useEffect, useRef, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; -import { Section, SettingRow, Dropdown, Textarea, Toggle } from '../components'; +import { Section, TextField, Textarea, Toggle } from '../components'; import { SaveField } from '../components/SaveField'; import { useDebouncedSave } from '../hooks/useDebouncedSave'; -import { useModelSelection } from '../../hooks/useModelSelection'; -import { isNonLocalUrl } from '../../utils/isNonLocalUrl'; import { configHelp } from '../configHelpers'; import { DrawCheckIcon } from '../../components/DrawCheckIcon'; import { Tooltip } from '../../components/Tooltip'; @@ -100,20 +98,6 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { const [devOpen, setDevOpen] = useState(false); - // Ollama provider URL: local editable copy committed on blur / Enter via - // the dedicated set_ollama_url command (the URL lives on the providers array, - // not a flat set_config_field key). - const ollamaBaseUrl = - config.inference.providers.find((p) => p.kind === 'ollama')?.base_url ?? ''; - const [ollamaUrl, setOllamaUrl] = useState(ollamaBaseUrl); - const ollamaUrlFocusedRef = useRef(false); - - // Per-provider model picker (Ollama). Mirrors the overlay picker; both read - // get_model_picker_state, which is scoped to the active provider. - // `useModelSelection` already refreshes once on mount, so no extra effect is - // needed here. - const { activeModel, availableModels, setActiveModel } = useModelSelection(); - useEffect(() => { let unlistenLoaded: (() => void) | null = null; let unlistenEvicted: (() => void) | null = null; @@ -176,9 +160,6 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { setCtxPos(ctxToPos(nextCtx)); setCtxChip(String(nextCtx)); resetNumCtx(nextCtx); - if (!ollamaUrlFocusedRef.current) { - setOllamaUrl(ollamaBaseUrl); - } } function commitCtx(v: number) { @@ -196,83 +177,30 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { .catch(() => setEjecting(false)); } - function commitOllamaUrl() { - const next = ollamaUrl.trim(); - if (next === ollamaBaseUrl) return; - void invoke('set_ollama_url', { baseUrl: next }) - .then((cfg) => onSaved(cfg)) - .catch(() => { - // Save failed: revert the field to the persisted value so the input - // never shows a URL the backend did not accept. - setOllamaUrl(ollamaBaseUrl); - }); - } - - const modelValue = - activeModel && availableModels.includes(activeModel) - ? activeModel - : (availableModels[0] ?? ''); - const ctxTurns = Math.round(numCtx / TOKENS_PER_TURN_ESTIMATE); const fillPct = `${ctxPos / 10}%`; return ( <> -

-
- Built-in (Thuki) - - Available in an upcoming version - -
- -
Ollama
- + - { - ollamaUrlFocusedRef.current = true; - }} - onChange={(e) => setOllamaUrl(e.target.value)} - onBlur={() => { - ollamaUrlFocusedRef.current = false; - commitOllamaUrl(); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); - }} - /> - - {isNonLocalUrl(ollamaUrl) && ( -

- This points Thuki at a non-local Ollama server. You are responsible - for securing it: prefer a VPN/Tailscale or SSH tunnel over exposing - the port directly. -

- )} - - {availableModels.length > 0 ? ( - void setActiveModel(m)} - ariaLabel="Active Ollama model" + helper={configHelp('inference', 'ollama_url')} + initialValue={config.inference.ollama_url} + resyncToken={resyncToken} + onSaved={onSaved} + render={(value, setValue, errored) => ( + - ) : ( - No models installed )} - + />
diff --git a/src/settings/tabs/tabs.test.tsx b/src/settings/tabs/tabs.test.tsx index 1545aa17..abde60de 100644 --- a/src/settings/tabs/tabs.test.tsx +++ b/src/settings/tabs/tabs.test.tsx @@ -35,25 +35,9 @@ const invokeMock = invoke as unknown as ReturnType; const CONFIG: RawAppConfig = { inference: { - active_provider: 'ollama', + ollama_url: 'http://127.0.0.1:11434', keep_warm_inactivity_minutes: 0, num_ctx: 16384, - providers: [ - { - id: 'builtin', - kind: 'builtin', - label: 'Built-in (Thuki)', - base_url: '', - model: '', - }, - { - id: 'ollama', - kind: 'ollama', - label: 'Ollama', - base_url: 'http://127.0.0.1:11434', - model: '', - }, - ], }, prompt: { system: 'hello' }, window: { @@ -95,9 +79,6 @@ beforeEach(() => { invokeMock.mockReset(); invokeMock.mockImplementation((cmd: string) => { if (cmd === 'get_loaded_model') return Promise.resolve(null); - if (cmd === 'get_model_picker_state') { - return Promise.resolve({ active: null, all: [], ollamaReachable: false }); - } if (cmd === 'get_updater_state') { return Promise.resolve({ last_check_at_unix: null, @@ -126,273 +107,14 @@ async function renderModelTab() { } describe('ModelTab', () => { - it('renders Providers and Prompt sections with the expected labels', async () => { + it('renders Ollama and Prompt sections with the expected labels', async () => { await renderModelTab(); - expect(screen.getByText('Providers')).toBeInTheDocument(); - expect(screen.getByText('Built-in (Thuki)')).toBeInTheDocument(); - expect( - screen.getByText('Available in an upcoming version'), - ).toBeInTheDocument(); + expect(screen.getByText('Ollama')).toBeInTheDocument(); expect(screen.getByText('Prompt')).toBeInTheDocument(); expect(screen.getByText('Ollama URL')).toBeInTheDocument(); expect(screen.getByText('System prompt')).toBeInTheDocument(); }); - it('renders the Ollama URL field seeded from the active provider base_url', async () => { - await renderModelTab(); - const input = screen.getByRole('textbox', { - name: 'Ollama URL', - }) as HTMLInputElement; - expect(input.value).toBe('http://127.0.0.1:11434'); - }); - - it('committing a changed Ollama URL invokes set_ollama_url and lifts the config', async () => { - let savedUrl: unknown; - const onSaved = vi.fn(); - invokeMock.mockImplementation((cmd: string, args?: unknown) => { - if (cmd === 'get_loaded_model') return Promise.resolve(null); - if (cmd === 'get_model_picker_state') - return Promise.resolve({ - active: null, - all: [], - ollamaReachable: false, - }); - if (cmd === 'set_ollama_url') { - savedUrl = (args as { baseUrl: string }).baseUrl; - return Promise.resolve(CONFIG); - } - return Promise.resolve(CONFIG); - }); - render(); - await act(async () => { - await Promise.resolve(); - }); - const input = screen.getByRole('textbox', { name: 'Ollama URL' }); - fireEvent.focus(input); - fireEvent.change(input, { target: { value: 'http://10.0.0.2:11434' } }); - fireEvent.blur(input); - await act(async () => { - await Promise.resolve(); - }); - expect(savedUrl).toBe('http://10.0.0.2:11434'); - expect(onSaved).toHaveBeenCalledWith(CONFIG); - }); - - it('committing an unchanged Ollama URL does not invoke set_ollama_url', async () => { - await renderModelTab(); - const input = screen.getByRole('textbox', { name: 'Ollama URL' }); - fireEvent.focus(input); - fireEvent.blur(input); - expect(invokeMock).not.toHaveBeenCalledWith( - 'set_ollama_url', - expect.anything(), - ); - }); - - it('Enter in the Ollama URL field commits via blur', async () => { - invokeMock.mockImplementation((cmd: string) => { - if (cmd === 'get_loaded_model') return Promise.resolve(null); - if (cmd === 'get_model_picker_state') - return Promise.resolve({ - active: null, - all: [], - ollamaReachable: false, - }); - return Promise.resolve(CONFIG); - }); - await renderModelTab(); - const input = screen.getByRole('textbox', { name: 'Ollama URL' }); - fireEvent.focus(input); - fireEvent.change(input, { target: { value: 'http://10.0.0.9:11434' } }); - fireEvent.keyDown(input, { key: 'Enter' }); - // Programmatic blur() only fires when the element is focused. - fireEvent.blur(input); - await act(async () => { - await Promise.resolve(); - }); - expect(invokeMock).toHaveBeenCalledWith('set_ollama_url', { - baseUrl: 'http://10.0.0.9:11434', - }); - }); - - it('a non-Enter keydown in the Ollama URL field does not commit', async () => { - await renderModelTab(); - const input = screen.getByRole('textbox', { name: 'Ollama URL' }); - fireEvent.focus(input); - fireEvent.change(input, { target: { value: 'http://10.0.0.4:11434' } }); - fireEvent.keyDown(input, { key: 'Tab' }); - expect(invokeMock).not.toHaveBeenCalledWith( - 'set_ollama_url', - expect.anything(), - ); - }); - - it('swallows a set_ollama_url failure without crashing', async () => { - invokeMock.mockImplementation((cmd: string) => { - if (cmd === 'get_loaded_model') return Promise.resolve(null); - if (cmd === 'get_model_picker_state') - return Promise.resolve({ - active: null, - all: [], - ollamaReachable: false, - }); - if (cmd === 'set_ollama_url') - return Promise.reject(new Error('write failed')); - return Promise.resolve(CONFIG); - }); - await renderModelTab(); - const input = screen.getByRole('textbox', { name: 'Ollama URL' }); - fireEvent.change(input, { target: { value: 'http://10.0.0.3:11434' } }); - fireEvent.blur(input); - await act(async () => { - await Promise.resolve(); - }); - // Field still rendered; no throw. - expect( - screen.getByRole('textbox', { name: 'Ollama URL' }), - ).toBeInTheDocument(); - }); - - it('shows the non-local warning for a remote URL and hides it for localhost', async () => { - await renderModelTab(); - const input = screen.getByRole('textbox', { name: 'Ollama URL' }); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - fireEvent.focus(input); - fireEvent.change(input, { target: { value: 'http://example.com:11434' } }); - expect(screen.getByRole('alert')).toHaveTextContent( - /responsible for securing it/, - ); - fireEvent.change(input, { target: { value: 'http://127.0.0.1:11434' } }); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - }); - - it('renders the model dropdown with installed models and switches on change', async () => { - let switched: unknown; - invokeMock.mockImplementation((cmd: string, args?: unknown) => { - if (cmd === 'get_loaded_model') return Promise.resolve(null); - if (cmd === 'get_model_picker_state') { - return Promise.resolve({ - active: 'llama3.1:8b', - all: ['llama3.1:8b', 'phi4:14b'], - ollamaReachable: true, - }); - } - if (cmd === 'set_active_model') { - switched = (args as { model: string }).model; - return Promise.resolve(undefined); - } - return Promise.resolve(CONFIG); - }); - await renderModelTab(); - const dropdown = screen.getByRole('combobox', { - name: 'Active Ollama model', - }) as HTMLSelectElement; - expect(dropdown.value).toBe('llama3.1:8b'); - fireEvent.change(dropdown, { target: { value: 'phi4:14b' } }); - await act(async () => { - await Promise.resolve(); - }); - expect(switched).toBe('phi4:14b'); - }); - - it('falls back to the first installed model when none is active', async () => { - invokeMock.mockImplementation((cmd: string) => { - if (cmd === 'get_loaded_model') return Promise.resolve(null); - if (cmd === 'get_model_picker_state') { - return Promise.resolve({ - active: null, - all: ['gemma3:12b', 'phi4:14b'], - ollamaReachable: true, - }); - } - return Promise.resolve(CONFIG); - }); - await renderModelTab(); - const dropdown = screen.getByRole('combobox', { - name: 'Active Ollama model', - }) as HTMLSelectElement; - expect(dropdown.value).toBe('gemma3:12b'); - }); - - it('shows a no-models hint when the provider reports no installed models', async () => { - await renderModelTab(); - expect(screen.getByText('No models installed')).toBeInTheDocument(); - expect( - screen.queryByRole('combobox', { name: 'Active Ollama model' }), - ).not.toBeInTheDocument(); - }); - - it('shows an empty Ollama URL when no Ollama provider is configured', async () => { - const builtinOnly: RawAppConfig = { - ...CONFIG, - inference: { - ...CONFIG.inference, - providers: [CONFIG.inference.providers[0]], - }, - }; - render( - {}} />, - ); - await act(async () => { - await Promise.resolve(); - }); - const input = screen.getByRole('textbox', { - name: 'Ollama URL', - }) as HTMLInputElement; - expect(input.value).toBe(''); - }); - - it('does not overwrite the Ollama URL on resync while the field is focused', async () => { - const { rerender } = await renderModelTab(); - const input = screen.getByRole('textbox', { - name: 'Ollama URL', - }) as HTMLInputElement; - fireEvent.focus(input); - fireEvent.change(input, { target: { value: 'http://typing.in/progress' } }); - const updatedConfig: RawAppConfig = { - ...CONFIG, - inference: { - ...CONFIG.inference, - providers: [ - CONFIG.inference.providers[0], - { - ...CONFIG.inference.providers[1], - base_url: 'http://10.0.0.8:11434', - }, - ], - }, - }; - rerender( - {}} />, - ); - expect(input.value).toBe('http://typing.in/progress'); - }); - - it('resyncs the Ollama URL field when resyncToken changes', async () => { - const { rerender } = await renderModelTab(); - const input = screen.getByRole('textbox', { - name: 'Ollama URL', - }) as HTMLInputElement; - expect(input.value).toBe('http://127.0.0.1:11434'); - const updatedConfig: RawAppConfig = { - ...CONFIG, - inference: { - ...CONFIG.inference, - providers: [ - CONFIG.inference.providers[0], - { - ...CONFIG.inference.providers[1], - base_url: 'http://10.0.0.7:11434', - }, - ], - }, - }; - rerender( - {}} />, - ); - expect(input.value).toBe('http://10.0.0.7:11434'); - }); - it('no longer renders the auto-replace toggle (moved to the Behavior tab)', async () => { await renderModelTab(); expect(screen.queryByText('Text Replacement')).not.toBeInTheDocument(); diff --git a/src/settings/types.ts b/src/settings/types.ts index c69b387f..27d87a5c 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -12,21 +12,11 @@ * shapes are not interchangeable. */ -/** One entry in the `[[inference.providers]]` array (snake_case, from TOML). */ -export interface RawProvider { - id: string; - kind: string; - label: string; - base_url: string; - model: string; -} - export interface RawAppConfig { inference: { - active_provider: string; + ollama_url: string; keep_warm_inactivity_minutes: number; num_ctx: number; - providers: RawProvider[]; }; prompt: { system: string; diff --git a/src/styles/settings.module.css b/src/styles/settings.module.css index bad187bc..ad399e69 100644 --- a/src/styles/settings.module.css +++ b/src/styles/settings.module.css @@ -1545,36 +1545,3 @@ border-color: rgba(94, 201, 138, 0.25); background: rgba(94, 201, 138, 0.06); } - -/* ─── Providers section (AI tab) ─────────────────────────────────────────── */ -.providerRow { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 6px 0; -} -.providerName { - font-size: 13px; - font-weight: 600; - color: rgba(255, 255, 255, 0.92); -} -.providerBadge { - font-size: 11px; - color: rgba(255, 255, 255, 0.5); - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 6px; - padding: 2px 8px; - white-space: nowrap; -} -.providerWarning { - margin: 4px 0 8px; - font-size: 12px; - line-height: 1.45; - color: #f0b27a; -} -.providerHint { - font-size: 12px; - color: rgba(255, 255, 255, 0.5); -} diff --git a/src/testUtils/README.md b/src/testUtils/README.md index 0f3c269c..f9cfa5be 100644 --- a/src/testUtils/README.md +++ b/src/testUtils/README.md @@ -114,12 +114,12 @@ To mock a new module (e.g., a third-party library): ```typescript import { invoke } from '../../testUtils/mocks/tauri'; -it('calls ask_model on submit', async () => { +it('calls ask_ollama on submit', async () => { render(); fireEvent.change(textarea, { target: { value: 'hello' } }); fireEvent.keyDown(textarea, { key: 'Enter' }); - expect(invoke).toHaveBeenCalledWith('ask_model', expect.objectContaining({ + expect(invoke).toHaveBeenCalledWith('ask_ollama', expect.objectContaining({ prompt: 'hello' })); }); diff --git a/src/utils/__tests__/isNonLocalUrl.test.ts b/src/utils/__tests__/isNonLocalUrl.test.ts deleted file mode 100644 index 71454b40..00000000 --- a/src/utils/__tests__/isNonLocalUrl.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { isNonLocalUrl } from '../isNonLocalUrl'; - -describe('isNonLocalUrl', () => { - it('treats localhost and loopback as local', () => { - expect(isNonLocalUrl('http://localhost:11434')).toBe(false); - expect(isNonLocalUrl('http://127.0.0.1:11434')).toBe(false); - expect(isNonLocalUrl('http://[::1]:11434')).toBe(false); - expect(isNonLocalUrl('http://api.localhost:11434')).toBe(false); - }); - - it('treats the whole 127.0.0.0/8 loopback block as local', () => { - // Only 127.0.0.1 used to be matched; the rest of the /8 is also loopback. - expect(isNonLocalUrl('http://127.0.0.2:11434')).toBe(false); - expect(isNonLocalUrl('http://127.5.5.5:11434')).toBe(false); - }); - - it('treats only ::1 as local among IPv6 literals', () => { - expect(isNonLocalUrl('http://[2001:db8::1]:11434')).toBe(true); - expect(isNonLocalUrl('http://[fe80::1]:11434')).toBe(true); - }); - - it('treats RFC1918 and link-local ranges as local', () => { - expect(isNonLocalUrl('http://192.168.1.50:11434')).toBe(false); - expect(isNonLocalUrl('http://10.0.0.2:11434')).toBe(false); - expect(isNonLocalUrl('http://172.16.0.5:11434')).toBe(false); - expect(isNonLocalUrl('http://172.31.255.1:11434')).toBe(false); - expect(isNonLocalUrl('http://169.254.1.1:11434')).toBe(false); - }); - - it('flags public hosts as non-local', () => { - expect(isNonLocalUrl('http://example.com:11434')).toBe(true); - expect(isNonLocalUrl('http://8.8.8.8:11434')).toBe(true); - expect(isNonLocalUrl('https://ollama.my-server.net')).toBe(true); - // 172.32 is outside the 172.16-31 private block. - expect(isNonLocalUrl('http://172.32.0.1:11434')).toBe(true); - }); - - it('does not let a private-prefixed DNS name suppress the warning', () => { - // The private-range check must only apply to true IPv4 literals; a public - // domain that merely starts with a private prefix is still remote. - expect(isNonLocalUrl('http://192.168.1.1.evil.com:11434')).toBe(true); - expect(isNonLocalUrl('http://10.0.0.1.attacker.io:11434')).toBe(true); - }); - - it('treats malformed or empty input as local (no warning)', () => { - expect(isNonLocalUrl('')).toBe(false); - expect(isNonLocalUrl('not a url')).toBe(false); - }); -}); diff --git a/src/utils/isNonLocalUrl.ts b/src/utils/isNonLocalUrl.ts deleted file mode 100644 index 31868925..00000000 --- a/src/utils/isNonLocalUrl.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Returns true when `url` points at a non-local host: not localhost, not a - * loopback address, and not an RFC1918 / link-local private range. Drives the - * Providers settings warning that a remote Ollama server is the user's - * responsibility to secure (Ollama has no built-in auth). - * - * Malformed or empty URLs are treated as local (no warning): the field may - * still be mid-edit, and the backend normalizes anything unusable. - * - * Private/loopback IPv4 ranges are matched only when the host is a complete - * dotted-quad literal, so a DNS name that merely begins with a private prefix - * (`192.168.1.1.evil.com`) is correctly treated as remote and still warns. - */ -export function isNonLocalUrl(url: string): boolean { - let hostname: string; - try { - hostname = new URL(url).hostname.toLowerCase(); - } catch { - return false; - } - // URL.hostname keeps the brackets around IPv6 literals; strip them. - const host = hostname.replace(/^\[/, '').replace(/\]$/, ''); - - // localhost is reserved to loopback (RFC6761), as is any `*.localhost` name. - if (host === 'localhost' || host.endsWith('.localhost')) { - return false; - } - - // IPv6 literals contain a colon (the port is not part of URL.hostname). - // Only loopback `::1` is local; every other IPv6 address is treated as - // remote so the warning still fires. - if (host.includes(':')) { - return host !== '::1'; - } - - // IPv4 literal: apply the loopback/private ranges. A non-IPv4 DNS name - // falls through to the remote verdict below. - if (isIpv4Literal(host)) { - return !isPrivateIpv4(host); - } - - return true; -} - -/** - * True when `host` is a four-group dotted-decimal IPv4 literal. The octet - * ranges are not re-validated here: a host this shape only reaches us via - * `URL.hostname`, which already rejects out-of-range IPv4 literals. - */ -function isIpv4Literal(host: string): boolean { - return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host); -} - -/** - * True when an IPv4 literal falls in a loopback or private range: the whole - * 127.0.0.0/8 loopback block, RFC1918 (10/8, 172.16-31/16, 192.168/16), or - * 169.254/16 link-local. Callers must pass a value that already satisfies - * {@link isIpv4Literal}, so the unanchored prefixes are safe. - */ -function isPrivateIpv4(host: string): boolean { - return ( - /^127\./.test(host) || - /^10\./.test(host) || - /^192\.168\./.test(host) || - /^169\.254\./.test(host) || - /^172\.(1[6-9]|2\d|3[0-1])\./.test(host) - ); -} diff --git a/src/utils/replaceSelection.ts b/src/utils/replaceSelection.ts index 0bbadd06..9df6fde6 100644 --- a/src/utils/replaceSelection.ts +++ b/src/utils/replaceSelection.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core'; -import type { Message } from '../hooks/useModel'; +import type { Message } from '../hooks/useOllama'; /** * Writes a `/rewrite` or `/refine` result back into the source app, replacing diff --git a/src/view/ConversationView.tsx b/src/view/ConversationView.tsx index ff85c0e7..bcbc0e3d 100644 --- a/src/view/ConversationView.tsx +++ b/src/view/ConversationView.tsx @@ -3,7 +3,7 @@ import { useRef, useEffect } from 'react'; import { ChatBubble } from '../components/ChatBubble'; import { LoadingStage } from '../components/LoadingStage'; import { WindowControls } from '../components/WindowControls'; -import type { Message } from '../hooks/useModel'; +import type { Message } from '../hooks/useOllama'; import type { SearchStage } from '../types/search'; /**