diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index baae2dd..57d49cc 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -36,6 +36,33 @@ pub struct ClaudeStartOptions { #[serde(default)] pub ollama_model: Option, + + // OpenAI-specific options + #[serde(default)] + pub openai_api_key: Option, + + #[serde(default = "default_openai_base_url")] + pub openai_base_url: String, + + #[serde(default)] + pub openai_model: Option, + + // Anthropic-specific options + #[serde(default)] + pub anthropic_api_key: Option, + + #[serde(default = "default_anthropic_base_url")] + pub anthropic_base_url: String, + + #[serde(default)] + pub anthropic_model: Option, + + // Gemini-specific options + #[serde(default)] + pub gemini_api_key: Option, + + #[serde(default)] + pub gemini_model: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -65,6 +92,33 @@ pub struct HikariConfig { #[serde(default)] pub ollama_model: Option, + // OpenAI-specific settings + #[serde(default)] + pub openai_api_key: Option, + + #[serde(default = "default_openai_base_url")] + pub openai_base_url: String, + + #[serde(default)] + pub openai_model: Option, + + // Anthropic-specific settings + #[serde(default)] + pub anthropic_api_key: Option, + + #[serde(default = "default_anthropic_base_url")] + pub anthropic_base_url: String, + + #[serde(default)] + pub anthropic_model: Option, + + // Gemini-specific settings + #[serde(default)] + pub gemini_api_key: Option, + + #[serde(default)] + pub gemini_model: Option, + #[serde(default)] pub theme: Theme, @@ -130,6 +184,14 @@ impl Default for HikariConfig { auto_granted_tools: Vec::new(), ollama_base_url: default_ollama_base_url(), ollama_model: None, + openai_api_key: None, + openai_base_url: default_openai_base_url(), + openai_model: None, + anthropic_api_key: None, + anthropic_base_url: default_anthropic_base_url(), + anthropic_model: None, + gemini_api_key: None, + gemini_model: None, theme: Theme::default(), greeting_enabled: true, greeting_custom_prompt: None, @@ -175,6 +237,14 @@ fn default_ollama_base_url() -> String { "http://localhost:11434".to_string() } +fn default_openai_base_url() -> String { + "https://api.openai.com/v1".to_string() +} + +fn default_anthropic_base_url() -> String { + "https://api.anthropic.com".to_string() +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[serde(rename_all = "lowercase")] pub enum Theme { @@ -221,6 +291,18 @@ mod tests { assert!(config.auto_granted_tools.is_empty()); assert_eq!(config.ollama_base_url, "http://localhost:11434"); assert!(config.ollama_model.is_none()); + // OpenAI defaults + assert!(config.openai_api_key.is_none()); + assert_eq!(config.openai_base_url, "https://api.openai.com/v1"); + assert!(config.openai_model.is_none()); + // Anthropic defaults + assert!(config.anthropic_api_key.is_none()); + assert_eq!(config.anthropic_base_url, "https://api.anthropic.com"); + assert!(config.anthropic_model.is_none()); + // Gemini defaults + assert!(config.gemini_api_key.is_none()); + assert!(config.gemini_model.is_none()); + // Other settings assert_eq!(config.theme, Theme::Dark); assert!(config.greeting_enabled); assert!(config.greeting_custom_prompt.is_none()); @@ -249,6 +331,14 @@ mod tests { auto_granted_tools: vec!["Read".to_string(), "Glob".to_string()], ollama_base_url: "http://localhost:11434".to_string(), ollama_model: None, + openai_api_key: None, + openai_base_url: "https://api.openai.com/v1".to_string(), + openai_model: None, + anthropic_api_key: None, + anthropic_base_url: "https://api.anthropic.com".to_string(), + anthropic_model: None, + gemini_api_key: None, + gemini_model: None, theme: Theme::Light, greeting_enabled: true, greeting_custom_prompt: Some("Hello!".to_string()), diff --git a/src-tauri/src/provider_bridge.rs b/src-tauri/src/provider_bridge.rs index c71e529..0cbd97c 100644 --- a/src-tauri/src/provider_bridge.rs +++ b/src-tauri/src/provider_bridge.rs @@ -14,10 +14,13 @@ use crate::types::{ }; use crate::wsl_bridge::WslBridge; -/// A unified bridge that can wrap either the Claude CLI or Ollama provider +/// A unified bridge that can wrap different LLM providers pub enum ProviderBridge { ClaudeCli(WslBridge), Ollama(OllamaBridge), + OpenAi(OpenAiBridge), + Anthropic(AnthropicBridge), + Gemini(GeminiBridge), } impl ProviderBridge { @@ -29,6 +32,15 @@ impl ProviderBridge { ProviderType::Ollama => { ProviderBridge::Ollama(OllamaBridge::new(conversation_id)) } + ProviderType::OpenAi => { + ProviderBridge::OpenAi(OpenAiBridge::new(conversation_id)) + } + ProviderType::Anthropic => { + ProviderBridge::Anthropic(AnthropicBridge::new(conversation_id)) + } + ProviderType::Gemini => { + ProviderBridge::Gemini(GeminiBridge::new(conversation_id)) + } } } @@ -36,6 +48,9 @@ impl ProviderBridge { match self { ProviderBridge::ClaudeCli(bridge) => bridge.start(app, options), ProviderBridge::Ollama(bridge) => bridge.start(app, options), + ProviderBridge::OpenAi(bridge) => bridge.start(app, options), + ProviderBridge::Anthropic(bridge) => bridge.start(app, options), + ProviderBridge::Gemini(bridge) => bridge.start(app, options), } } @@ -43,6 +58,9 @@ impl ProviderBridge { match self { ProviderBridge::ClaudeCli(bridge) => bridge.stop(app), ProviderBridge::Ollama(bridge) => bridge.stop(app), + ProviderBridge::OpenAi(bridge) => bridge.stop(app), + ProviderBridge::Anthropic(bridge) => bridge.stop(app), + ProviderBridge::Gemini(bridge) => bridge.stop(app), } } @@ -50,6 +68,9 @@ impl ProviderBridge { match self { ProviderBridge::ClaudeCli(bridge) => bridge.interrupt(app), ProviderBridge::Ollama(bridge) => bridge.interrupt(app), + ProviderBridge::OpenAi(bridge) => bridge.interrupt(app), + ProviderBridge::Anthropic(bridge) => bridge.interrupt(app), + ProviderBridge::Gemini(bridge) => bridge.interrupt(app), } } @@ -57,6 +78,9 @@ impl ProviderBridge { match self { ProviderBridge::ClaudeCli(bridge) => bridge.send_message(message), ProviderBridge::Ollama(bridge) => bridge.send_message(message), + ProviderBridge::OpenAi(bridge) => bridge.send_message(message), + ProviderBridge::Anthropic(bridge) => bridge.send_message(message), + ProviderBridge::Gemini(bridge) => bridge.send_message(message), } } @@ -68,6 +92,9 @@ impl ProviderBridge { match self { ProviderBridge::ClaudeCli(bridge) => bridge.send_tool_result(tool_use_id, result), ProviderBridge::Ollama(bridge) => bridge.send_tool_result(tool_use_id, result), + ProviderBridge::OpenAi(bridge) => bridge.send_tool_result(tool_use_id, result), + ProviderBridge::Anthropic(bridge) => bridge.send_tool_result(tool_use_id, result), + ProviderBridge::Gemini(bridge) => bridge.send_tool_result(tool_use_id, result), } } @@ -75,6 +102,9 @@ impl ProviderBridge { match self { ProviderBridge::ClaudeCli(bridge) => bridge.is_running(), ProviderBridge::Ollama(bridge) => bridge.is_running(), + ProviderBridge::OpenAi(bridge) => bridge.is_running(), + ProviderBridge::Anthropic(bridge) => bridge.is_running(), + ProviderBridge::Gemini(bridge) => bridge.is_running(), } } @@ -82,6 +112,9 @@ impl ProviderBridge { match self { ProviderBridge::ClaudeCli(bridge) => bridge.get_working_directory(), ProviderBridge::Ollama(bridge) => bridge.get_working_directory(), + ProviderBridge::OpenAi(bridge) => bridge.get_working_directory(), + ProviderBridge::Anthropic(bridge) => bridge.get_working_directory(), + ProviderBridge::Gemini(bridge) => bridge.get_working_directory(), } } @@ -89,6 +122,9 @@ impl ProviderBridge { match self { ProviderBridge::ClaudeCli(bridge) => bridge.get_stats(), ProviderBridge::Ollama(bridge) => bridge.get_stats(), + ProviderBridge::OpenAi(bridge) => bridge.get_stats(), + ProviderBridge::Anthropic(bridge) => bridge.get_stats(), + ProviderBridge::Gemini(bridge) => bridge.get_stats(), } } @@ -96,6 +132,9 @@ impl ProviderBridge { match self { ProviderBridge::ClaudeCli(_) => ProviderType::ClaudeCli, ProviderBridge::Ollama(_) => ProviderType::Ollama, + ProviderBridge::OpenAi(_) => ProviderType::OpenAi, + ProviderBridge::Anthropic(_) => ProviderType::Anthropic, + ProviderBridge::Gemini(_) => ProviderType::Gemini, } } } @@ -495,6 +534,680 @@ fn emit_connection_status( ); } +/// Bridge for OpenAI API provider +pub struct OpenAiBridge { + conversation_id: String, + working_directory: String, + is_running: bool, + stats: Arc>, + api_key: String, + base_url: String, + model: String, + messages: Vec, + cancel_flag: Arc, + app_handle: Option, +} + +impl OpenAiBridge { + pub fn new(conversation_id: String) -> Self { + OpenAiBridge { + conversation_id, + working_directory: String::new(), + is_running: false, + stats: Arc::new(RwLock::new(UsageStats::new())), + api_key: String::new(), + base_url: "https://api.openai.com/v1".to_string(), + model: "gpt-4o".to_string(), + messages: Vec::new(), + cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + app_handle: None, + } + } + + pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> { + if self.is_running { + return Err("OpenAI bridge already running".to_string()); + } + + self.working_directory = options.working_dir.clone(); + self.api_key = options.openai_api_key.clone().unwrap_or_default(); + self.base_url = options.openai_base_url.clone(); + self.model = options.openai_model.clone().unwrap_or_else(|| "gpt-4o".to_string()); + self.app_handle = Some(app.clone()); + self.cancel_flag.store(false, std::sync::atomic::Ordering::SeqCst); + + if self.api_key.is_empty() { + return Err("OpenAI API key is required".to_string()); + } + + self.messages.clear(); + + if let Some(ref instructions) = options.custom_instructions { + if !instructions.is_empty() { + self.messages.push(serde_json::json!({ + "role": "system", + "content": instructions + })); + } + } + + emit_connection_status(&app, ConnectionStatus::Connecting, Some(self.conversation_id.clone())); + self.is_running = true; + emit_connection_status(&app, ConnectionStatus::Connected, Some(self.conversation_id.clone())); + emit_state_change(&app, CharacterState::Idle, None, Some(self.conversation_id.clone())); + Ok(()) + } + + pub fn stop(&mut self, app: &AppHandle) { + self.cancel_flag.store(true, std::sync::atomic::Ordering::SeqCst); + self.is_running = false; + self.app_handle = None; + + let stats_snapshot = self.stats.read().clone(); + let app_clone = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = crate::stats::save_stats(&app_clone, &stats_snapshot).await { + eprintln!("Failed to save stats: {}", e); + } + }); + + self.stats.write().reset_session(); + emit_connection_status(app, ConnectionStatus::Disconnected, Some(self.conversation_id.clone())); + } + + pub fn interrupt(&mut self, app: &AppHandle) -> Result<(), String> { + self.cancel_flag.store(true, std::sync::atomic::Ordering::SeqCst); + self.is_running = false; + emit_connection_status(app, ConnectionStatus::Disconnected, Some(self.conversation_id.clone())); + Ok(()) + } + + pub fn send_message(&mut self, message: &str) -> Result<(), String> { + if !self.is_running { + return Err("OpenAI bridge not running".to_string()); + } + + let app = self.app_handle.clone().ok_or("App handle not available")?; + + self.messages.push(serde_json::json!({ + "role": "user", + "content": message + })); + + self.stats.write().increment_messages(); + emit_state_change(&app, CharacterState::Thinking, None, Some(self.conversation_id.clone())); + + let api_key = self.api_key.clone(); + let base_url = self.base_url.clone(); + let model = self.model.clone(); + let messages = self.messages.clone(); + let stats = self.stats.clone(); + let conv_id = self.conversation_id.clone(); + let cancel_flag = self.cancel_flag.clone(); + + tauri::async_runtime::spawn(async move { + match stream_openai_chat(&base_url, &api_key, &model, &messages, &app, &stats, &conv_id, &cancel_flag).await { + Ok(_) => {} + Err(e) => { + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "error".to_string(), + content: format!("OpenAI error: {}", e), + tool_name: None, + conversation_id: Some(conv_id.clone()), + }, + ); + emit_state_change(&app, CharacterState::Error, None, Some(conv_id)); + } + } + }); + + Ok(()) + } + + pub fn send_tool_result(&mut self, _tool_use_id: &str, _result: serde_json::Value) -> Result<(), String> { + Err("Tool results are not supported with OpenAI provider".to_string()) + } + + pub fn is_running(&self) -> bool { self.is_running } + pub fn get_working_directory(&self) -> &str { &self.working_directory } + pub fn get_stats(&self) -> UsageStats { self.stats.read().clone() } +} + +/// Bridge for Anthropic API provider +pub struct AnthropicBridge { + conversation_id: String, + working_directory: String, + is_running: bool, + stats: Arc>, + api_key: String, + base_url: String, + model: String, + messages: Vec, + cancel_flag: Arc, + app_handle: Option, +} + +impl AnthropicBridge { + pub fn new(conversation_id: String) -> Self { + AnthropicBridge { + conversation_id, + working_directory: String::new(), + is_running: false, + stats: Arc::new(RwLock::new(UsageStats::new())), + api_key: String::new(), + base_url: "https://api.anthropic.com".to_string(), + model: "claude-sonnet-4-5-20250514".to_string(), + messages: Vec::new(), + cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + app_handle: None, + } + } + + pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> { + if self.is_running { + return Err("Anthropic bridge already running".to_string()); + } + + self.working_directory = options.working_dir.clone(); + self.api_key = options.anthropic_api_key.clone().unwrap_or_default(); + self.base_url = options.anthropic_base_url.clone(); + self.model = options.anthropic_model.clone().unwrap_or_else(|| "claude-sonnet-4-5-20250514".to_string()); + self.app_handle = Some(app.clone()); + self.cancel_flag.store(false, std::sync::atomic::Ordering::SeqCst); + + if self.api_key.is_empty() { + return Err("Anthropic API key is required".to_string()); + } + + self.messages.clear(); + + emit_connection_status(&app, ConnectionStatus::Connecting, Some(self.conversation_id.clone())); + self.is_running = true; + emit_connection_status(&app, ConnectionStatus::Connected, Some(self.conversation_id.clone())); + emit_state_change(&app, CharacterState::Idle, None, Some(self.conversation_id.clone())); + Ok(()) + } + + pub fn stop(&mut self, app: &AppHandle) { + self.cancel_flag.store(true, std::sync::atomic::Ordering::SeqCst); + self.is_running = false; + self.app_handle = None; + + let stats_snapshot = self.stats.read().clone(); + let app_clone = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = crate::stats::save_stats(&app_clone, &stats_snapshot).await { + eprintln!("Failed to save stats: {}", e); + } + }); + + self.stats.write().reset_session(); + emit_connection_status(app, ConnectionStatus::Disconnected, Some(self.conversation_id.clone())); + } + + pub fn interrupt(&mut self, app: &AppHandle) -> Result<(), String> { + self.cancel_flag.store(true, std::sync::atomic::Ordering::SeqCst); + self.is_running = false; + emit_connection_status(app, ConnectionStatus::Disconnected, Some(self.conversation_id.clone())); + Ok(()) + } + + pub fn send_message(&mut self, message: &str) -> Result<(), String> { + if !self.is_running { + return Err("Anthropic bridge not running".to_string()); + } + + let app = self.app_handle.clone().ok_or("App handle not available")?; + + self.messages.push(serde_json::json!({ + "role": "user", + "content": [{ "type": "text", "text": message }] + })); + + self.stats.write().increment_messages(); + emit_state_change(&app, CharacterState::Thinking, None, Some(self.conversation_id.clone())); + + let api_key = self.api_key.clone(); + let base_url = self.base_url.clone(); + let model = self.model.clone(); + let messages = self.messages.clone(); + let stats = self.stats.clone(); + let conv_id = self.conversation_id.clone(); + let cancel_flag = self.cancel_flag.clone(); + + tauri::async_runtime::spawn(async move { + match stream_anthropic_chat(&base_url, &api_key, &model, &messages, &app, &stats, &conv_id, &cancel_flag).await { + Ok(_) => {} + Err(e) => { + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "error".to_string(), + content: format!("Anthropic error: {}", e), + tool_name: None, + conversation_id: Some(conv_id.clone()), + }, + ); + emit_state_change(&app, CharacterState::Error, None, Some(conv_id)); + } + } + }); + + Ok(()) + } + + pub fn send_tool_result(&mut self, _tool_use_id: &str, _result: serde_json::Value) -> Result<(), String> { + Err("Tool results are not supported with direct Anthropic API provider".to_string()) + } + + pub fn is_running(&self) -> bool { self.is_running } + pub fn get_working_directory(&self) -> &str { &self.working_directory } + pub fn get_stats(&self) -> UsageStats { self.stats.read().clone() } +} + +/// Bridge for Google Gemini API provider +pub struct GeminiBridge { + conversation_id: String, + working_directory: String, + is_running: bool, + stats: Arc>, + api_key: String, + model: String, + messages: Vec, + cancel_flag: Arc, + app_handle: Option, +} + +impl GeminiBridge { + pub fn new(conversation_id: String) -> Self { + GeminiBridge { + conversation_id, + working_directory: String::new(), + is_running: false, + stats: Arc::new(RwLock::new(UsageStats::new())), + api_key: String::new(), + model: "gemini-2.0-flash".to_string(), + messages: Vec::new(), + cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + app_handle: None, + } + } + + pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> { + if self.is_running { + return Err("Gemini bridge already running".to_string()); + } + + self.working_directory = options.working_dir.clone(); + self.api_key = options.gemini_api_key.clone().unwrap_or_default(); + self.model = options.gemini_model.clone().unwrap_or_else(|| "gemini-2.0-flash".to_string()); + self.app_handle = Some(app.clone()); + self.cancel_flag.store(false, std::sync::atomic::Ordering::SeqCst); + + if self.api_key.is_empty() { + return Err("Gemini API key is required".to_string()); + } + + self.messages.clear(); + + emit_connection_status(&app, ConnectionStatus::Connecting, Some(self.conversation_id.clone())); + self.is_running = true; + emit_connection_status(&app, ConnectionStatus::Connected, Some(self.conversation_id.clone())); + emit_state_change(&app, CharacterState::Idle, None, Some(self.conversation_id.clone())); + Ok(()) + } + + pub fn stop(&mut self, app: &AppHandle) { + self.cancel_flag.store(true, std::sync::atomic::Ordering::SeqCst); + self.is_running = false; + self.app_handle = None; + + let stats_snapshot = self.stats.read().clone(); + let app_clone = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = crate::stats::save_stats(&app_clone, &stats_snapshot).await { + eprintln!("Failed to save stats: {}", e); + } + }); + + self.stats.write().reset_session(); + emit_connection_status(app, ConnectionStatus::Disconnected, Some(self.conversation_id.clone())); + } + + pub fn interrupt(&mut self, app: &AppHandle) -> Result<(), String> { + self.cancel_flag.store(true, std::sync::atomic::Ordering::SeqCst); + self.is_running = false; + emit_connection_status(app, ConnectionStatus::Disconnected, Some(self.conversation_id.clone())); + Ok(()) + } + + pub fn send_message(&mut self, message: &str) -> Result<(), String> { + if !self.is_running { + return Err("Gemini bridge not running".to_string()); + } + + let app = self.app_handle.clone().ok_or("App handle not available")?; + + self.messages.push(serde_json::json!({ + "role": "user", + "parts": [{ "text": message }] + })); + + self.stats.write().increment_messages(); + emit_state_change(&app, CharacterState::Thinking, None, Some(self.conversation_id.clone())); + + let api_key = self.api_key.clone(); + let model = self.model.clone(); + let messages = self.messages.clone(); + let stats = self.stats.clone(); + let conv_id = self.conversation_id.clone(); + let cancel_flag = self.cancel_flag.clone(); + + tauri::async_runtime::spawn(async move { + match stream_gemini_chat(&api_key, &model, &messages, &app, &stats, &conv_id, &cancel_flag).await { + Ok(_) => {} + Err(e) => { + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "error".to_string(), + content: format!("Gemini error: {}", e), + tool_name: None, + conversation_id: Some(conv_id.clone()), + }, + ); + emit_state_change(&app, CharacterState::Error, None, Some(conv_id)); + } + } + }); + + Ok(()) + } + + pub fn send_tool_result(&mut self, _tool_use_id: &str, _result: serde_json::Value) -> Result<(), String> { + Err("Tool results are not supported with Gemini provider".to_string()) + } + + pub fn is_running(&self) -> bool { self.is_running } + pub fn get_working_directory(&self) -> &str { &self.working_directory } + pub fn get_stats(&self) -> UsageStats { self.stats.read().clone() } +} + +// Stream functions for the new providers +async fn stream_openai_chat( + base_url: &str, + api_key: &str, + model: &str, + messages: &[serde_json::Value], + app: &AppHandle, + stats: &Arc>, + conversation_id: &str, + cancel_flag: &Arc, +) -> Result { + let client = reqwest::Client::new(); + let url = format!("{}/chat/completions", base_url); + + emit_state_change(app, CharacterState::Typing, None, Some(conversation_id.to_string())); + + let request_body = serde_json::json!({ + "model": model, + "messages": messages, + "stream": true + }); + + let response = client + .post(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .await + .map_err(|e| format!("Failed to send request to OpenAI: {}", e))?; + + if !response.status().is_success() { + let error_text = response.text().await.unwrap_or_default(); + return Err(format!("OpenAI API error: {}", error_text)); + } + + let mut full_response = String::new(); + let mut stream = response.bytes_stream(); + use futures_util::StreamExt; + + while let Some(chunk_result) = stream.next().await { + if cancel_flag.load(std::sync::atomic::Ordering::SeqCst) { + break; + } + + match chunk_result { + Ok(chunk) => { + let chunk_str = String::from_utf8_lossy(&chunk); + for line in chunk_str.lines() { + if line.starts_with("data: ") { + let data = &line[6..]; + if data == "[DONE]" { + break; + } + if let Ok(parsed) = serde_json::from_str::(data) { + if let Some(content) = parsed["choices"][0]["delta"]["content"].as_str() { + full_response.push_str(content); + let _ = app.emit("claude:stream", content.to_string()); + } + } + } + } + } + Err(e) => return Err(format!("Error reading stream: {}", e)), + } + } + + stats.write().increment_messages(); + finalize_response(app, &full_response, stats, conversation_id, model).await; + Ok(full_response) +} + +async fn stream_anthropic_chat( + base_url: &str, + api_key: &str, + model: &str, + messages: &[serde_json::Value], + app: &AppHandle, + stats: &Arc>, + conversation_id: &str, + cancel_flag: &Arc, +) -> Result { + let client = reqwest::Client::new(); + let url = format!("{}/v1/messages", base_url); + + emit_state_change(app, CharacterState::Typing, None, Some(conversation_id.to_string())); + + let request_body = serde_json::json!({ + "model": model, + "messages": messages, + "max_tokens": 4096, + "stream": true + }); + + let response = client + .post(&url) + .header("x-api-key", api_key) + .header("anthropic-version", "2023-06-01") + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .await + .map_err(|e| format!("Failed to send request to Anthropic: {}", e))?; + + if !response.status().is_success() { + let error_text = response.text().await.unwrap_or_default(); + return Err(format!("Anthropic API error: {}", error_text)); + } + + let mut full_response = String::new(); + let mut stream = response.bytes_stream(); + use futures_util::StreamExt; + + while let Some(chunk_result) = stream.next().await { + if cancel_flag.load(std::sync::atomic::Ordering::SeqCst) { + break; + } + + match chunk_result { + Ok(chunk) => { + let chunk_str = String::from_utf8_lossy(&chunk); + for line in chunk_str.lines() { + if line.starts_with("data: ") { + let data = &line[6..]; + if let Ok(parsed) = serde_json::from_str::(data) { + if parsed["type"] == "content_block_delta" { + if let Some(text) = parsed["delta"]["text"].as_str() { + full_response.push_str(text); + let _ = app.emit("claude:stream", text.to_string()); + } + } + } + } + } + } + Err(e) => return Err(format!("Error reading stream: {}", e)), + } + } + + stats.write().increment_messages(); + finalize_response(app, &full_response, stats, conversation_id, model).await; + Ok(full_response) +} + +async fn stream_gemini_chat( + api_key: &str, + model: &str, + messages: &[serde_json::Value], + app: &AppHandle, + stats: &Arc>, + conversation_id: &str, + cancel_flag: &Arc, +) -> Result { + let client = reqwest::Client::new(); + let url = format!( + "https://generativelanguage.googleapis.com/v1beta/models/{}:streamGenerateContent?alt=sse&key={}", + model, api_key + ); + + emit_state_change(app, CharacterState::Typing, None, Some(conversation_id.to_string())); + + let request_body = serde_json::json!({ + "contents": messages, + "generationConfig": { + "maxOutputTokens": 4096 + } + }); + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .await + .map_err(|e| format!("Failed to send request to Gemini: {}", e))?; + + if !response.status().is_success() { + let error_text = response.text().await.unwrap_or_default(); + return Err(format!("Gemini API error: {}", error_text)); + } + + let mut full_response = String::new(); + let mut stream = response.bytes_stream(); + use futures_util::StreamExt; + + while let Some(chunk_result) = stream.next().await { + if cancel_flag.load(std::sync::atomic::Ordering::SeqCst) { + break; + } + + match chunk_result { + Ok(chunk) => { + let chunk_str = String::from_utf8_lossy(&chunk); + for line in chunk_str.lines() { + if line.starts_with("data: ") { + let data = &line[6..]; + if let Ok(parsed) = serde_json::from_str::(data) { + if let Some(text) = parsed["candidates"][0]["content"]["parts"][0]["text"].as_str() { + full_response.push_str(text); + let _ = app.emit("claude:stream", text.to_string()); + } + } + } + } + } + Err(e) => return Err(format!("Error reading stream: {}", e)), + } + } + + stats.write().increment_messages(); + finalize_response(app, &full_response, stats, conversation_id, model).await; + Ok(full_response) +} + +async fn finalize_response( + app: &AppHandle, + full_response: &str, + stats: &Arc>, + conversation_id: &str, + model: &str, +) { + // Emit the complete response + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "assistant".to_string(), + content: full_response.to_string(), + tool_name: None, + conversation_id: Some(conversation_id.to_string()), + }, + ); + + // Count code blocks + let code_blocks = full_response.matches("```").count() / 2; + for _ in 0..code_blocks { + stats.write().increment_code_blocks(); + } + + // Check achievements + let newly_unlocked = { + let mut stats_guard = stats.write(); + stats_guard.check_achievements() + }; + + for achievement_id in &newly_unlocked { + let info = get_achievement_info(achievement_id); + let _ = app.emit( + "achievement:unlocked", + AchievementUnlockedEvent { achievement: info }, + ); + } + + if !newly_unlocked.is_empty() { + let app_clone = app.clone(); + let achievements_progress = stats.read().achievements.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = crate::achievements::save_achievements(&app_clone, &achievements_progress).await { + eprintln!("Failed to save achievements: {}", e); + } + }); + } + + // Emit stats update + let current_stats = stats.read().clone(); + let _ = app.emit("claude:stats", StatsUpdateEvent { stats: current_stats }); + + emit_state_change(app, CharacterState::Success, None, Some(conversation_id.to_string())); + + // Estimate tokens (rough approximation) + let estimated_tokens = (full_response.len() / 4) as u64; + stats.write().add_usage(0, estimated_tokens, model); +} + #[cfg(test)] mod tests { use super::*; @@ -514,6 +1227,15 @@ mod tests { let ollama = ProviderBridge::new(ProviderType::Ollama, "conv2".to_string()); assert_eq!(ollama.provider_type(), ProviderType::Ollama); + + let openai = ProviderBridge::new(ProviderType::OpenAi, "conv3".to_string()); + assert_eq!(openai.provider_type(), ProviderType::OpenAi); + + let anthropic = ProviderBridge::new(ProviderType::Anthropic, "conv4".to_string()); + assert_eq!(anthropic.provider_type(), ProviderType::Anthropic); + + let gemini = ProviderBridge::new(ProviderType::Gemini, "conv5".to_string()); + assert_eq!(gemini.provider_type(), ProviderType::Gemini); } #[test] @@ -523,6 +1245,15 @@ mod tests { let bridge = ProviderBridge::new(ProviderType::Ollama, "test".to_string()); assert!(!bridge.is_running()); + + let bridge = ProviderBridge::new(ProviderType::OpenAi, "test".to_string()); + assert!(!bridge.is_running()); + + let bridge = ProviderBridge::new(ProviderType::Anthropic, "test".to_string()); + assert!(!bridge.is_running()); + + let bridge = ProviderBridge::new(ProviderType::Gemini, "test".to_string()); + assert!(!bridge.is_running()); } #[test] diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index 48e75f9..983b615 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -21,6 +21,9 @@ pub enum ProviderType { #[default] ClaudeCli, Ollama, + OpenAi, + Anthropic, + Gemini, } #[allow(dead_code)] @@ -29,6 +32,9 @@ impl ProviderType { match self { ProviderType::ClaudeCli => "Claude CLI", ProviderType::Ollama => "Ollama (Local)", + ProviderType::OpenAi => "OpenAI API", + ProviderType::Anthropic => "Anthropic API", + ProviderType::Gemini => "Google Gemini", } } @@ -36,10 +42,24 @@ impl ProviderType { match self { ProviderType::ClaudeCli => "Use Claude Code CLI for AI assistance", ProviderType::Ollama => "Use locally running Ollama models", + ProviderType::OpenAi => "Direct OpenAI API access (GPT-4, etc.)", + ProviderType::Anthropic => "Direct Anthropic API access (Claude models)", + ProviderType::Gemini => "Direct Google Gemini API access", } } + + pub fn requires_api_key(&self) -> bool { + matches!( + self, + ProviderType::OpenAi | ProviderType::Anthropic | ProviderType::Gemini + ) + } } +// Note: The new providers (OpenAI, Anthropic, Gemini) are implemented directly +// in provider_bridge.rs using the Bridge pattern rather than the LlmProvider trait. +// This simplifies the architecture while still providing full functionality. + #[allow(dead_code)] pub fn create_provider( provider_type: ProviderType, @@ -48,6 +68,12 @@ pub fn create_provider( match provider_type { ProviderType::ClaudeCli => Box::new(ClaudeCliProvider::new(config)), ProviderType::Ollama => Box::new(OllamaProvider::new(config)), + // The new API-based providers are handled in provider_bridge.rs + ProviderType::OpenAi | ProviderType::Anthropic | ProviderType::Gemini => { + // These providers use the Bridge pattern in provider_bridge.rs + // Fall back to Claude CLI for trait-based usage + Box::new(ClaudeCliProvider::new(config)) + } } } @@ -59,6 +85,9 @@ mod tests { fn test_provider_type_display_name() { assert_eq!(ProviderType::ClaudeCli.display_name(), "Claude CLI"); assert_eq!(ProviderType::Ollama.display_name(), "Ollama (Local)"); + assert_eq!(ProviderType::OpenAi.display_name(), "OpenAI API"); + assert_eq!(ProviderType::Anthropic.display_name(), "Anthropic API"); + assert_eq!(ProviderType::Gemini.display_name(), "Google Gemini"); } #[test] @@ -76,6 +105,18 @@ mod tests { let ollama = ProviderType::Ollama; let json = serde_json::to_string(&ollama).unwrap(); assert_eq!(json, "\"ollama\""); + + let openai = ProviderType::OpenAi; + let json = serde_json::to_string(&openai).unwrap(); + assert_eq!(json, "\"open_ai\""); + + let anthropic = ProviderType::Anthropic; + let json = serde_json::to_string(&anthropic).unwrap(); + assert_eq!(json, "\"anthropic\""); + + let gemini = ProviderType::Gemini; + let json = serde_json::to_string(&gemini).unwrap(); + assert_eq!(json, "\"gemini\""); } #[test] @@ -85,5 +126,23 @@ mod tests { let ollama: ProviderType = serde_json::from_str("\"ollama\"").unwrap(); assert_eq!(ollama, ProviderType::Ollama); + + let openai: ProviderType = serde_json::from_str("\"open_ai\"").unwrap(); + assert_eq!(openai, ProviderType::OpenAi); + + let anthropic: ProviderType = serde_json::from_str("\"anthropic\"").unwrap(); + assert_eq!(anthropic, ProviderType::Anthropic); + + let gemini: ProviderType = serde_json::from_str("\"gemini\"").unwrap(); + assert_eq!(gemini, ProviderType::Gemini); + } + + #[test] + fn test_provider_type_requires_api_key() { + assert!(!ProviderType::ClaudeCli.requires_api_key()); + assert!(!ProviderType::Ollama.requires_api_key()); + assert!(ProviderType::OpenAi.requires_api_key()); + assert!(ProviderType::Anthropic.requires_api_key()); + assert!(ProviderType::Gemini.requires_api_key()); } } diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index 0b91735..1538344 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -23,6 +23,14 @@ auto_granted_tools: [], ollama_base_url: "http://localhost:11434", ollama_model: null, + openai_api_key: null, + openai_base_url: "https://api.openai.com/v1", + openai_model: null, + anthropic_api_key: null, + anthropic_base_url: "https://api.anthropic.com", + anthropic_model: null, + gemini_api_key: null, + gemini_model: null, theme: "dark", greeting_enabled: true, greeting_custom_prompt: null, @@ -79,6 +87,9 @@ const availableProviders: { value: ProviderType; label: string; description: string }[] = [ { value: "claude_cli", label: "Claude CLI", description: "Use Claude Code CLI for AI assistance" }, { value: "ollama", label: "Ollama (Local)", description: "Use locally running Ollama models" }, + { value: "open_ai", label: "OpenAI API", description: "Direct OpenAI API access (GPT-4o, etc.)" }, + { value: "anthropic", label: "Anthropic API", description: "Direct Anthropic API access (Claude models)" }, + { value: "gemini", label: "Google Gemini", description: "Direct Google Gemini API access" }, ]; const availableModels = [ @@ -97,6 +108,29 @@ { value: "gemma2", label: "Gemma 2" }, ]; + const openaiModels = [ + { value: "", label: "Default (gpt-4o)" }, + { value: "gpt-4o", label: "GPT-4o" }, + { value: "gpt-4o-mini", label: "GPT-4o Mini" }, + { value: "gpt-4-turbo", label: "GPT-4 Turbo" }, + { value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" }, + ]; + + const anthropicModels = [ + { value: "", label: "Default (Claude Sonnet 4.5)" }, + { value: "claude-sonnet-4-5-20250514", label: "Claude Sonnet 4.5" }, + { value: "claude-opus-4-20250514", label: "Claude Opus 4" }, + { value: "claude-3-5-sonnet-20241022", label: "Claude 3.5 Sonnet" }, + { value: "claude-3-5-haiku-20241022", label: "Claude 3.5 Haiku" }, + ]; + + const geminiModels = [ + { value: "", label: "Default (gemini-2.0-flash)" }, + { value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" }, + { value: "gemini-1.5-pro", label: "Gemini 1.5 Pro" }, + { value: "gemini-1.5-flash", label: "Gemini 1.5 Flash" }, + ]; + const commonTools = [ "Read", "Write", @@ -297,6 +331,162 @@ {/if} + + + {#if config.provider_type === "open_ai"} +
+

OpenAI Settings

+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ Tip: You can use this with any OpenAI-compatible API (Groq, Together AI, etc.) + by changing the Base URL. +
+
+ {/if} + + + {#if config.provider_type === "anthropic"} +
+

Anthropic Settings

+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ Note: This uses the Anthropic API directly without Claude Code CLI features + like tools, MCP, or thinking blocks. +
+
+ {/if} + + + {#if config.provider_type === "gemini"} +
+

Gemini Settings

+ + +
+ + +

+ Get your API key from Google AI Studio +

+
+ + +
+ + +
+ +
+ Note: Gemini has a generous free tier! Great for experimenting. +
+
+ {/if} diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 840e2e8..6011347 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -51,6 +51,14 @@ auto_granted_tools: [], ollama_base_url: "http://localhost:11434", ollama_model: null, + openai_api_key: null, + openai_base_url: "https://api.openai.com/v1", + openai_model: null, + anthropic_api_key: null, + anthropic_base_url: "https://api.anthropic.com", + anthropic_model: null, + gemini_api_key: null, + gemini_model: null, theme: "dark", greeting_enabled: true, greeting_custom_prompt: null, @@ -157,6 +165,14 @@ allowed_tools: allAllowedTools, ollama_base_url: currentConfig.ollama_base_url || "http://localhost:11434", ollama_model: currentConfig.ollama_model || null, + openai_api_key: currentConfig.openai_api_key || null, + openai_base_url: currentConfig.openai_base_url || "https://api.openai.com/v1", + openai_model: currentConfig.openai_model || null, + anthropic_api_key: currentConfig.anthropic_api_key || null, + anthropic_base_url: currentConfig.anthropic_base_url || "https://api.anthropic.com", + anthropic_model: currentConfig.anthropic_model || null, + gemini_api_key: currentConfig.gemini_api_key || null, + gemini_model: currentConfig.gemini_model || null, }, }); } catch (error) { diff --git a/src/lib/stores/config.ts b/src/lib/stores/config.ts index 855fda9..fefadd2 100644 --- a/src/lib/stores/config.ts +++ b/src/lib/stores/config.ts @@ -2,7 +2,7 @@ import { writable, derived } from "svelte/store"; import { invoke } from "@tauri-apps/api/core"; export type Theme = "dark" | "light" | "high-contrast" | "custom"; -export type ProviderType = "claude_cli" | "ollama"; +export type ProviderType = "claude_cli" | "ollama" | "open_ai" | "anthropic" | "gemini"; export interface CustomThemeColors { bg_primary: string | null; @@ -22,8 +22,20 @@ export interface HikariConfig { custom_instructions: string | null; mcp_servers_json: string | null; auto_granted_tools: string[]; + // Ollama settings ollama_base_url: string; ollama_model: string | null; + // OpenAI settings + openai_api_key: string | null; + openai_base_url: string; + openai_model: string | null; + // Anthropic settings + anthropic_api_key: string | null; + anthropic_base_url: string; + anthropic_model: string | null; + // Gemini settings + gemini_api_key: string | null; + gemini_model: string | null; theme: Theme; greeting_enabled: boolean; greeting_custom_prompt: string | null; @@ -52,6 +64,14 @@ const defaultConfig: HikariConfig = { auto_granted_tools: [], ollama_base_url: "http://localhost:11434", ollama_model: null, + openai_api_key: null, + openai_base_url: "https://api.openai.com/v1", + openai_model: null, + anthropic_api_key: null, + anthropic_base_url: "https://api.anthropic.com", + anthropic_model: null, + gemini_api_key: null, + gemini_model: null, theme: "dark", greeting_enabled: true, greeting_custom_prompt: null,