Files

1 line
20 KiB
JSON

{"repo": "0xsero/azul", "files": {"src/config.rs": {"0f4df982389efddc86731108a83b9e3afbe14f5b": {"t": 1785596004.2186608, "n": 7814, "h": "d487c18ed19d20bc", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::fs;\nuse std::path::PathBuf;\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Config {\n #[serde(default)]\n pub theme: Theme,\n #[serde(default)]\n pub ai: Option<AIConfig>,\n #[serde(default = \"default_refresh_rate\")]\n pub refresh_rate_ms: u64,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub rag_base_url: Option<String>,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub memory_scope: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Theme {\n #[serde(default = \"default_accent\")]\n pub accent: String,\n #[serde(default = \"default_border\")]\n pub border: String,\n #[serde(default = \"default_text\")]\n pub text: String,\n #[serde(default = \"default_bg\")]\n pub background: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AIConfig {\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub provider: Option<String>,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub api_key: Option<String>,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub model: Option<String>,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub models: Option<Vec<String>>,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub fallback_models: Option<Vec<String>>,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub base_url: Option<String>,\n}\n\nfn default_accent() -> String {\n \"#00BFFF\".to_string() // Deep sky blue (Azul theme)\n}\n\nfn default_border() -> String {\n \"#7aa2f7\".to_string() // Tokyo Night blue\n}\n\nfn default_text() -> String {\n \"#c0caf5\".to_string() // Tokyo Night foreground\n}\n\nfn default_bg() -> String {\n \"#1a1b26\".to_string() // Tokyo Night background\n}\n\nfn default_refresh_rate() -> u64 {\n 200\n}\n\nimpl Default for Theme {\n fn default() -> Self {\n Self {\n accent: default_accent(),\n border: default_border(),\n text: default_text(),\n background: default_bg(),\n }\n }\n}\n\nimpl Default for Config {\n fn default() -> Self {\n Self {\n theme: Theme::default(),\n ai: None,\n refresh_rate_ms: default_refresh_rate(),\n rag_base_url: None,\n memory_scope: None,\n }\n }\n}\n\nimpl Config {\n /// Get the config file path (JSON format, compatible with Go version)\n pub fn config_path() -> PathBuf {\n dirs::config_dir()\n .unwrap_or_else(|| PathBuf::from(\".\"))\n .join(\"azul\")\n .join(\"config.json\")\n }\n\n /// Load config from file and environment variables\n pub fn load() -> Result<Self> {\n let path = Self::config_path();\n\n // Start with default or load from file\n let mut config = if path.exists() {\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read config from {}\", path.display()))?;\n\n serde_json::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config from {}\", path.display()))?\n } else {\n Self::default()\n };\n\n // Initialize AI config if not present\n if config.ai.is_none() {\n config.ai = Some(AIConfig {\n provider: None,\n api_key: None,\n model: None,\n models: None,\n fallback_models: None,\n base_url: None,\n });\n }\n\n let ai = config.ai.as_mut().unwrap();\n let mut changed = false;\n\n // Environment variables override file config (compatible with Go version)\n\n // Try OPENROUTER_API_KEY first, then AZUL_AI_API_KEY\n if let Some(key) = lookup_env_non_empty(&[\"OPENROUTER_API_KEY\", \"AZUL_AI_API_KEY\"]) {\n if ai.api_key.as_ref() != Some(&key) {\n ai.api_key = Some(key);\n changed = true;\n }\n }\n\n // Provider\n if let Some(provider) = std::env::var(\"AZUL_AI_PROVIDER\").ok() {\n if !provider.is_empty() && ai.provider.as_ref() != Some(&provider) {\n ai.provider = Some(provider);\n changed = true;\n }\n }\n\n // Model (OPENROUTER_MODEL or AZUL_AI_MODEL)\n if let Some(model) = lookup_env_non_empty(&[\"OPENROUTER_MODEL\", \"AZUL_AI_MODEL\"]) {\n if ai.model.as_ref() != Some(&model) {\n ai.model = Some(model);\n changed = true;\n }\n }\n\n // Models list\n if let Some(models_env) = std::env::var(\"AZUL_AI_MODELS\").ok() {\n let models = split_list(&models_env);\n if !models.is_empty() {\n ai.models = Some(models);\n changed = true;\n }\n }\n\n // Fallback models\n if let Some(fallback_env) = std::env::var(\"AZUL_AI_FALLBACK_MODELS\").ok() {\n let models = split_list(&fallback_env);\n if !models.is_empty() {\n ai.fallback_models = Some(models);\n changed = true;\n }\n }\n\n // Set defaults when API key is present but provider/model are not\n if ai.api_key.is_some() {\n if ai.provider.is_none() {\n ai.provider = Some(\"openrouter\".to_string());\n changed = true;\n }\n\n if ai.provider.as_deref() == Some(\"openrouter\") && ai.model.is_none() {\n ai.model = Some(\"qwen/qwen3-235b-a22b:free\".to_string());\n changed = true;\n }\n\n // Set default fallback models if not specified\n if ai.fallback_models.is_none() {\n ai.fallback_models = Some(vec![\n \"x-ai/grok-4.1-fast:free\".to_string(),\n \"kwaipilot/kat-coder-pro:free\".to_string(),\n \"z-ai/glm-4.5-air:free\".to_string(),\n \"moonshotai/kimi-k2:free\".to_string(),\n ]);\n changed = true;\n }\n }\n\n // Save config if changed and we have AI settings\n if changed && ai.api_key.is_some() && ai.provider.is_some() {\n let _ = config.save();\n }\n\n Ok(config)\n }\n\n pub fn save(&self) -> Result<()> {\n let path = Self::config_path();\n\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)?;\n }\n\n let contents = serde_json::to_string_pretty(self)?;\n fs::write(&path, contents)\n .with_context(|| format!(\"Failed to write config to {}\", path.display()))?;\n\n Ok(())\n }\n\n /// Get the API key for AI features\n pub fn get_api_key(&self) -> Option<&str> {\n self.ai.as_ref()?.api_key.as_deref()\n }\n\n /// Get the AI model to use\n pub fn get_ai_model(&self) -> Option<&str> {\n self.ai.as_ref()?.model.as_deref()\n }\n\n /// Get fallback models\n pub fn get_fallback_models(&self) -> Option<Vec<String>> {\n self.ai.as_ref()?.fallback_models.clone()\n }\n\n /// Get the AI base URL\n pub fn get_ai_base_url(&self) -> Option<&str> {\n self.ai.as_ref()?.base_url.as_deref()\n }\n}\n\n/// Look up environment variable from multiple possible keys\nfn lookup_env_non_empty(keys: &[&str]) -> Option<String> {\n for key in keys {\n if let Ok(val) = std::env::var(key) {\n if !val.is_empty() {\n return Some(val);\n }\n }\n }\n None\n}\n\n/// Split comma/space delimited list into trimmed entries\nfn split_list(value: &str) -> Vec<String> {\n value\n .split(|c| c == ',' || c == ' ' || c == '\\n' || c == '\\t')\n .map(|s| s.trim())\n .filter(|s| !s.is_empty())\n .map(String::from)\n .collect()\n}\n"}}, "src/ai/mod.rs": {"0f4df982389efddc86731108a83b9e3afbe14f5b": {"t": 1785596006.5931234, "n": 11348, "h": "cda0c6f7194e0aae", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::time::Duration;\n\n/// AI Provider trait for LLM backends\npub trait Provider: Send + Sync {\n fn complete(&self, prompt: &str) -> Result<String>;\n fn complete_with_system(&self, system: &str, user: &str) -> Result<String>;\n fn name(&self) -> &str;\n fn is_available(&self) -> bool;\n}\n\n/// OpenRouter provider with fallback model support\npub struct OpenRouterProvider {\n api_key: String,\n models: Vec<String>,\n base_url: String,\n client: reqwest::blocking::Client,\n}\n\n#[derive(Debug, Serialize)]\nstruct OpenAIRequest {\n model: String,\n messages: Vec<OpenAIMessage>,\n max_tokens: u32,\n temperature: f32,\n stream: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\nstruct OpenAIMessage {\n role: String,\n content: String,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OpenAIResponse {\n choices: Option<Vec<OpenAIChoice>>,\n error: Option<OpenAIError>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OpenAIChoice {\n message: OpenAIMessage,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OpenAIError {\n message: String,\n}\n\n/// Default fallback models for OpenRouter (free tier)\npub fn default_fallback_models() -> Vec<String> {\n vec![\n \"x-ai/grok-4.1-fast:free\".to_string(),\n \"kwaipilot/kat-coder-pro:free\".to_string(),\n \"z-ai/glm-4.5-air:free\".to_string(),\n \"moonshotai/kimi-k2:free\".to_string(),\n ]\n}\n\nimpl OpenRouterProvider {\n pub fn new(api_key: String, models: Option<Vec<String>>, base_url: Option<String>) -> Result<Self> {\n let client = reqwest::blocking::Client::builder()\n .timeout(Duration::from_secs(60))\n .build()\n .context(\"Failed to create HTTP client\")?;\n\n let models = models.unwrap_or_else(default_fallback_models);\n let base_url = base_url.unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string());\n\n Ok(Self {\n api_key,\n models,\n base_url,\n client,\n })\n }\n\n fn try_model(&self, system: &str, user: &str, model: &str) -> Result<String> {\n let mut messages = vec![];\n\n if !system.is_empty() {\n messages.push(OpenAIMessage {\n role: \"system\".to_string(),\n content: system.to_string(),\n });\n }\n\n messages.push(OpenAIMessage {\n role: \"user\".to_string(),\n content: user.to_string(),\n });\n\n let request = OpenAIRequest {\n model: model.to_string(),\n messages,\n max_tokens: 1024,\n temperature: 0.7,\n stream: false,\n };\n\n let url = format!(\"{}/chat/completions\", self.base_url.trim_end_matches('/'));\n let response = self\n .client\n .post(&url)\n .header(\"Content-Type\", \"application/json\")\n .header(\"Authorization\", format!(\"Bearer {}\", self.api_key))\n .header(\"HTTP-Referer\", \"https://github.com/ser/azul-browse\")\n .header(\"X-Title\", \"Azul Terminal Browser\")\n .json(&request)\n .send()\n .context(\"Failed to send request\")?;\n\n let result: OpenAIResponse = response.json().context(\"Failed to parse response\")?;\n\n if let Some(error) = result.error {\n anyhow::bail!(\"API error: {}\", error.message);\n }\n\n let choices = result.choices.context(\"No choices in response\")?;\n let first = choices.first().context(\"Empty choices array\")?;\n\n Ok(first.message.content.clone())\n }\n}\n\nimpl Provider for OpenRouterProvider {\n fn complete(&self, prompt: &str) -> Result<String> {\n self.complete_with_system(\"\", prompt)\n }\n\n fn complete_with_system(&self, system: &str, user: &str) -> Result<String> {\n let mut errors = vec![];\n\n for model in &self.models {\n match self.try_model(system, user, model) {\n Ok(response) => return Ok(response),\n Err(e) => {\n errors.push(format!(\"{}: {}\", model, e));\n }\n }\n }\n\n anyhow::bail!(\"All models failed: {}\", errors.join(\"; \"))\n }\n\n fn name(&self) -> &str {\n \"OpenRouter\"\n }\n\n fn is_available(&self) -> bool {\n !self.api_key.is_empty()\n }\n}\n\n/// Summarizer for AI-powered content summarization\npub struct Summarizer {\n provider: Box<dyn Provider>,\n}\n\n/// Search result for summarization\n#[derive(Debug, Clone)]\npub struct SearchResult {\n pub title: String,\n pub url: String,\n pub description: String,\n pub source: String,\n}\n\nimpl Summarizer {\n pub fn new(provider: Box<dyn Provider>) -> Self {\n Self { provider }\n }\n\n /// Create a summarizer from config\n pub fn from_config(config: &crate::config::Config) -> Option<Self> {\n let api_key = config.get_api_key()?.to_string();\n if api_key.is_empty() {\n return None;\n }\n\n // Get model and fallback models from config\n let model = config.get_ai_model().map(String::from);\n let fallback_models = config.get_fallback_models();\n let base_url = config.get_ai_base_url().map(String::from);\n\n // Build models list: primary model + fallback models\n let mut models = Vec::new();\n if let Some(m) = model {\n models.push(m);\n }\n if let Some(fallback) = fallback_models {\n models.extend(fallback);\n }\n\n // If no models specified, use the default primary model\n if models.is_empty() {\n models.push(\"qwen/qwen3-235b-a22b:free\".to_string());\n }\n\n let provider = OpenRouterProvider::new(api_key, Some(models), base_url).ok()?;\n Some(Self::new(Box::new(provider)))\n }\n\n /// Create a summarizer from environment config (deprecated, use from_config)\n pub fn from_env() -> Option<Self> {\n let config = crate::config::Config::load().ok()?;\n Self::from_config(&config)\n }\n\n /// Summarize search results\n pub fn summarize_search(&self, query: &str, results: &[SearchResult]) -> Result<String> {\n if results.is_empty() {\n anyhow::bail!(\"No results to summarize\");\n }\n\n let mut context = String::new();\n for (i, r) in results.iter().take(10).enumerate() {\n context.push_str(&format!(\"Result {}:\\n\", i + 1));\n context.push_str(&format!(\"Title: {}\\n\", r.title));\n context.push_str(&format!(\"Source: {}\\n\", r.source));\n if !r.description.is_empty() {\n context.push_str(&format!(\"Description: {}\\n\", r.description));\n }\n context.push('\\n');\n }\n\n let system_prompt = r#\"You are a helpful research assistant. Analyze the search results and provide a concise summary.\nYour summary should:\n1. Identify the main themes or findings across results\n2. Highlight the most relevant information for the query\n3. Note any conflicting information if present\n4. Be 2-4 sentences long\n5. Be objective and factual\n\nDo NOT include phrases like \"Based on the search results\" or \"The results show\". Just provide the summary directly.\"#;\n\n let user_prompt = format!(\n \"Query: {}\\n\\nSearch Results:\\n{}\\n\\nProvide a brief summary of the key information from these results.\",\n query, context\n );\n\n self.provider.complete_with_system(system_prompt, &user_prompt)\n }\n\n /// Summarize a webpage\n pub fn summarize_page(&self, title: &str, url: &str, content: &str) -> Result<String> {\n let truncated = if content.len() > 4000 {\n format!(\"{}...\", &content[..4000])\n } else {\n content.to_string()\n };\n\n let system_prompt = r#\"You are a helpful assistant that summarizes web pages. Provide a clear, concise summary that captures the main points.\nYour summary should:\n1. Be 3-5 sentences long\n2. Focus on the key information and main points\n3. Be objective and factual\n4. Not include any meta-commentary about the summarization process\"#;\n\n let user_prompt = format!(\n \"Title: {}\\nURL: {}\\n\\nContent:\\n{}\\n\\nSummarize this page.\",\n title, url, truncated\n );\n\n self.provider.complete_with_system(system_prompt, &user_prompt)\n }\n\n /// Extract key points from content\n pub fn extract_key_points(&self, content: &str, count: usize) -> Result<Vec<String>> {\n let count = if count == 0 { 5 } else { count };\n let truncated = if content.len() > 4000 {\n format!(\"{}...\", &content[..4000])\n } else {\n content.to_string()\n };\n\n let system_prompt = format!(\n r#\"Extract exactly {} key points from the given content.\nFormat your response as a numbered list, one point per line.\nEach point should be a single, clear sentence.\nExample format:\n1. First key point\n2. Second key point\n...\"#,\n count\n );\n\n let response = self.provider.complete_with_system(&system_prompt, &truncated)?;\n\n // Parse numbered list\n let points: Vec<String> = response\n .lines()\n .map(|line| line.trim())\n .filter(|line| !line.is_empty())\n .map(|line| {\n // Remove number prefix\n let mut result = line.to_string();\n for i in 0..10 {\n let prefix1 = format!(\"{}. \", i);\n let prefix2 = format!(\"{}) \", i);\n if result.starts_with(&prefix1) {\n result = result[prefix1.len()..].to_string();\n break;\n }\n if result.starts_with(&prefix2) {\n result = result[prefix2.len()..].to_string();\n break;\n }\n }\n result\n })\n .take(count)\n .collect();\n\n Ok(points)\n }\n\n /// Answer a question about content\n pub fn answer_question(&self, content: &str, question: &str) -> Result<String> {\n let truncated = if content.len() > 4000 {\n format!(\"{}...\", &content[..4000])\n } else {\n content.to_string()\n };\n\n let system_prompt = r#\"You are a helpful assistant that answers questions based on provided content.\nAnswer the question using only information from the given content.\nIf the answer cannot be found in the content, say so clearly.\nKeep your answer concise and direct.\"#;\n\n let user_prompt = format!(\"Content:\\n{}\\n\\nQuestion: {}\", truncated, question);\n\n self.provider.complete_with_system(system_prompt, &user_prompt)\n }\n\n /// Check if summarization is available\n pub fn is_available(&self) -> bool {\n self.provider.is_available()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_default_fallback_models() {\n let models = default_fallback_models();\n assert!(!models.is_empty());\n assert!(models.iter().all(|m| m.contains(\":free\")));\n }\n\n #[test]\n fn test_summarizer_from_env_without_key() {\n // Clear the env var for this test\n std::env::remove_var(\"OPENROUTER_API_KEY\");\n let summarizer = Summarizer::from_env();\n assert!(summarizer.is_none());\n }\n}\n"}}}}