diff --git a/windows/package-lock.json b/windows/package-lock.json index 5e06f8bcb..c00688c9b 100644 --- a/windows/package-lock.json +++ b/windows/package-lock.json @@ -1,12 +1,12 @@ { "name": "coucou-windows", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coucou-windows", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@tauri-apps/api": "^2" }, diff --git a/windows/src-tauri/src/claude.rs b/windows/src-tauri/src/claude.rs index 060a57c64..daff3a18b 100644 --- a/windows/src-tauri/src/claude.rs +++ b/windows/src-tauri/src/claude.rs @@ -38,19 +38,19 @@ impl Chat { self.messages.lock().unwrap().clear(); } - fn is_empty(&self) -> bool { + pub(crate) fn is_empty(&self) -> bool { self.messages.lock().unwrap().is_empty() } - fn push(&self, message: Value) { + pub(crate) fn push(&self, message: Value) { self.messages.lock().unwrap().push(message); } - fn pop(&self) { + pub(crate) fn pop(&self) { self.messages.lock().unwrap().pop(); } - fn snapshot(&self) -> Vec { + pub(crate) fn snapshot(&self) -> Vec { self.messages.lock().unwrap().clone() } } diff --git a/windows/src-tauri/src/gemini.rs b/windows/src-tauri/src/gemini.rs new file mode 100644 index 000000000..d0736f73f --- /dev/null +++ b/windows/src-tauri/src/gemini.rs @@ -0,0 +1,191 @@ +// Google Gemini API client — OpenAI-compatible endpoint as in ClaudeService.swift +// Endpoint: https://generativelanguage.googleapis.com/v1beta/openai/chat/completions +// +// The API key lives in the Windows Credential Manager or Secret Service +// ("google-api-key") and never leaves the backend. + +use serde_json::{json, Value}; + +use crate::claude::{base64_for, Chat, ChatContext, ChatReply}; +use crate::secrets; + +pub const DEFAULT_MODEL: &str = "gemini-2.0-flash"; +const ENDPOINT: &str = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"; +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 can help with absolutely anything — research, coding, finding places, 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."; + +/// One chat turn using Google Gemini. +pub async fn send( + chat: &Chat, + model: &str, + query: String, + context: Option, +) -> Result { + let key = secrets::get("google-api-key") + .ok_or_else(|| "Google API key missing. Open settings.".to_string())?; + + let is_first = chat.is_empty(); + + // Prepare content parts or text for this turn + let mut parts: Vec = Vec::new(); + + if is_first { + match &context { + Some(ChatContext::File { name, path }) => { + if let Some(block) = file_content_part(path) { + parts.push(block); + } + parts.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}")); + } + parts.push(json!({ "type": "text", "text": text })); + } + None => {} + } + } + + parts.push(json!({ "type": "text", "text": query })); + + // User message + let user_msg = if parts.len() == 1 && parts[0].get("type").and_then(Value::as_str) == Some("text") { + json!({ + "role": "user", + "content": parts[0]["text"].as_str().unwrap_or(&query), + }) + } else { + json!({ + "role": "user", + "content": parts, + }) + }; + + chat.push(user_msg); + + // Build messages array including system prompt at the top + let mut messages: Vec = vec![ + json!({ "role": "system", "content": SYSTEM_PROMPT }), + ]; + messages.extend(chat.snapshot()); + + let body = json!({ + "model": model, + "messages": messages, + }); + + let response = match call(&key, &body).await { + Ok(v) => v, + Err(err) => { + chat.pop(); + return Err(err); + } + }; + + let reply_text = response + .get("choices") + .and_then(Value::as_array) + .and_then(|c| c.first()) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(Value::as_str) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + + if reply_text.is_empty() { + chat.pop(); + return Err("No response text from Gemini.".into()); + } + + chat.push(json!({ "role": "assistant", "content": reply_text.clone() })); + Ok(ChatReply { text: reply_text }) +} + +async fn call(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(ENDPOINT) + .header("Authorization", format!("Bearer {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() { + let detail = serde_json::from_str::(&text) + .ok() + .and_then(|v| { + v.get("error") + .and_then(|e| e.get("message")) + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| text.chars().take(200).collect()); + return Err(format!("Gemini API {status}: {detail}")); + } + + serde_json::from_str(&text).map_err(|e| format!("Bad API response: {e}")) +} + +fn file_content_part(path: &str) -> Option { + let ext = std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + + let media_type = match ext.as_str() { + "jpg" | "jpeg" => Some("image/jpeg"), + "png" => Some("image/png"), + "gif" => Some("image/gif"), + "webp" => Some("image/webp"), + _ => None, + }; + + if let Some(mime) = media_type { + let bytes = std::fs::read(path).ok()?; + let b64 = base64_for(&bytes); + return Some(json!({ + "type": "image_url", + "image_url": { + "url": format!("data:{mime};base64,{b64}") + } + })); + } + + let len = std::fs::metadata(path).ok()?.len(); + if len > MAX_INLINE_TEXT { + return None; + } + let text = std::fs::read_to_string(path).ok()?; + Some(json!({ "type": "text", "text": format!("File contents:\n{text}") })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_model_is_gemini_2_flash() { + assert_eq!(DEFAULT_MODEL, "gemini-2.0-flash"); + } + + #[test] + fn system_prompt_forbids_markdown() { + assert!(SYSTEM_PROMPT.contains("No markdown formatting")); + } +} diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 714103e59..1e16b56f0 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod claude; mod files; +mod gemini; mod hooks; mod integrations; mod island; @@ -241,8 +242,25 @@ 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(); + match settings.chat_provider.as_str() { + "google" => { + let model = if settings.google_model.is_empty() { + gemini::DEFAULT_MODEL + } else { + &settings.google_model + }; + gemini::send(&chat, model, query, context).await + } + _ => { + let model = if settings.model.is_empty() { + claude::DEFAULT_MODEL + } else { + &settings.model + }; + claude::send(&chat, model, query, context).await + } + } } #[tauri::command] diff --git a/windows/src-tauri/src/secrets.rs b/windows/src-tauri/src/secrets.rs index 5c37b6529..9b98e9f0e 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", + "google-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..0d34ef3af 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -20,12 +20,24 @@ pub struct Settings { /// Defaulted explicitly so a settings.json written by an older build still loads. #[serde(default = "default_model")] pub model: String, + #[serde(default = "default_chat_provider")] + pub chat_provider: String, + #[serde(default = "default_google_model")] + pub google_model: String, } fn default_model() -> String { crate::claude::DEFAULT_MODEL.to_string() } +fn default_chat_provider() -> String { + "anthropic".to_string() +} + +fn default_google_model() -> String { + crate::gemini::DEFAULT_MODEL.to_string() +} + impl Default for Settings { fn default() -> Self { Self { @@ -43,6 +55,8 @@ impl Default for Settings { autostart: false, hooks_installed: false, model: default_model(), + chat_provider: default_chat_provider(), + google_model: default_google_model(), } } } diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a..400e8a60e 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -92,6 +92,8 @@ export interface Settings { hooksInstalled: boolean; /** Claude model used by the chat. */ model: string; + chatProvider?: "anthropic" | "google"; + googleModel?: string; } export const DEFAULT_SETTINGS: Settings = { @@ -106,6 +108,8 @@ export const DEFAULT_SETTINGS: Settings = { autostart: false, hooksInstalled: false, model: "claude-opus-5", + chatProvider: "anthropic", + googleModel: "gemini-2.0-flash", }; type Listener = () => void; diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..21da3b802 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -171,87 +171,132 @@ function claudeSection(status: HookStatus): HTMLElement { return section; } -// ── Claude API section ──────────────────────────────────────────────────────── +// ── AI Chat Provider section ────────────────────────────────────────────────── -const MODELS: [string, string][] = [ +const ANTHROPIC_MODELS: [string, string][] = [ ["claude-opus-5", "Claude Opus 5"], ["claude-sonnet-5", "Claude Sonnet 5"], ["claude-haiku-4-5", "Claude Haiku 4.5"], ]; -function apiSection(hasKey: boolean): HTMLElement { - 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 GOOGLE_MODELS: [string, string][] = [ + ["gemini-2.5-pro", "Gemini 2.5 Pro"], + ["gemini-2.0-flash", "Gemini 2.0 Flash"], + ["gemini-2.0-flash-lite", "Gemini 2.0 Flash Lite"], + ["gemini-1.5-flash", "Gemini 1.5 Flash"], + ["gemini-1.5-pro", "Gemini 1.5 Pro"], +]; - const field = h("input", { - type: "password", - placeholder: hasKey ? "•••••••••••• (stored)" : "sk-ant-...", - style: "flex:1 1 auto;min-width:0", - autocomplete: "off", - spellcheck: "false", - }) as HTMLInputElement; +function apiSection(hasAnthropicKey: boolean, hasGoogleKey: boolean): HTMLElement { + const isGoogleInitial = (settings.chatProvider || "anthropic") === "google"; + const initialKeyPresent = isGoogleInitial ? hasGoogleKey : hasAnthropicKey; + const dot = statusDot(initialKeyPresent); + const title = h("span", { text: "Chat Provider" }); - 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("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."; - field.placeholder = present ? "•••••••••••• (stored)" : "sk-ant-..."; - clearBtn.style.display = present ? "" : "none"; - } + const body = h("div", { style: "display:flex;flex-direction:column;gap:12px" }); + const section = h( + "section", + {}, + h("h2", {}, dot, title), + body, + ); - saveBtn.addEventListener("click", async () => { - const value = field.value.trim(); - if (!value) return; - clear(feedback); - try { - await Bridge.secretSet("anthropic-api-key", value); - field.value = ""; - feedback.append(h("div", { class: "notice ok", text: "Saved. It never touches disk." })); - await refresh(); - } catch (err) { - feedback.append(h("div", { class: "notice err", text: `Could not save: ${String(err)}` })); - } - }); + async function redraw() { + clear(body); - clearBtn.addEventListener("click", async () => { - clear(feedback); - try { - await Bridge.secretClear("anthropic-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)}` })); + const providerSelect = h("select", {}) as HTMLSelectElement; + providerSelect.append( + h("option", { value: "anthropic", text: "Claude (Anthropic)" }), + h("option", { value: "google", text: "Google Gemini" }), + ); + providerSelect.value = settings.chatProvider || "anthropic"; + providerSelect.addEventListener("change", async () => { + settings.chatProvider = providerSelect.value as "anthropic" | "google"; + await save(); + await redraw(); + }); + + const isG = settings.chatProvider === "google"; + const secretKeyName = isG ? "google-api-key" : "anthropic-api-key"; + const hasKey = (await Bridge.secretPresent(secretKeyName)) ?? false; + + dot.style.background = hasKey ? "#22c55e" : "#f4505e"; + + const state = h("span", { + class: "hint", + text: hasKey + ? "Key saved in the Windows Credential Manager." + : isG + ? "No key yet — get a free Gemini API key from aistudio.google.com." + : "No key yet — the chat needs an Anthropic API key.", + }); + + const field = h("input", { + type: "password", + placeholder: hasKey ? "•••••••••••• (stored)" : isG ? "AIzaSy..." : "sk-ant-...", + 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", style: hasKey ? "" : "display:none" }); + const feedback = h("div", {}); + + saveBtn.addEventListener("click", async () => { + const value = field.value.trim(); + if (!value) return; + clear(feedback); + try { + await Bridge.secretSet(secretKeyName, value); + field.value = ""; + feedback.append(h("div", { class: "notice ok", text: "Saved. It never touches disk." })); + await redraw(); + } 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(secretKeyName); + feedback.append(h("div", { class: "notice ok", text: "Key removed." })); + await redraw(); + } catch (err) { + feedback.append(h("div", { class: "notice err", text: `Could not remove: ${String(err)}` })); + } + }); + + const modelSelect = h("select", {}) as HTMLSelectElement; + const modelList = isG ? GOOGLE_MODELS : ANTHROPIC_MODELS; + const selectedModel = isG ? (settings.googleModel || "gemini-2.0-flash") : settings.model; + + for (const [id, label] of modelList) modelSelect.append(h("option", { value: id, text: label })); + if (!modelList.some(([id]) => id === selectedModel)) { + modelSelect.append(h("option", { value: selectedModel, text: selectedModel })); } - }); + modelSelect.value = selectedModel; + modelSelect.addEventListener("change", () => { + if (isG) { + settings.googleModel = modelSelect.value; + } else { + settings.model = modelSelect.value; + } + void save(); + }); - const model = h("select", {}) as HTMLSelectElement; - for (const [id, label] of MODELS) model.append(h("option", { value: id, text: label })); - if (!MODELS.some(([id]) => id === settings.model)) { - model.append(h("option", { value: settings.model, text: settings.model })); + body.append( + h("div", { class: "row" }, h("label", { text: "Provider" }), providerSelect), + state, + h("div", { class: "row" }, h("label", { text: "API key" }), field, saveBtn, clearBtn), + h("div", { class: "row" }, h("label", { text: "Model" }), modelSelect), + feedback, + ); } - model.value = settings.model; - model.addEventListener("change", () => { - settings.model = model.value; - void save(); - }); - clearBtn.style.display = hasKey ? "" : "none"; - - return h( - "section", - {}, - h("h2", {}, dot, h("span", { text: "Claude" })), - state, - h("div", { class: "row" }, h("label", { text: "API key" }), field, saveBtn, clearBtn), - h("div", { class: "row" }, h("label", { text: "Model" }), model), - feedback, - ); + void redraw(); + return section; } // ── Integrations section ────────────────────────────────────────────────────── @@ -429,7 +474,8 @@ async function main() { installed: false, settingsPath: "", hookPath: "", hookReady: false, }; - const hasKey = (await Bridge.secretPresent("anthropic-api-key")) ?? false; + const hasAnthropicKey = (await Bridge.secretPresent("anthropic-api-key")) ?? false; + const hasGoogleKey = (await Bridge.secretPresent("google-api-key")) ?? false; const keys = [ "stripe-api-key", "github-token", "vercel-token", @@ -442,7 +488,7 @@ async function main() { root.append( h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })), claudeSection(status), - apiSection(hasKey), + apiSection(hasAnthropicKey, hasGoogleKey), integrationsSection(present), generalSection(), h("div", {