From a416bfadbf434d609370273ad10bec96ddbb16e3 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:24:56 +0300 Subject: [PATCH] feat: add Ollama chat provider Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- NotchBuddy/Sources/App/ClaudeService.swift | 106 ++++++++++---- NotchBuddy/Sources/App/SettingsView.swift | 22 ++- docs/INTEGRATIONS.md | 12 ++ windows/README.md | 15 +- windows/src-tauri/src/claude.rs | 161 ++++++++++++++++++--- windows/src-tauri/src/lib.rs | 12 +- windows/src-tauri/src/settings.rs | 16 ++ windows/src/core/state.ts | 6 + windows/src/settings/main.ts | 50 ++++++- 9 files changed, 333 insertions(+), 67 deletions(-) diff --git a/NotchBuddy/Sources/App/ClaudeService.swift b/NotchBuddy/Sources/App/ClaudeService.swift index 503582be6..5c8701c37 100644 --- a/NotchBuddy/Sources/App/ClaudeService.swift +++ b/NotchBuddy/Sources/App/ClaudeService.swift @@ -108,9 +108,15 @@ final class ClaudeService { private let endpoint = URL(string: "https://api.anthropic.com/v1/messages")! private let anthropicVersion = "2023-06-01" - private let model = "claude-sonnet-4-6" + private let defaultModel = "claude-sonnet-4-6" + private let defaultOllamaEndpoint = "http://127.0.0.1:11434/api/chat" var apiKey: String? { KeychainStore.shared.get("anthropic-api-key") } + var provider: String { UserDefaults.standard.string(forKey: "chatProvider") ?? "anthropic" } + var model: String { UserDefaults.standard.string(forKey: "chatModel") ?? defaultModel } + var ollamaEndpoint: String { + UserDefaults.standard.string(forKey: "ollamaEndpoint") ?? defaultOllamaEndpoint + } // Multi-turn conversation messages (for API) private var conversationMessages: [[String: Any]] = [] @@ -133,7 +139,7 @@ final class ClaudeService { // MARK: - Chat (multi-turn, natural text + web search) func chat(query: String, context: PromptContext?, state: AppState) async { - guard let key = apiKey, !key.isEmpty else { + guard provider == "ollama" || (apiKey?.isEmpty == false) else { await showError("API key missing. Open settings.", state: state) return } @@ -159,16 +165,10 @@ final class ClaudeService { conversationMessages.append(["role": "user", "content": userContent]) - let body: [String: Any] = [ - "model": model, - "max_tokens": 4096, - "tools": webSearchTools, - "system": systemPrompt, - "messages": conversationMessages, - ] + let body = requestBody(messages: conversationMessages, tools: webSearchTools) do { - let data = try await callAPI(body: body, key: key, beta: "web-search-2025-03-05") + let data = try await callAPI(body: body, beta: "web-search-2025-03-05") await handleChatResult(data, state: state) } catch { conversationMessages.removeLast() @@ -179,7 +179,7 @@ final class ClaudeService { // MARK: - Structured search (M8 — window attach + web search) func search(query: String, context: PromptContext?, state: AppState) async { - guard let key = apiKey, !key.isEmpty else { + guard provider == "ollama" || (apiKey?.isEmpty == false) else { await showError("Anthropic API key missing. Open settings to configure it.", state: state) return } @@ -211,16 +211,25 @@ final class ClaudeService { ["type": "web_search_20250305", "name": "web_search", "max_uses": 3] ] - let body: [String: Any] = [ - "model": model, - "max_tokens": 1024, - "tools": tools, - "system": system, - "messages": [["role": "user", "content": userContent]], - ] + let body: [String: Any] + if provider == "ollama" { + body = [ + "model": model, + "stream": false, + "messages": [["role": "user", "content": userContent.compactMap { $0["text"] as? String }.joined(separator: "\n")]], + ] + } else { + body = [ + "model": model, + "max_tokens": 1024, + "tools": tools, + "system": system, + "messages": [["role": "user", "content": userContent]], + ] + } do { - let result = try await callAPI(body: body, key: key, beta: "web-search-2025-03-05") + let result = try await callAPI(body: body, beta: "web-search-2025-03-05") await handleResult(result, state: state) } catch { await showError("Network error: \(error.localizedDescription)", state: state) @@ -229,13 +238,17 @@ final class ClaudeService { // MARK: - API call - private func callAPI(body: [String: Any], key: String, beta: String? = nil) async throws -> Data { - var request = URLRequest(url: endpoint) + private func callAPI(body: [String: Any], beta: String? = nil) async throws -> Data { + let url = provider == "ollama" ? URL(string: ollamaEndpoint) : endpoint + guard let url else { throw NSError(domain: "Chat", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid chat endpoint."]) } + var request = URLRequest(url: url) request.httpMethod = "POST" - request.setValue(key, forHTTPHeaderField: "x-api-key") - request.setValue(anthropicVersion, forHTTPHeaderField: "anthropic-version") + if provider != "ollama", let key = apiKey { + request.setValue(key, forHTTPHeaderField: "x-api-key") + request.setValue(anthropicVersion, forHTTPHeaderField: "anthropic-version") + } request.setValue("application/json", forHTTPHeaderField: "content-type") - if let beta { request.setValue(beta, forHTTPHeaderField: "anthropic-beta") } + if provider != "ollama", let beta { request.setValue(beta, forHTTPHeaderField: "anthropic-beta") } request.httpBody = try JSONSerialization.data(withJSONObject: body) request.timeoutInterval = 45 @@ -248,14 +261,39 @@ final class ClaudeService { return data } + private func requestBody(messages: [[String: Any]], tools: [[String: Any]]) -> [String: Any] { + if provider == "ollama" { + let flattened = messages.map { message -> [String: Any] in + let content = (message["content"] as? [[String: Any]]) ?? [] + let text = content.compactMap { $0["text"] as? String }.joined(separator: "\n") + return ["role": message["role"] ?? "user", "content": text] + } + return ["model": model, "stream": false, "messages": flattened] + } + return ["model": model, "max_tokens": 4096, "tools": tools, "system": systemPrompt, "messages": messages] + } + // MARK: - Chat result handler private func handleChatResult(_ data: Data, state: AppState) async { - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let content = json["content"] as? [[String: Any]] else { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { await showError("Unexpected API response.", state: state) return } + let content: [[String: Any]] + if provider == "ollama" { + guard let text = (json["message"] as? [String: Any])?["content"] as? String, !text.isEmpty else { + await showError("Unexpected Ollama response.", state: state) + return + } + content = [["type": "text", "text": text]] + } else { + guard let anthropicContent = json["content"] as? [[String: Any]] else { + await showError("Unexpected API response.", state: state) + return + } + content = anthropicContent + } // Store full content (includes tool_use/tool_result blocks) for correct multi-turn context conversationMessages.append(["role": "assistant", "content": content]) @@ -278,10 +316,18 @@ final class ClaudeService { private func handleResult(_ data: Data, state: AppState) async { // Extract text from Anthropic response (may contain tool_use / web_search_tool_result blocks) - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let content = json["content"] as? [[String: Any]], - let textBlock = content.first(where: { $0["type"] as? String == "text" }), - let text = textBlock["text"] as? String else { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + await showError("Unexpected API response.", state: state) + return + } + let text: String? + if provider == "ollama" { + text = (json["message"] as? [String: Any])?["content"] as? String + } else { + let content = json["content"] as? [[String: Any]] + text = content?.first(where: { $0["type"] as? String == "text" })?["text"] as? String + } + guard let text else { await showError("Unexpected API response.", state: state) return } diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index 7114f30c9..29a4376b0 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -47,8 +47,28 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 18) { // MARK: API - GroupBox("Anthropic API") { + GroupBox("Chat provider") { VStack(alignment: .leading, spacing: 8) { + Picker("Provider", selection: Binding( + get: { UserDefaults.standard.string(forKey: "chatProvider") ?? "anthropic" }, + set: { UserDefaults.standard.set($0, forKey: "chatProvider") } + )) { + Text("Anthropic (cloud)").tag("anthropic") + Text("Ollama (local)").tag("ollama") + } + TextField("Model", text: Binding( + get: { UserDefaults.standard.string(forKey: "chatModel") ?? "claude-sonnet-4-6" }, + set: { UserDefaults.standard.set($0, forKey: "chatModel") } + )) + .textFieldStyle(.roundedBorder) + TextField( + "Ollama endpoint", + text: Binding( + get: { UserDefaults.standard.string(forKey: "ollamaEndpoint") ?? "http://127.0.0.1:11434/api/chat" }, + set: { UserDefaults.standard.set($0, forKey: "ollamaEndpoint") } + ) + ) + .textFieldStyle(.roundedBorder) SecureField("API key (sk-ant-…)", text: $apiKey) .textFieldStyle(.roundedBorder) Button("Save") { diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index d59860164..28d255187 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -5,6 +5,9 @@ Règle d'or : **vérifier la doc officielle au moment d'implémenter**. Les form - API Claude (Messages, outil de recherche web, modèles) : https://docs.claude.com/en/api/overview - API publique n8n : `{URL de l'instance}/api/v1/docs` (playground de l'instance de Louis) +Le fournisseur Ollama décrit ci-dessous concerne uniquement le chat intégré de +Coucou. Il ne transforme pas Claude Code en client Ollama. + --- ## 1. Claude Code (sessions de Louis) @@ -19,6 +22,9 @@ claude (terminal, VS Code, app Claude) - `nb-hook` : cible séparée dans le projet, copiée dans `~/Library/Application Support/NotchBuddy/bin/nb-hook` au premier lancement. - Socket : `~/Library/Application Support/NotchBuddy/nb.sock`. - `nb-hook ` lit le JSON du hook sur stdin, ajoute le contexte du terminal (`TERM_PROGRAM`, `ITERM_SESSION_ID`, `TERM_SESSION_ID`, `__CFBundleIdentifier`, le tty trouvé en remontant les processus parents, `cwd`), l'envoie à l'app. +- Le hook observe et relaie un processus Claude Code déjà lancé ; il ne lance, + ne pilote et ne remplace jamais ce processus. Si Coucou est fermé ou lent, + Claude Code reprend son comportement normal dans le terminal. - **Si l'app ne répond pas en 300 ms, `nb-hook` sort en code 0 sans rien écrire** : Claude Code continue normalement. Jamais de blocage. ### Événements à brancher et état du bonhomme @@ -110,6 +116,12 @@ Permissions : Enregistrement de l'écran (capture) et Automatisation (navigateur ## 5. API Claude (recherche) +Le chat et les questions sur fichier peuvent aussi utiliser un serveur Ollama +local compatible avec `/api/chat`. L'endpoint par défaut est +`http://127.0.0.1:11434/api/chat`, et le modèle est réglable dans les réglages. +Ollama ne demande pas de clé et reçoit des messages texte ; les pièces jointes +non textuelles ne sont donc pas envoyées comme images ou PDF à ce fournisseur. + - `POST https://api.anthropic.com/v1/messages`, en-têtes `x-api-key`, `anthropic-version`, `content-type: application/json` (versions à vérifier dans la doc). - Modèle par défaut : `claude-sonnet-5`, réglable dans les réglages. Vérifier la liste des modèles disponibles dans la doc. - Outil de recherche web côté serveur de l'API : l'identifiant de type à jour est dans la doc (au moment d'écrire, `web_search_20250305`) ; `max_uses` 5. diff --git a/windows/README.md b/windows/README.md index de57792c9..1e9b1fa7e 100644 --- a/windows/README.md +++ b/windows/README.md @@ -70,9 +70,18 @@ It works from any terminal — Windows Terminal, PowerShell, VS Code, Git Bash. ## Chat and keys -**Settings… → Claude** takes your Anthropic API key. Keys live in the **Windows -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… → Chat provider** supports Anthropic or a local Ollama-compatible +endpoint. Anthropic keys live in the **Windows Credential Manager**, never on +disk and never in the interface — the island can only ask whether a key exists. +Ollama uses `http://127.0.0.1:11434/api/chat` by default and needs no key; set +the endpoint and model in Settings. Ollama chat is text-only, so image/PDF +attachments are reduced to any readable text context. + +The Claude Code hooks are not an agent backend: they observe an already-running +Claude Code process and relay events/permission requests to Coucou. Selecting +Ollama changes only Coucou's built-in chat/file-question view; it cannot make +Claude Code use Ollama or replace its terminal process. If Coucou is closed or +does not answer, the hook exits and Claude Code continues in the terminal. 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..6b571952a 100644 --- a/windows/src-tauri/src/claude.rs +++ b/windows/src-tauri/src/claude.rs @@ -12,6 +12,7 @@ use serde_json::{json, Value}; use crate::secrets; const ENDPOINT: &str = "https://api.anthropic.com/v1/messages"; +pub const DEFAULT_OLLAMA_ENDPOINT: &str = "http://127.0.0.1:11434/api/chat"; const ANTHROPIC_VERSION: &str = "2023-06-01"; /// Server-side fallback: on a policy decline the API retries the same request on /// a fallback model inside the same call, so the island never shows a dead end. @@ -72,13 +73,12 @@ pub struct ChatReply { /// in the note view. pub async fn send( chat: &Chat, + provider: &str, model: &str, + ollama_endpoint: &str, query: String, context: Option, ) -> Result { - let key = secrets::get("anthropic-api-key") - .ok_or_else(|| "API key missing. Open settings.".to_string())?; - let mut content: Vec = Vec::new(); // File / window context rides along with the first message only, exactly @@ -105,16 +105,38 @@ pub async fn send( chat.push(json!({ "role": "user", "content": content })); - let body = json!({ - "model": model, - "max_tokens": MAX_TOKENS, - "system": SYSTEM_PROMPT, - "tools": [{ "type": "web_search_20260209", "name": "web_search", "max_uses": 5 }], - "fallbacks": "default", - "messages": chat.snapshot(), - }); + let messages = chat.snapshot(); + let (endpoint, body, headers) = match provider { + "ollama" => { + let endpoint = if ollama_endpoint.trim().is_empty() { + DEFAULT_OLLAMA_ENDPOINT + } else { + ollama_endpoint.trim() + }; + let body = json!({ + "model": model, + "stream": false, + "messages": ollama_messages(&messages), + }); + (endpoint.to_string(), body, ProviderHeaders::Ollama) + } + "anthropic" | "" => { + let key = secrets::get("anthropic-api-key") + .ok_or_else(|| "Anthropic API key missing. Open settings.".to_string())?; + let body = json!({ + "model": model, + "max_tokens": MAX_TOKENS, + "system": SYSTEM_PROMPT, + "tools": [{ "type": "web_search_20260209", "name": "web_search", "max_uses": 5 }], + "fallbacks": "default", + "messages": messages, + }); + (ENDPOINT.to_string(), body, ProviderHeaders::Anthropic(key)) + } + other => return Err(format!("Unsupported chat provider: {other}")), + }; - let response = match call(&key, &body).await { + let response = match call(&endpoint, &headers, &body).await { Ok(v) => v, Err(err) => { chat.pop(); // keep the history consistent with what the model saw @@ -133,9 +155,12 @@ pub async fn send( return Err(why.to_string()); } - let Some(blocks) = response.get("content").and_then(Value::as_array).cloned() else { - chat.pop(); - return Err("Unexpected API response.".into()); + let blocks = match response_blocks(&headers, &response) { + Ok(blocks) => blocks, + Err(error) => { + chat.pop(); + return Err(error.into()); + } }; // Store the whole content — tool_use / tool_result blocks included — so the @@ -157,17 +182,29 @@ pub async fn send( Ok(ChatReply { text }) } -async fn call(key: &str, body: &Value) -> Result { +enum ProviderHeaders { + Anthropic(String), + Ollama, +} + +async fn call(endpoint: &str, headers: &ProviderHeaders, body: &Value) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(90)) .build() .map_err(|e| e.to_string())?; let response = client - .post(ENDPOINT) - .header("x-api-key", key) - .header("anthropic-version", ANTHROPIC_VERSION) - .header("anthropic-beta", FALLBACK_BETA) + .post(endpoint) + .headers(match headers { + ProviderHeaders::Anthropic(key) => { + let mut h = reqwest::header::HeaderMap::new(); + h.insert("x-api-key", key.parse().map_err(|_| "Invalid Anthropic API key".to_string())?); + h.insert("anthropic-version", ANTHROPIC_VERSION.parse().unwrap()); + h.insert("anthropic-beta", FALLBACK_BETA.parse().unwrap()); + h + } + ProviderHeaders::Ollama => reqwest::header::HeaderMap::new(), + }) .header("content-type", "application/json") .json(body) .send() @@ -177,8 +214,11 @@ async fn call(key: &str, body: &Value) -> Result { let status = response.status(); let text = response.text().await.map_err(|e| e.to_string())?; if !status.is_success() { - // Surface the API's own message, which is what makes a bad key obvious. - let detail = serde_json::from_str::(&text) + return Err(provider_error(headers, status.as_u16(), &text)); + } + + fn provider_error(headers: &ProviderHeaders, status: u16, text: &str) -> String { + let detail = serde_json::from_str::(text) .ok() .and_then(|v| { v.get("error") @@ -187,11 +227,53 @@ async fn call(key: &str, body: &Value) -> Result { .map(str::to_string) }) .unwrap_or_else(|| text.chars().take(200).collect()); - return Err(format!("Claude API {status}: {detail}")); + let name = if matches!(headers, ProviderHeaders::Ollama) { "Ollama" } else { "Claude API" }; + format!("{name} HTTP {status}: {detail}") } + serde_json::from_str(&text).map_err(|e| format!("Bad API response: {e}")) } +fn ollama_messages(messages: &[Value]) -> Vec { + messages + .iter() + .filter_map(|message| { + let role = message.get("role")?.as_str()?; + let content = message.get("content")?; + let text = if let Some(text) = content.as_str() { + text.to_string() + } else { + content + .as_array()? + .iter() + .filter_map(|block| block.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n") + }; + Some(json!({ "role": role, "content": text })) + }) + .collect() +} + +fn response_blocks(headers: &ProviderHeaders, response: &Value) -> Result, &'static str> { + if matches!(headers, ProviderHeaders::Ollama) { + let text = response + .pointer("/message/content") + .and_then(Value::as_str) + .ok_or("Unexpected Ollama response.")?; + if text.trim().is_empty() { + return Err("No response text."); + } + Ok(vec![json!({ "type": "text", "text": text })]) + } else { + response + .get("content") + .and_then(Value::as_array) + .cloned() + .ok_or("Unexpected API response.") + } +} + /// PDF → document block, image → image block, text/code → inline text. /// Mirrors readFileAsBlock() in ClaudeService.swift. fn file_block(path: &str) -> Option { @@ -248,7 +330,8 @@ fn base64(bytes: &[u8]) -> String { #[cfg(test)] mod tests { - use super::base64; + use super::{base64, ollama_messages, provider_error, response_blocks, ProviderHeaders}; + use serde_json::json; #[test] fn base64_matches_rfc4648_vectors() { @@ -260,4 +343,34 @@ mod tests { assert_eq!(base64(b"fooba"), "Zm9vYmE="); assert_eq!(base64(b"foobar"), "Zm9vYmFy"); } + + #[test] + fn ollama_request_messages_are_text_only() { + let messages = ollama_messages(&[json!({ + "role": "user", + "content": [{"type": "text", "text": "hello"}, {"type": "text", "text": "world"}] + })]); + assert_eq!(messages, vec![json!({"role": "user", "content": "hello\nworld"})]); + } + + #[test] + fn ollama_response_requires_message_content() { + let headers = ProviderHeaders::Ollama; + assert_eq!( + response_blocks(&headers, &json!({"message": {"content": "ok"}})).unwrap(), + vec![json!({"type": "text", "text": "ok"})] + ); + assert!(response_blocks(&headers, &json!({"message": {}})).is_err()); + assert!(response_blocks(&headers, &json!({"message": {"content": ""}})).is_err()); + } + + #[test] + fn provider_errors_surface_json_message_and_bound_plain_text() { + let headers = ProviderHeaders::Ollama; + assert_eq!( + provider_error(&headers, 404, r#"{"error":{"message":"model not found"}}"#), + "Ollama HTTP 404: model not found" + ); + assert!(provider_error(&headers, 500, &"x".repeat(300)).len() < 220); + } } diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 5d19db6c1..4fd79124e 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -248,8 +248,16 @@ async fn chat_send( query: String, context: Option, ) -> Result { - let model = shared.settings.lock().unwrap().model.clone(); - claude::send(&chat, &model, query, context).await + let settings = shared.settings.lock().unwrap().clone(); + claude::send( + &chat, + &settings.provider, + &settings.model, + &settings.ollama_endpoint, + query, + context, + ) + .await } #[tauri::command] diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 27071798b..c346f7e32 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -20,12 +20,26 @@ pub struct Settings { /// Defaulted explicitly so a settings.json written by an older build still loads. #[serde(default = "default_model")] pub model: String, + /// Chat provider: "anthropic" or "ollama". + #[serde(default = "default_provider")] + pub provider: String, + /// Ollama-compatible chat endpoint. + #[serde(default = "default_ollama_endpoint")] + pub ollama_endpoint: String, } fn default_model() -> String { crate::claude::DEFAULT_MODEL.to_string() } +fn default_provider() -> String { + "anthropic".to_string() +} + +fn default_ollama_endpoint() -> String { + crate::claude::DEFAULT_OLLAMA_ENDPOINT.to_string() +} + impl Default for Settings { fn default() -> Self { Self { @@ -43,6 +57,8 @@ impl Default for Settings { autostart: false, hooks_installed: false, model: default_model(), + provider: default_provider(), + ollama_endpoint: default_ollama_endpoint(), } } } diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index e4988b8e7..29551ec35 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; + /** Chat provider used by the chat. */ + provider: "anthropic" | "ollama"; + /** Ollama-compatible chat endpoint. */ + ollamaEndpoint: string; } export const DEFAULT_SETTINGS: Settings = { @@ -106,6 +110,8 @@ export const DEFAULT_SETTINGS: Settings = { autostart: false, hooksInstalled: false, model: "claude-opus-5", + provider: "anthropic", + ollamaEndpoint: "http://127.0.0.1:11434/api/chat", }; type Listener = () => void; diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..fe9e9b0d9 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -171,7 +171,7 @@ function claudeSection(status: HookStatus): HTMLElement { return section; } -// ── Claude API section ──────────────────────────────────────────────────────── +// ── Chat provider section ───────────────────────────────────────────────────── const MODELS: [string, string][] = [ ["claude-opus-5", "Claude Opus 5"], @@ -180,8 +180,15 @@ const MODELS: [string, string][] = [ ]; function apiSection(hasKey: boolean): HTMLElement { + const provider = h("select", {}) as HTMLSelectElement; + provider.append( + h("option", { value: "anthropic", text: "Anthropic (cloud)" }), + h("option", { value: "ollama", text: "Ollama (local)" }), + ); + provider.value = settings.provider; + const dot = statusDot(hasKey); - const state = h("span", { class: "hint", text: hasKey ? "Key saved in the Windows Credential Manager." : "No key yet — the chat needs one." }); + const state = h("span", { class: "hint" }); const field = h("input", { type: "password", @@ -197,14 +204,28 @@ function apiSection(hasKey: boolean): HTMLElement { async function refresh() { const present = (await Bridge.secretPresent("anthropic-api-key")) ?? false; - dot.style.background = present ? "#22c55e" : "#f4505e"; - state.textContent = present - ? "Key saved in the Windows Credential Manager." - : "No key yet — the chat needs one."; + dot.style.background = settings.provider === "ollama" || present ? "#22c55e" : "#f4505e"; + state.textContent = settings.provider === "ollama" + ? "Runs locally; no API key is required." + : present ? "Key saved in the Windows Credential Manager." : "No key yet — the chat needs one."; field.placeholder = present ? "•••••••••••• (stored)" : "sk-ant-..."; clearBtn.style.display = present ? "" : "none"; } + const endpoint = h("input", { + type: "url", + value: settings.ollamaEndpoint, + placeholder: "http://127.0.0.1:11434/api/chat", + style: "flex:1 1 auto;min-width:0", + }) as HTMLInputElement; + endpoint.addEventListener("change", () => { + const value = endpoint.value.trim(); + if (value) { + settings.ollamaEndpoint = value; + void save(); + } + }); + saveBtn.addEventListener("click", async () => { const value = field.value.trim(); if (!value) return; @@ -240,15 +261,30 @@ function apiSection(hasKey: boolean): HTMLElement { settings.model = model.value; void save(); }); + provider.addEventListener("change", () => { + settings.provider = provider.value as Settings["provider"]; + dot.style.background = settings.provider === "ollama" ? "#22c55e" : (hasKey ? "#22c55e" : "#f4505e"); + field.disabled = settings.provider === "ollama"; + clearBtn.disabled = settings.provider === "ollama"; + endpoint.disabled = settings.provider !== "ollama"; + void save(); + void refresh(); + }); clearBtn.style.display = hasKey ? "" : "none"; + field.disabled = settings.provider === "ollama"; + clearBtn.disabled = settings.provider === "ollama"; + endpoint.disabled = settings.provider !== "ollama"; + void refresh(); return h( "section", {}, - h("h2", {}, dot, h("span", { text: "Claude" })), + h("h2", {}, dot, h("span", { text: "Chat provider" })), + h("div", { class: "row" }, h("label", { text: "Provider" }), provider), state, h("div", { class: "row" }, h("label", { text: "API key" }), field, saveBtn, clearBtn), + h("div", { class: "row" }, h("label", { text: "Ollama endpoint" }), endpoint), h("div", { class: "row" }, h("label", { text: "Model" }), model), feedback, );