From 9ef4b9e9342b4e02662ef0f5c2c3f4be2c0819b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:51:59 +0000 Subject: [PATCH 1/2] Ignore local .env files Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_017BSFYLvbLzfP3nodSZRsNb --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index a3be23840..5ccbcc1de 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,8 @@ DerivedData/ # Local only (never published) .claude/ _prive/ + +# Local secrets / config (never commit) +.env +.env.* +!.env.example From 1a3097d577117e96479af9de5ddd686b5081e61c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:58:11 +0000 Subject: [PATCH 2/2] Windows: chat through any OpenAI-compatible provider (AvalAI) Adds a Custom provider option next to Claude: base URL, model and key in Settings, key in the Credential Manager. Chat only (no web search, no PDFs). Debug builds can read AVALAI_* from a git-ignored .env. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_017BSFYLvbLzfP3nodSZRsNb --- windows/README.md | 8 + windows/src-tauri/src/claude.rs | 283 +++++++++++++++++++++++++++--- windows/src-tauri/src/lib.rs | 11 +- windows/src-tauri/src/secrets.rs | 1 + windows/src-tauri/src/settings.rs | 17 ++ windows/src/core/state.ts | 9 + windows/src/settings/main.ts | 106 +++++++++++ 7 files changed, 409 insertions(+), 26 deletions(-) diff --git a/windows/README.md b/windows/README.md index 60374024c..d5b458af4 100644 --- a/windows/README.md +++ b/windows/README.md @@ -74,6 +74,14 @@ 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… → Chat provider** lets the chat run on any OpenAI-compatible server +(AvalAI, OpenRouter…) instead of the Anthropic API: pick *Custom provider*, then +enter its base URL (e.g. `https://api.avalai.ir/v1`), key and model id. The key +goes to the Credential Manager like the others. This mode is chat only — no web +search, and PDFs are not read. In `npm run tauri dev` builds only, `AVALAI_BASE_URL`, +`AVALAI_API_KEY` and `AVALAI_MODEL` from a git-ignored `.env` fill in whatever +Settings leaves empty; release builds never read it. + 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..a9c31aef7 100644 --- a/windows/src-tauri/src/claude.rs +++ b/windows/src-tauri/src/claude.rs @@ -79,29 +79,7 @@ pub async fn send( 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 - // like ClaudeService.chat(). - if chat.is_empty() { - match &context { - Some(ChatContext::File { name, path }) => { - if let Some(block) = file_block(path) { - content.push(block); - } - content.push(json!({ "type": "text", "text": format!("File: {name}") })); - } - Some(ChatContext::Window { app_name, title, url }) => { - let mut text = format!("Context — App: {app_name}, Window: {title}"); - if let Some(url) = url { - text.push_str(&format!(", URL: {url}")); - } - content.push(json!({ "type": "text", "text": text })); - } - None => {} - } - } - content.push(json!({ "type": "text", "text": query })); + let content = user_content(chat, context.as_ref(), query); chat.push(json!({ "role": "user", "content": content })); @@ -157,6 +135,32 @@ pub async fn send( Ok(ChatReply { text }) } +/// The user turn's content blocks. File / window context rides along with the +/// first message only, exactly like ClaudeService.chat(). +fn user_content(chat: &Chat, context: Option<&ChatContext>, query: String) -> Vec { + let mut content: Vec = Vec::new(); + if chat.is_empty() { + match context { + Some(ChatContext::File { name, path }) => { + if let Some(block) = file_block(path) { + content.push(block); + } + content.push(json!({ "type": "text", "text": format!("File: {name}") })); + } + Some(ChatContext::Window { app_name, title, url }) => { + let mut text = format!("Context — App: {app_name}, Window: {title}"); + if let Some(url) = url { + text.push_str(&format!(", URL: {url}")); + } + content.push(json!({ "type": "text", "text": text })); + } + None => {} + } + } + content.push(json!({ "type": "text", "text": query })); + content +} + async fn call(key: &str, body: &Value) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(90)) @@ -192,6 +196,209 @@ async fn call(key: &str, body: &Value) -> Result { serde_json::from_str(&text).map_err(|e| format!("Bad API response: {e}")) } +// ── Custom provider (OpenAI-compatible, e.g. AvalAI) ────────────────────────── +// +// Same history as the Claude path (Anthropic-shaped blocks), converted to +// /chat/completions messages at call time. No web search: that tool is Claude's. + +const CUSTOM_KEY: &str = "custom-api-key"; +const CUSTOM_SYSTEM_PROMPT: &str = "You are Mochi, a personal AI assistant living at the top of the user's screen. \ +You can help with absolutely anything — research, coding, recommendations, tasks, questions. \ +Respond in the user's language. Be thorough and complete — use as much detail as the task requires. \ +No markdown formatting (no **, no ##, no bullet dashes). Use plain text with line breaks."; + +pub struct CustomProvider { + pub base_url: String, + pub model: String, +} + +/// Debug builds only: lets `npm run tauri dev` read AVALAI_* from the git-ignored +/// `.env` at the repo root. Release builds never look at it — keys live in the +/// Credential Manager. +#[cfg(debug_assertions)] +fn dev_env(name: &str) -> Option { + if let Ok(v) = std::env::var(name) { + if !v.trim().is_empty() { + return Some(v.trim().to_string()); + } + } + let mut dir = std::env::current_dir().ok()?; + loop { + if let Ok(text) = std::fs::read_to_string(dir.join(".env")) { + for line in text.lines() { + let Some((k, v)) = line.trim().split_once('=') else { continue }; + if k.trim() == name { + let v = v.trim().trim_matches('"').trim_matches('\''); + return (!v.is_empty()).then(|| v.to_string()); + } + } + } + if !dir.pop() { + return None; + } + } +} + +#[cfg(not(debug_assertions))] +fn dev_env(_name: &str) -> Option { + None +} + +fn non_empty(value: &str) -> Option { + let v = value.trim(); + (!v.is_empty()).then(|| v.to_string()) +} + +/// `https://host/v1/` → `https://host/v1`; a pasted `/chat/completions` is cut off. +fn normalise_base_url(url: &str) -> String { + let url = url.trim().trim_end_matches('/'); + url.strip_suffix("/chat/completions").unwrap_or(url).trim_end_matches('/').to_string() +} + +/// Anthropic-shaped history → OpenAI messages. Images become `image_url` data +/// URIs; a PDF has no OpenAI equivalent, so the model is told it was left out. +fn to_openai_messages(history: &[Value]) -> Vec { + let mut out = vec![json!({ "role": "system", "content": CUSTOM_SYSTEM_PROMPT })]; + for message in history { + let role = message.get("role").and_then(Value::as_str).unwrap_or("user"); + let blocks: Vec = match message.get("content") { + Some(Value::Array(blocks)) => blocks.clone(), + Some(Value::String(text)) => vec![json!({ "type": "text", "text": text })], + _ => continue, + }; + + let mut parts: Vec = Vec::new(); + let mut has_image = false; + for block in &blocks { + match block.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = block.get("text").and_then(Value::as_str) { + parts.push(json!({ "type": "text", "text": text })); + } + } + Some("image") if role == "user" => { + let source = &block["source"]; + if let (Some(media), Some(data)) = ( + source.get("media_type").and_then(Value::as_str), + source.get("data").and_then(Value::as_str), + ) { + has_image = true; + parts.push(json!({ + "type": "image_url", + "image_url": { "url": format!("data:{media};base64,{data}") }, + })); + } + } + Some("document") if role == "user" => parts.push(json!({ + "type": "text", + "text": "[A PDF was attached, but this provider cannot read PDFs.]", + })), + _ => {} + } + } + if parts.is_empty() { + continue; + } + + let content = if has_image { + Value::Array(parts) + } else { + let text = parts + .iter() + .filter_map(|p| p.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"); + Value::String(text) + }; + out.push(json!({ "role": role, "content": content })); + } + out +} + +/// One chat turn through the custom provider. +pub async fn send_custom( + chat: &Chat, + provider: &CustomProvider, + query: String, + context: Option, +) -> Result { + let base = non_empty(&provider.base_url) + .or_else(|| dev_env("AVALAI_BASE_URL")) + .map(|u| normalise_base_url(&u)) + .ok_or_else(|| "Set the provider's base URL in Settings.".to_string())?; + let key = secrets::get(CUSTOM_KEY) + .or_else(|| dev_env("AVALAI_API_KEY")) + .ok_or_else(|| "Custom provider API key missing. Open settings.".to_string())?; + let model = non_empty(&provider.model) + .or_else(|| dev_env("AVALAI_MODEL")) + .ok_or_else(|| "Set the provider's model in Settings.".to_string())?; + + let content = user_content(chat, context.as_ref(), query); + chat.push(json!({ "role": "user", "content": content })); + + let body = json!({ + "model": model, + "max_tokens": MAX_TOKENS, + "messages": to_openai_messages(&chat.snapshot()), + }); + + let response = match call_custom(&base, &key, &body).await { + Ok(v) => v, + Err(err) => { + chat.pop(); + return Err(err); + } + }; + + let text = response + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(Value::as_str) + .map(|t| t.trim().to_string()) + .unwrap_or_default(); + if text.is_empty() { + chat.pop(); + return Err("No response text.".into()); + } + + chat.push(json!({ "role": "assistant", "content": [{ "type": "text", "text": text }] })); + Ok(ChatReply { text }) +} + +async fn call_custom(base: &str, key: &str, 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(format!("{base}/chat/completions")) + .bearer_auth(key) + .header("content-type", "application/json") + .json(body) + .send() + .await + .map_err(|e| format!("Network error: {e}"))?; + + let status = response.status(); + let text = response.text().await.map_err(|e| e.to_string())?; + if !status.is_success() { + // OpenAI-style errors: {"error":{"message":"…"}}; some gateways send a plain string. + let detail = serde_json::from_str::(&text) + .ok() + .and_then(|v| match v.get("error") { + Some(Value::String(s)) => Some(s.clone()), + Some(e) => e.get("message").and_then(Value::as_str).map(str::to_string), + None => None, + }) + .unwrap_or_else(|| text.chars().take(200).collect()); + return Err(format!("Provider API {status}: {detail}")); + } + serde_json::from_str(&text).map_err(|e| format!("Bad API response: {e}")) +} + /// PDF → document block, image → image block, text/code → inline text. /// Mirrors readFileAsBlock() in ClaudeService.swift. fn file_block(path: &str) -> Option { @@ -248,7 +455,8 @@ fn base64(bytes: &[u8]) -> String { #[cfg(test)] mod tests { - use super::base64; + use super::{base64, normalise_base_url, to_openai_messages}; + use serde_json::json; #[test] fn base64_matches_rfc4648_vectors() { @@ -260,4 +468,31 @@ mod tests { assert_eq!(base64(b"fooba"), "Zm9vYmE="); assert_eq!(base64(b"foobar"), "Zm9vYmFy"); } + + #[test] + fn base_url_is_normalised() { + assert_eq!(normalise_base_url(" https://api.avalai.ir/v1/ "), "https://api.avalai.ir/v1"); + assert_eq!( + normalise_base_url("https://api.avalai.ir/v1/chat/completions"), + "https://api.avalai.ir/v1" + ); + } + + #[test] + fn history_converts_to_openai_messages() { + let history = vec![ + json!({ "role": "user", "content": [ + { "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "AAAA" } }, + { "type": "text", "text": "what is this?" }, + ]}), + json!({ "role": "assistant", "content": [{ "type": "text", "text": "a cat" }] }), + json!({ "role": "user", "content": [{ "type": "text", "text": "thanks" }] }), + ]; + let msgs = to_openai_messages(&history); + assert_eq!(msgs[0]["role"], "system"); + assert_eq!(msgs[1]["content"][0]["image_url"]["url"], "data:image/png;base64,AAAA"); + assert_eq!(msgs[1]["content"][1]["text"], "what is this?"); + assert_eq!(msgs[2], json!({ "role": "assistant", "content": "a cat" })); + assert_eq!(msgs[3], json!({ "role": "user", "content": "thanks" })); + } } diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 714103e59..b244c15cf 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -241,8 +241,15 @@ 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(); + if settings.chat_provider == "custom" { + let custom = claude::CustomProvider { + base_url: settings.custom_base_url, + model: settings.custom_model, + }; + return claude::send_custom(&chat, &custom, query, context).await; + } + claude::send(&chat, &settings.model, query, context).await } #[tauri::command] diff --git a/windows/src-tauri/src/secrets.rs b/windows/src-tauri/src/secrets.rs index 5c37b6529..6fb266d24 100644 --- a/windows/src-tauri/src/secrets.rs +++ b/windows/src-tauri/src/secrets.rs @@ -8,6 +8,7 @@ const SERVICE: &str = "fr.louisraille.coucou"; /// Every key Coucou may store. Anything outside this list is refused. pub const KNOWN_KEYS: &[&str] = &[ "anthropic-api-key", + "custom-api-key", "n8n-url", "n8n-api-key", "vercel-token", diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 2a8d7d359..32eac4cec 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -20,6 +20,20 @@ pub struct Settings { /// Defaulted explicitly so a settings.json written by an older build still loads. #[serde(default = "default_model")] pub model: String, + /// Which backend answers the chat: "anthropic" (default) or "custom", any + /// OpenAI-compatible server such as AvalAI. The custom key lives in the keychain. + #[serde(default = "default_chat_provider")] + pub chat_provider: String, + /// Base URL of the custom provider, e.g. https://api.avalai.ir/v1 + #[serde(default)] + pub custom_base_url: String, + /// Model id sent to the custom provider. + #[serde(default)] + pub custom_model: String, +} + +fn default_chat_provider() -> String { + "anthropic".to_string() } fn default_model() -> String { @@ -43,6 +57,9 @@ impl Default for Settings { autostart: false, hooks_installed: false, model: default_model(), + chat_provider: default_chat_provider(), + custom_base_url: String::new(), + custom_model: String::new(), } } } diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a..fd2d6f66c 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -92,6 +92,12 @@ export interface Settings { hooksInstalled: boolean; /** Claude model used by the chat. */ model: string; + /** Which backend answers the chat: Claude, or any OpenAI-compatible server. */ + chatProvider: "anthropic" | "custom"; + /** Base URL of the custom provider, e.g. https://api.avalai.ir/v1 */ + customBaseUrl: string; + /** Model id sent to the custom provider. */ + customModel: string; } export const DEFAULT_SETTINGS: Settings = { @@ -106,6 +112,9 @@ export const DEFAULT_SETTINGS: Settings = { autostart: false, hooksInstalled: false, model: "claude-opus-5", + chatProvider: "anthropic", + customBaseUrl: "", + customModel: "", }; type Listener = () => void; diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..317984cd7 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -254,6 +254,110 @@ function apiSection(hasKey: boolean): HTMLElement { ); } +// ── Custom provider section (OpenAI-compatible, e.g. AvalAI) ───────────────── + +function customProviderSection(hasKey: boolean): HTMLElement { + const dot = statusDot(hasKey); + const hint = h("span", { + class: "hint", + text: "Any OpenAI-compatible server (AvalAI, OpenRouter…). Chat only — no web search, and PDFs are not read.", + }); + + const provider = h("select", {}) as HTMLSelectElement; + provider.append( + h("option", { value: "anthropic", text: "Claude (Anthropic)" }), + h("option", { value: "custom", text: "Custom provider" }), + ); + provider.value = settings.chatProvider; + provider.addEventListener("change", () => { + settings.chatProvider = provider.value as Settings["chatProvider"]; + void save(); + }); + + const baseUrl = h("input", { + type: "text", + placeholder: "https://api.avalai.ir/v1", + style: "flex:1 1 auto;min-width:0", + autocomplete: "off", + spellcheck: "false", + }) as HTMLInputElement; + baseUrl.value = settings.customBaseUrl; + baseUrl.addEventListener("change", () => { + settings.customBaseUrl = baseUrl.value.trim(); + void save(); + }); + + const model = h("input", { + type: "text", + placeholder: "model id from your provider", + style: "flex:1 1 auto;min-width:0", + autocomplete: "off", + spellcheck: "false", + }) as HTMLInputElement; + model.value = settings.customModel; + model.addEventListener("change", () => { + settings.customModel = model.value.trim(); + void save(); + }); + + const field = h("input", { + type: "password", + placeholder: hasKey ? "•••••••••••• (stored)" : "API key", + style: "flex:1 1 auto;min-width:0", + autocomplete: "off", + spellcheck: "false", + }) as HTMLInputElement; + const saveBtn = h("button", { class: "primary", text: "Save key" }); + const clearBtn = h("button", { class: "danger", text: "Remove" }); + const feedback = h("div", {}); + + async function refresh() { + const present = (await Bridge.secretPresent("custom-api-key")) ?? false; + dot.style.background = present ? "#22c55e" : "#f4505e"; + field.placeholder = present ? "•••••••••••• (stored)" : "API key"; + clearBtn.style.display = present ? "" : "none"; + } + + saveBtn.addEventListener("click", async () => { + const value = field.value.trim(); + if (!value) return; + clear(feedback); + try { + await Bridge.secretSet("custom-api-key", value); + field.value = ""; + feedback.append(h("div", { class: "notice ok", text: "Saved in the Windows Credential Manager." })); + await refresh(); + } catch (err) { + feedback.append(h("div", { class: "notice err", text: `Could not save: ${String(err)}` })); + } + }); + + clearBtn.addEventListener("click", async () => { + clear(feedback); + try { + await Bridge.secretClear("custom-api-key"); + feedback.append(h("div", { class: "notice ok", text: "Key removed." })); + await refresh(); + } catch (err) { + feedback.append(h("div", { class: "notice err", text: `Could not remove: ${String(err)}` })); + } + }); + + clearBtn.style.display = hasKey ? "" : "none"; + + return h( + "section", + {}, + h("h2", {}, dot, h("span", { text: "Chat provider" })), + hint, + h("div", { class: "row" }, h("label", { text: "Chat uses" }), provider), + h("div", { class: "row" }, h("label", { text: "Base URL" }), baseUrl), + h("div", { class: "row" }, h("label", { text: "API key" }), field, saveBtn, clearBtn), + h("div", { class: "row" }, h("label", { text: "Model" }), model), + feedback, + ); +} + // ── Integrations section ────────────────────────────────────────────────────── interface IntegrationDef { @@ -430,6 +534,7 @@ async function main() { }; const hasKey = (await Bridge.secretPresent("anthropic-api-key")) ?? false; + const hasCustomKey = (await Bridge.secretPresent("custom-api-key")) ?? false; const keys = [ "stripe-api-key", "github-token", "vercel-token", @@ -443,6 +548,7 @@ async function main() { h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })), claudeSection(status), apiSection(hasKey), + customProviderSection(hasCustomKey), integrationsSection(present), generalSection(), h("div", {