diff --git a/README.md b/README.md index d02ad1d29..4612dfb38 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Meet **Mochi**: a soft little squircle with big eyes that pops out of your notch - See what Claude is editing, live in the notch: each file modification shows the file name and +N βˆ’M counts in the ticker, tap to read the full diff - βœ… **Approve and answer from the notch** β€” Claude Code permission requests show up with **Allow / Deny / Always**; `AskUserQuestion` prompts show the choices right in the notch (single or multi-select, up to 4 questions). One click, or "Reply in terminal" to fall back to the CLI. Codex also gets Allow / Deny. - πŸ§‘β€πŸ’» **Jump to the right terminal** β€” open the exact terminal window of a session *(macOS)*. -- πŸ’¬ **Chat with Claude, Gemini, OpenAI, or a local model (Ollama / LM Studio)** β€” click the model name above the chat box to switch provider and pick a model. Cloud providers use your own API key; local providers connect to a server running on your Mac. *(Gemini, OpenAI and local models: macOS)* +- πŸ’¬ **Chat with Claude, Gemini, OpenAI, or a local model (Ollama / LM Studio)** β€” click the model name above the chat box to switch provider and pick a model. Cloud providers use your own API key; local providers connect to a server running on your Mac. *(Gemini, OpenAI and LM Studio: macOS; Ollama: macOS, Windows, Linux)* - πŸ“Š **Claude plan usage** *(macOS, GitHub build)* β€” a small pill in the notch header shows your 5-hour and weekly Claude plan limits. Enable it from Settings β†’ Agents β†’ Plan usage. Pro and Max plans only. - πŸ“‹ **Declare the tools you use** β€” open Settings β†’ Active pills and pick your main workspace tool (VS Code, Cursor, Codex or Antigravity), then toggle up to 4 more: Gemini CLI, Anthropic, Google AI, OpenAI, Ollama, LM Studio and service integrations *(macOS)*. - πŸ“Ž **Drop a file on the notch** β€” Mochi turns into a box and swallows it, then ask a question about it or send it by email *(email: macOS, Mail.app)*. @@ -154,7 +154,7 @@ The first Linux build is out as a beta: download it from [Coucou for Linux 0.1.1 - **Debian / Ubuntu**: `sudo apt install ./Coucou-Linux-*.deb` - **Fedora / openSUSE**: `sudo dnf install ./Coucou-Linux-*.rpm` -Check a download with `sha256sum -c SHA256SUMS --ignore-missing`. Gemini CLI, Antigravity, Google AI, OpenAI and local model (Ollama / LM Studio) chat are macOS only for now. +Check a download with `sha256sum -c SHA256SUMS --ignore-missing`. Gemini CLI, Antigravity, Google AI, OpenAI and LM Studio chat are macOS only for now. Ollama chat works on Linux too (Settings β†’ Ollama (local)). The island sits on the top edge on compositors with layer-shell β€” COSMIC, KDE Plasma, Hyprland, Sway and other wlroots compositors. GNOME has no layer-shell, @@ -208,7 +208,7 @@ Click the Coucou icon in the menu bar (macOS) or in the system tray (Windows, Li | **Anthropic API key** | chat and questions about files | Settings β†’ Anthropic API Β· Keychain / Windows Credential Manager / Secret Service | | **Google AI API key** *(macOS)* | chat with Google AI (Gemini) | Settings β†’ Chat β€” other providers Β· Keychain | | **OpenAI API key** *(macOS)* | chat with OpenAI | Settings β†’ Chat β€” other providers Β· Keychain | -| **Ollama server** *(macOS)* | chat with local models via Ollama | Settings β†’ Chat β†’ Local models β†’ **Connect** | +| **Ollama server** *(macOS, Windows, Linux)* | chat with local models via Ollama | macOS: Settings β†’ Chat β†’ Local models β†’ **Connect** Β· Windows, Linux: Settings β†’ Ollama (local) | | **LM Studio server** *(macOS)* | chat with local models via LM Studio | Settings β†’ Chat β†’ Local models β†’ **Connect** | | **iPhone** *(macOS)* | sessions, approvals, questions and Mochi on your iPhone | Settings β†’ General β†’ iPhone Β· your private iCloud, see [docs/IPHONE.md](docs/IPHONE.md) | | **Active pills** *(macOS)* | choose which tools and agents appear in the island | Settings β†’ Active pills | diff --git a/windows/README.md b/windows/README.md index 60374024c..c4318a5af 100644 --- a/windows/README.md +++ b/windows/README.md @@ -74,6 +74,13 @@ It works from any terminal β€” Windows Terminal, PowerShell, VS Code, Git Bash. Credential Manager**, never on disk and never in the interface β€” the island can only ask whether a key exists. Same for every integration key. +**Settings… β†’ Ollama (local)** runs the chat on a local model instead: turn on +*Use for chat*, set the server URL (empty uses `OLLAMA_HOST`, else +`http://127.0.0.1:11434`) and pick an installed model. No key is needed. Text +files and images can be dropped on the island; PDFs are not supported by local +models. `localhost` is pinned to `127.0.0.1` because Ollama on Windows listens +on IPv4 only. + No telemetry. The only network requests Coucou makes are to the services you configure yourself. diff --git a/windows/src-tauri/src/claude.rs b/windows/src-tauri/src/claude.rs index 060a57c64..c5d1ba20c 100644 --- a/windows/src-tauri/src/claude.rs +++ b/windows/src-tauri/src/claude.rs @@ -31,11 +31,14 @@ No markdown formatting (no **, no ##, no bullet dashes). Use plain text with lin pub struct Chat { /// Full multi-turn history, including tool_use / tool_result blocks. messages: Mutex>, + /// Plain role/content history for local (Ollama) models. + pub(crate) local: Mutex>, } impl Chat { pub fn reset(&self) { self.messages.lock().unwrap().clear(); + self.local.lock().unwrap().clear(); } fn is_empty(&self) -> bool { diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 714103e59..b9bef338b 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod hooks; mod integrations; mod island; mod log; +mod ollama; mod pipe; mod platform; mod secrets; @@ -241,10 +242,22 @@ async fn chat_send( query: String, context: Option, ) -> Result { - let model = shared.settings.lock().unwrap().model.clone(); + let (provider, model, ollama_url, ollama_model) = { + let s = shared.settings.lock().unwrap(); + (s.provider.clone(), s.model.clone(), s.ollama_url.clone(), s.ollama_model.clone()) + }; + if provider == "ollama" { + return ollama::send(&chat, &ollama_url, &ollama_model, query, context).await; + } claude::send(&chat, &model, query, context).await } +/// Models installed on the Ollama server, for the settings dropdown. +#[tauri::command] +async fn ollama_models(url: String) -> Result, String> { + ollama::models(&url).await +} + #[tauri::command] fn chat_reset(chat: State) { chat.reset(); @@ -393,6 +406,7 @@ pub fn run() { log_line, chat_send, chat_reset, + ollama_models, ingest_file, secret_present, secret_set, diff --git a/windows/src-tauri/src/ollama.rs b/windows/src-tauri/src/ollama.rs new file mode 100644 index 000000000..b781e745e --- /dev/null +++ b/windows/src-tauri/src/ollama.rs @@ -0,0 +1,302 @@ +// Ollama client: chat and model list over Ollama's native API (/api/chat, /api/tags). + +use std::time::Duration; + +use reqwest::Url; +use serde_json::{json, Value}; + +use crate::claude::{base64_for, Chat, ChatContext, ChatReply}; + +const DEFAULT_PORT: u16 = 11434; +const MAX_TOKENS: u32 = 4096; +const MAX_INLINE_TEXT: u64 = 200_000; + +const SYSTEM_PROMPT: &str = "You are Mochi, a personal AI assistant living at the top of the user's screen. \ +You have no internet access. Respond in the user's language. Be clear and concise. \ +No markdown formatting (no **, no ##, no bullet dashes). Use plain text with line breaks."; + +/// Accepts what people actually type or have in OLLAMA_HOST: "localhost", "0.0.0.0:11434", +/// "http://host/api", trailing slashes. On Windows "localhost" can resolve to ::1 first while +/// Ollama only listens on 127.0.0.1, so loopback names are pinned to 127.0.0.1. +pub fn normalise_url(raw: &str) -> Result { + let mut s = raw.trim().to_string(); + if s.is_empty() { + s = std::env::var("OLLAMA_HOST").unwrap_or_default().trim().to_string(); + } + if s.is_empty() { + s = format!("http://127.0.0.1:{DEFAULT_PORT}"); + } + if !s.contains("://") { + s = format!("http://{s}"); + } + let mut url = Url::parse(&s).map_err(|_| format!("Invalid Ollama URL: {raw}"))?; + + let host = url.host_str().unwrap_or("").to_lowercase(); + let loopback = matches!(host.as_str(), "localhost" | "0.0.0.0" | "127.0.0.1" | "[::]" | "[::1]"); + if loopback { + let _ = url.set_host(Some("127.0.0.1")); + if url.port().is_none() && url.scheme() == "http" { + let _ = url.set_port(Some(DEFAULT_PORT)); + } + } + + let mut path = url.path().trim_end_matches('/').to_string(); + for suffix in ["/api", "/v1"] { + if path.ends_with(suffix) { + path.truncate(path.len() - suffix.len()); + } + } + url.set_path(&path); + url.set_query(None); + Ok(url) +} + +fn endpoint(base: &Url, tail: &str) -> String { + format!("{}{}", base.as_str().trim_end_matches('/'), tail) +} + +fn client(base: &Url, timeout: Duration) -> Result { + let mut builder = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(timeout); + if base.host_str() == Some("127.0.0.1") { + builder = builder.no_proxy(); + } + builder.build().map_err(|e| e.to_string()) +} + +fn unreachable_msg(base: &Url) -> String { + format!( + "Cannot reach Ollama at {}. Start it (Ollama app or `ollama serve`) and check the URL in settings.", + base.as_str().trim_end_matches('/') + ) +} + +fn api_error(text: &str, status: reqwest::StatusCode, model: &str) -> String { + let detail = serde_json::from_str::(text) + .ok() + .and_then(|v| v.get("error").and_then(Value::as_str).map(str::to_string)) + .unwrap_or_else(|| text.chars().take(200).collect()); + if status == reqwest::StatusCode::NOT_FOUND && detail.to_lowercase().contains("not found") { + return format!("Model '{model}' is not installed. Run: ollama pull {model}"); + } + format!("Ollama {status}: {detail}") +} + +const EXCLUDED: [&str; 5] = ["embed", "bge-", "all-minilm", "clip", "rerank"]; + +pub async fn models(url: &str) -> Result, String> { + let base = normalise_url(url)?; + let response = client(&base, Duration::from_secs(8))? + .get(endpoint(&base, "/api/tags")) + .send() + .await + .map_err(|_| unreachable_msg(&base))?; + let status = response.status(); + let text = response.text().await.map_err(|e| e.to_string())?; + if !status.is_success() { + return Err(api_error(&text, status, "")); + } + let v: Value = serde_json::from_str(&text).map_err(|_| "Unexpected reply from Ollama.".to_string())?; + let mut names: Vec = v + .get("models") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|m| m.get("name").and_then(Value::as_str)) + .filter(|n| { + let lower = n.to_lowercase(); + !EXCLUDED.iter().any(|x| lower.contains(x)) + }) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + names.sort(); + Ok(names) +} + +fn strip_thinking(text: &str) -> String { + let mut out = String::new(); + let mut rest = text; + while let Some(start) = rest.find("") { + out.push_str(&rest[..start]); + match rest[start..].find("") { + Some(end) => rest = &rest[start + end + "".len()..], + None => { + rest = ""; + break; + } + } + } + out.push_str(rest); + out.trim().to_string() +} + +struct Attachment { + text: Option, + image: Option, +} + +fn read_attachment(path: &str) -> Result { + let ext = std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + match ext.as_str() { + "png" | "jpg" | "jpeg" | "gif" | "webp" => { + let bytes = std::fs::read(path).map_err(|e| format!("Could not read the file: {e}"))?; + Ok(Attachment { text: None, image: Some(base64_for(&bytes)) }) + } + "pdf" => Err("Ollama can't read PDF files. Drop a text file or an image.".into()), + _ => { + let len = std::fs::metadata(path).map_err(|e| e.to_string())?.len(); + if len > MAX_INLINE_TEXT { + return Err("That file is too large for the local model.".into()); + } + let text = std::fs::read_to_string(path) + .map_err(|_| "Ollama can only read text files and images.".to_string())?; + Ok(Attachment { text: Some(format!("File contents:\n{text}")), image: None }) + } + } +} + +pub async fn send( + chat: &Chat, + url: &str, + model: &str, + query: String, + context: Option, +) -> Result { + if model.trim().is_empty() { + return Err("No Ollama model selected. Open settings.".into()); + } + let base = normalise_url(url)?; + + let first = chat.local.lock().unwrap().is_empty(); + let mut text = String::new(); + let mut images: Vec = Vec::new(); + if first { + match &context { + Some(ChatContext::File { name, path }) => { + let att = read_attachment(path)?; + text.push_str(&format!("File: {name}\n")); + if let Some(t) = att.text { + text.push_str(&t); + text.push_str("\n\n"); + } + if let Some(i) = att.image { + images.push(i); + } + } + Some(ChatContext::Window { app_name, title, url }) => { + text.push_str(&format!("Context β€” App: {app_name}, Window: {title}")); + if let Some(url) = url { + text.push_str(&format!(", URL: {url}")); + } + text.push_str("\n\n"); + } + None => {} + } + } + text.push_str(&query); + + let mut user = json!({ "role": "user", "content": text }); + if !images.is_empty() { + user["images"] = json!(images); + } + chat.local.lock().unwrap().push(user); + + let mut messages = vec![json!({ "role": "system", "content": SYSTEM_PROMPT })]; + messages.extend(chat.local.lock().unwrap().iter().cloned()); + let body = json!({ + "model": model, + "messages": messages, + "stream": false, + "options": { "num_predict": MAX_TOKENS }, + }); + + match request(&base, model, &body).await { + Ok(reply) => { + chat.local + .lock() + .unwrap() + .push(json!({ "role": "assistant", "content": reply })); + Ok(ChatReply { text: reply }) + } + Err(err) => { + chat.local.lock().unwrap().pop(); + Err(err) + } + } +} + +async fn request(base: &Url, model: &str, body: &Value) -> Result { + let response = client(base, Duration::from_secs(300))? + .post(endpoint(base, "/api/chat")) + .json(body) + .send() + .await + .map_err(|e| { + if e.is_timeout() { + "Ollama took too long to answer. The model may still be loading; try again.".to_string() + } else { + unreachable_msg(base) + } + })?; + let status = response.status(); + let text = response.text().await.map_err(|e| e.to_string())?; + if !status.is_success() { + return Err(api_error(&text, status, model)); + } + let v: Value = serde_json::from_str(&text).map_err(|_| "Unexpected reply from Ollama.".to_string())?; + let content = v + .get("message") + .and_then(|m| m.get("content")) + .and_then(Value::as_str) + .unwrap_or(""); + let cleaned = strip_thinking(content); + if cleaned.is_empty() { + return Err("No response text.".into()); + } + Ok(cleaned) +} + +#[cfg(test)] +mod tests { + use super::{normalise_url, strip_thinking}; + + fn norm(s: &str) -> String { + normalise_url(s).unwrap().as_str().trim_end_matches('/').to_string() + } + + #[test] + fn pins_loopback_to_ipv4() { + assert_eq!(norm("http://localhost:11434"), "http://127.0.0.1:11434"); + assert_eq!(norm("localhost"), "http://127.0.0.1:11434"); + assert_eq!(norm("0.0.0.0:11434"), "http://127.0.0.1:11434"); + assert_eq!(norm("http://[::1]:11434"), "http://127.0.0.1:11434"); + } + + #[test] + fn strips_api_suffixes_and_slashes() { + assert_eq!(norm("http://127.0.0.1:11434/"), "http://127.0.0.1:11434"); + assert_eq!(norm("http://127.0.0.1:11434/v1"), "http://127.0.0.1:11434"); + assert_eq!(norm("http://box.lan:8080/api/"), "http://box.lan:8080"); + } + + #[test] + fn keeps_remote_hosts() { + assert_eq!(norm("https://ollama.example.com"), "https://ollama.example.com"); + assert_eq!(norm("192.168.1.20:11434"), "http://192.168.1.20:11434"); + } + + #[test] + fn removes_think_blocks() { + assert_eq!(strip_thinking("xhola"), "hola"); + assert_eq!(strip_thinking("hola sin cerrar"), "hola"); + assert_eq!(strip_thinking("axbyc"), "abc"); + } +} diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 2a8d7d359..69b80480b 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -20,6 +20,18 @@ pub struct Settings { /// Defaulted explicitly so a settings.json written by an older build still loads. #[serde(default = "default_model")] pub model: String, + /// "anthropic" or "ollama". + #[serde(default = "default_provider")] + pub provider: String, + /// Empty means "use OLLAMA_HOST, else http://127.0.0.1:11434". + #[serde(default)] + pub ollama_url: String, + #[serde(default)] + pub ollama_model: String, +} + +fn default_provider() -> String { + "anthropic".to_string() } fn default_model() -> String { @@ -43,6 +55,9 @@ impl Default for Settings { autostart: false, hooks_installed: false, model: default_model(), + provider: default_provider(), + ollama_url: String::new(), + ollama_model: String::new(), } } } diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff..a2036c8e5 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -85,6 +85,8 @@ export const Bridge = { chatSend: (query: string, context: ChatContext | null) => callOrThrow<{ text: string }>("chat_send", { query, context }), chatReset: () => call("chat_reset"), + /** Models installed on the Ollama server. */ + ollamaModels: (url: string) => callOrThrow("ollama_models", { url }), /** Copies a dropped file into the inbox. */ ingestFile: (path: string) => callOrThrow("ingest_file", { path }), /** Only ever tells you whether a key exists β€” never its value. */ diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a..4df419ea5 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -92,6 +92,10 @@ export interface Settings { hooksInstalled: boolean; /** Claude model used by the chat. */ model: string; + provider: "anthropic" | "ollama"; + /** Empty = OLLAMA_HOST, else http://127.0.0.1:11434. */ + ollamaUrl: string; + ollamaModel: string; } export const DEFAULT_SETTINGS: Settings = { @@ -106,6 +110,9 @@ export const DEFAULT_SETTINGS: Settings = { autostart: false, hooksInstalled: false, model: "claude-opus-5", + provider: "anthropic", + ollamaUrl: "", + ollamaModel: "", }; type Listener = () => void; diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..ce3a45426 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -254,6 +254,87 @@ function apiSection(hasKey: boolean): HTMLElement { ); } +// ── Ollama section ──────────────────────────────────────────────────────────── + +function ollamaSection(): HTMLElement { + const dot = statusDot(false); + const state = h("span", { class: "hint", text: "Run the chat on a local model. Nothing leaves your PC." }); + const feedback = h("div", {}); + + const useIt = toggle(settings.provider === "ollama", (on) => { + settings.provider = on ? "ollama" : "anthropic"; + void Bridge.chatReset(); + void save(); + }); + + const url = h("input", { + type: "text", + value: settings.ollamaUrl, + placeholder: "http://127.0.0.1:11434", + style: "flex:1 1 auto;min-width:0", + autocomplete: "off", + spellcheck: "false", + }) as HTMLInputElement; + + const model = h("select", { style: "flex:1 1 auto;min-width:0" }) as HTMLSelectElement; + const refreshBtn = h("button", { class: "primary", text: "Refresh" }); + + function fill(names: string[]) { + clear(model); + if (settings.ollamaModel && !names.includes(settings.ollamaModel)) names = [settings.ollamaModel, ...names]; + if (names.length === 0) model.append(h("option", { value: "", text: "No models installed" })); + for (const n of names) model.append(h("option", { value: n, text: n })); + if (!settings.ollamaModel && names.length > 0) { + settings.ollamaModel = names[0]; + void save(); + } + model.value = settings.ollamaModel; + } + + async function refresh() { + clear(feedback); + try { + const names = await Bridge.ollamaModels(url.value.trim()); + dot.style.background = "#22c55e"; + fill(names); + if (names.length === 0) { + feedback.append(h("div", { class: "notice err", text: "Ollama is running but has no models. Run: ollama pull llama3.2" })); + } + } catch (err) { + dot.style.background = "#f4505e"; + fill([]); + feedback.append(h("div", { class: "notice err", text: String(err).replace(/^Error:\s*/, "") })); + } + } + + url.addEventListener("change", () => { + settings.ollamaUrl = url.value.trim(); + void Bridge.chatReset(); + void save(); + void refresh(); + }); + model.addEventListener("change", () => { + settings.ollamaModel = model.value; + void Bridge.chatReset(); + void save(); + }); + refreshBtn.addEventListener("click", () => void refresh()); + + fill([]); + if (settings.provider === "ollama") void refresh(); + + return h( + "section", + {}, + h("h2", {}, dot, h("span", { text: "Ollama (local)" })), + state, + h("div", { class: "row" }, h("label", { text: "Use for chat" }), useIt), + h("div", { class: "row" }, h("label", { text: "Server URL" }), url), + h("div", { class: "row" }, h("label", { text: "Model" }), model, refreshBtn), + feedback, + ); +} + // ── Integrations section ────────────────────────────────────────────────────── interface IntegrationDef { @@ -443,6 +524,7 @@ async function main() { h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })), claudeSection(status), apiSection(hasKey), + ollamaSection(), integrationsSection(present), generalSection(), h("div", {