diff --git a/bun.lockb b/bun.lockb new file mode 100644 index 000000000..34f5ab3ab Binary files /dev/null and b/bun.lockb differ diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a0fa7e897..d6b94efff 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,7 +1,8 @@ pub mod agents; pub mod claude; pub mod mcp; -pub mod usage; -pub mod storage; -pub mod slash_commands; +pub mod models; pub mod proxy; +pub mod slash_commands; +pub mod storage; +pub mod usage; diff --git a/src-tauri/src/commands/models.rs b/src-tauri/src/commands/models.rs new file mode 100644 index 000000000..fb30d1795 --- /dev/null +++ b/src-tauri/src/commands/models.rs @@ -0,0 +1,445 @@ +use crate::commands::storage::get_settings; +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomModel { + pub name: String, + pub identifier: String, + pub description: Option, +} + +impl CustomModel { + pub fn validate(&self) -> Result<(), String> { + if self.name.trim().is_empty() { + return Err("Model name cannot be empty".to_string()); + } + if self.identifier.trim().is_empty() { + return Err("Model identifier cannot be empty".to_string()); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelConfig { + pub custom_models: Vec, + pub env_model: Option, +} + +/// Helper function to load custom models from settings +async fn load_custom_models(app: &AppHandle) -> Result, String> { + let settings = get_settings(app).await?; + if let Some(models_value) = settings.get("custom_models") { + serde_json::from_value(models_value.clone()) + .map_err(|e| format!("Failed to parse models: {}", e)) + } else { + Ok(Vec::new()) + } +} + +#[tauri::command] +pub async fn get_available_models(app: AppHandle) -> Result { + let custom_models = load_custom_models(&app).await.unwrap_or_default(); + let env_model = get_env_var("ANTHROPIC_MODEL").await; + + Ok(ModelConfig { + custom_models, + env_model, + }) +} + +#[tauri::command] +pub async fn save_custom_models(app: AppHandle, models: Vec) -> Result<(), String> { + let mut settings = get_settings(&app) + .await + .map_err(|e| format!("Failed to get settings: {}", e))?; + + settings.insert( + "custom_models".to_string(), + serde_json::to_value(&models).map_err(|e| format!("Failed to serialize models: {}", e))?, + ); + + // Save settings + crate::commands::storage::save_settings(&app, settings) + .await + .map_err(|e| format!("Failed to save settings: {}", e))?; + + Ok(()) +} + +#[tauri::command] +pub async fn add_custom_model(app: AppHandle, model: CustomModel) -> Result<(), String> { + // Validate the model + model.validate()?; + + // Get current model list + let mut current_models = load_custom_models(&app).await?; + + // Check if model with same name already exists + if let Some(existing) = current_models.iter().find(|m| m.name == model.name) { + return Err(format!("Model '{}' already exists", existing.name)); + } + + // Add new model + current_models.push(model); + + // Save + save_custom_models(app, current_models).await +} + +#[tauri::command] +pub async fn remove_custom_model(app: AppHandle, model_name: String) -> Result<(), String> { + // Get current model list + let mut current_models = load_custom_models(&app).await?; + + // Remove specified model + current_models.retain(|m| m.name != model_name); + + // Save + save_custom_models(app, current_models).await +} + +#[derive(Debug, Deserialize)] +struct AnthropicModel { + id: String, + display_name: Option, + created: Option, +} + +#[derive(Debug, Deserialize)] +struct AnthropicModelsResponse { + data: Vec, +} + +/// Helper function to get environment variable from Claude settings or system env +async fn get_env_var(key: &str) -> Option { + // First try to get from Claude settings + if let Ok(claude_settings) = crate::commands::claude::get_claude_settings().await { + if let Some(env) = claude_settings.data.get("env").and_then(|v| v.as_object()) { + if let Some(value) = env.get(key).and_then(|v| v.as_str()) { + return Some(value.to_string()); + } + } + } + + // Fallback to system environment variable + std::env::var(key).ok() +} + +/// Fetch official models from Anthropic API +async fn fetch_official_models_from_api() -> Result, String> { + let api_key = get_env_var("ANTHROPIC_API_KEY").await + .ok_or("Anthropic API key not found. Please set ANTHROPIC_API_KEY in environment variables or Claude settings.")?; + + let client = reqwest::Client::new(); + let response = client + .get("https://api.anthropic.com/v1/models") + .header("x-api-key", &api_key) + .header("anthropic-version", "2023-06-01") + .header("User-Agent", "Claudia-App") + .send() + .await + .map_err(|e| format!("Failed to fetch models from Anthropic API: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); + return Err(format!("Anthropic API request failed with status {}: {}", status, error_text)); + } + + let api_response: AnthropicModelsResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse Anthropic API response: {}", e))?; + + let mut models: Vec = api_response.data + .into_iter() + .map(|model| { + let description = model.created + .map(|created| format!("Official Anthropic model (created: {})", created)) + .unwrap_or_else(|| "Official Anthropic model".to_string()); + + CustomModel { + name: model.display_name.unwrap_or(model.id.clone()), + identifier: model.id, + description: Some(description), + } + }) + .collect(); + + // Sort models by identifier for consistent ordering + models.sort_by(|a, b| a.identifier.cmp(&b.identifier)); + + Ok(models) +} + +#[tauri::command] +pub async fn get_official_models() -> Result, String> { + // Try to fetch from Anthropic API + match fetch_official_models_from_api().await { + Ok(models) => { + log::info!("Successfully fetched {} models from Anthropic API", models.len()); + Ok(models) + } + Err(e) => { + log::warn!("Failed to fetch from Anthropic API: {}", e); + // Return empty list if API call fails + Ok(Vec::new()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use tempfile::TempDir; + + /// Helper function to create a mock app handle for testing + /// This creates a temporary database for testing purposes + async fn create_test_settings() -> (tempfile::TempDir, rusqlite::Connection) { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let conn = rusqlite::Connection::open(&db_path).unwrap(); + + // Create the settings table + conn.execute( + "CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )", + [], + ).unwrap(); + + (temp_dir, conn) + } + + #[test] + fn test_custom_model_validation() { + // Valid model + let valid_model = CustomModel { + name: "Test Model".to_string(), + identifier: "test-model-v1".to_string(), + description: Some("A test model".to_string()), + }; + assert!(valid_model.validate().is_ok()); + + // Empty name should fail + let invalid_name = CustomModel { + name: "".to_string(), + identifier: "test-model-v1".to_string(), + description: None, + }; + assert!(invalid_name.validate().is_err()); + assert!(invalid_name.validate().unwrap_err().contains("name cannot be empty")); + + // Empty identifier should fail + let invalid_identifier = CustomModel { + name: "Test Model".to_string(), + identifier: "".to_string(), + description: None, + }; + assert!(invalid_identifier.validate().is_err()); + assert!(invalid_identifier.validate().unwrap_err().contains("identifier cannot be empty")); + + // Whitespace-only name should fail + let whitespace_name = CustomModel { + name: " ".to_string(), + identifier: "test-model-v1".to_string(), + description: None, + }; + assert!(whitespace_name.validate().is_err()); + } + + #[tokio::test] + async fn test_model_serialization_deserialization() { + let models = vec![ + CustomModel { + name: "Model 1".to_string(), + identifier: "model-1".to_string(), + description: Some("First test model".to_string()), + }, + CustomModel { + name: "Model 2".to_string(), + identifier: "model-2".to_string(), + description: None, + }, + ]; + + // Test JSON serialization + let json_value = serde_json::to_value(&models).unwrap(); + let deserialized: Vec = serde_json::from_value(json_value).unwrap(); + + assert_eq!(deserialized.len(), 2); + assert_eq!(deserialized[0].name, "Model 1"); + assert_eq!(deserialized[1].identifier, "model-2"); + assert_eq!(deserialized[0].description, Some("First test model".to_string())); + assert_eq!(deserialized[1].description, None); + } + + #[test] + fn test_anthropic_model_deserialization() { + // Test deserializing a typical Anthropic API response structure + let json_data = json!({ + "id": "claude-3-5-sonnet-20241022", + "display_name": "Claude 3.5 Sonnet", + "created": "2024-10-22" + }); + + let model: AnthropicModel = serde_json::from_value(json_data).unwrap(); + assert_eq!(model.id, "claude-3-5-sonnet-20241022"); + assert_eq!(model.display_name, Some("Claude 3.5 Sonnet".to_string())); + assert_eq!(model.created, Some("2024-10-22".to_string())); + + // Test with minimal data + let minimal_json = json!({ + "id": "claude-3-haiku-20241022" + }); + + let minimal_model: AnthropicModel = serde_json::from_value(minimal_json).unwrap(); + assert_eq!(minimal_model.id, "claude-3-haiku-20241022"); + assert_eq!(minimal_model.display_name, None); + assert_eq!(minimal_model.created, None); + } + + #[test] + fn test_model_config_serialization() { + let config = ModelConfig { + custom_models: vec![ + CustomModel { + name: "Test Model".to_string(), + identifier: "test-model".to_string(), + description: Some("Test description".to_string()), + } + ], + env_model: Some("claude-3-5-sonnet-20241022".to_string()), + }; + + // Test serialization and deserialization + let json_str = serde_json::to_string(&config).unwrap(); + let deserialized: ModelConfig = serde_json::from_str(&json_str).unwrap(); + + assert_eq!(deserialized.custom_models.len(), 1); + assert_eq!(deserialized.custom_models[0].name, "Test Model"); + assert_eq!(deserialized.env_model, Some("claude-3-5-sonnet-20241022".to_string())); + } + + #[test] + fn test_custom_model_with_optional_description() { + let model_with_desc = CustomModel { + name: "Model With Desc".to_string(), + identifier: "model-with-desc".to_string(), + description: Some("Has description".to_string()), + }; + + let model_without_desc = CustomModel { + name: "Model Without Desc".to_string(), + identifier: "model-without-desc".to_string(), + description: None, + }; + + // Both should be valid + assert!(model_with_desc.validate().is_ok()); + assert!(model_without_desc.validate().is_ok()); + + // Test serialization + let json_with = serde_json::to_string(&model_with_desc).unwrap(); + let json_without = serde_json::to_string(&model_without_desc).unwrap(); + + assert!(json_with.contains("Has description")); + assert!(!json_without.contains("Has description")); + } + + #[test] + fn test_anthropic_models_response_deserialization() { + let response_json = json!({ + "data": [ + { + "id": "claude-3-5-sonnet-20241022", + "display_name": "Claude 3.5 Sonnet", + "created": "2024-10-22" + }, + { + "id": "claude-3-haiku-20241022", + "display_name": "Claude 3 Haiku" + } + ] + }); + + let response: AnthropicModelsResponse = serde_json::from_value(response_json).unwrap(); + assert_eq!(response.data.len(), 2); + assert_eq!(response.data[0].id, "claude-3-5-sonnet-20241022"); + assert_eq!(response.data[1].id, "claude-3-haiku-20241022"); + assert_eq!(response.data[0].display_name, Some("Claude 3.5 Sonnet".to_string())); + assert_eq!(response.data[1].created, None); + } + + #[test] + fn test_model_validation_edge_cases() { + // Test with unicode characters + let unicode_model = CustomModel { + name: "模型测试".to_string(), + identifier: "model-测试".to_string(), + description: Some("测试描述".to_string()), + }; + assert!(unicode_model.validate().is_ok()); + + // Test with very long strings + let long_name = "a".repeat(1000); + let long_model = CustomModel { + name: long_name.clone(), + identifier: "long-model".to_string(), + description: Some(long_name), + }; + assert!(long_model.validate().is_ok()); + + // Test with only whitespace + let whitespace_identifier = CustomModel { + name: "Valid Name".to_string(), + identifier: " \t\n ".to_string(), + description: None, + }; + assert!(whitespace_identifier.validate().is_err()); + } + + #[tokio::test] + async fn test_database_operations() { + let (_temp_dir, conn) = create_test_settings().await; + + // Test inserting custom models data + let models = vec![ + CustomModel { + name: "Test Model 1".to_string(), + identifier: "test-1".to_string(), + description: Some("First model".to_string()), + }, + CustomModel { + name: "Test Model 2".to_string(), + identifier: "test-2".to_string(), + description: None, + }, + ]; + + let models_json = serde_json::to_value(&models).unwrap(); + + // Insert models into the database + conn.execute( + "INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)", + rusqlite::params!["custom_models", models_json.to_string()], + ).unwrap(); + + // Read models back from the database + let stored_json: String = conn.query_row( + "SELECT value FROM app_settings WHERE key = ?", + rusqlite::params!["custom_models"], + |row| row.get(0), + ).unwrap(); + + let stored_models: Vec = serde_json::from_str(&stored_json).unwrap(); + assert_eq!(stored_models.len(), 2); + assert_eq!(stored_models[0].name, "Test Model 1"); + assert_eq!(stored_models[1].description, None); + } +} diff --git a/src-tauri/src/commands/storage.rs b/src-tauri/src/commands/storage.rs index 1bcdb1b5e..98c23453c 100644 --- a/src-tauri/src/commands/storage.rs +++ b/src-tauri/src/commands/storage.rs @@ -480,6 +480,51 @@ pub async fn storage_reset_database(app: AppHandle) -> Result<(), String> { Ok(()) } +/// Get application settings +pub async fn get_settings(app: &AppHandle) -> Result, String> { + let data_dir = app.path().app_data_dir() + .map_err(|e| format!("Failed to get app data directory: {}", e))?; + + std::fs::create_dir_all(&data_dir) + .map_err(|e| format!("Failed to create app data directory: {}", e))?; + + let settings_path = data_dir.join("settings.json"); + + if !settings_path.exists() { + return Ok(std::collections::HashMap::new()); + } + + let content = std::fs::read_to_string(&settings_path) + .map_err(|e| format!("Failed to read settings file: {}", e))?; + + let settings: std::collections::HashMap = serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse settings JSON: {}", e))?; + + Ok(settings) +} + +/// Save application settings +pub async fn save_settings( + app: &AppHandle, + settings: std::collections::HashMap +) -> Result<(), String> { + let data_dir = app.path().app_data_dir() + .map_err(|e| format!("Failed to get app data directory: {}", e))?; + + std::fs::create_dir_all(&data_dir) + .map_err(|e| format!("Failed to create app data directory: {}", e))?; + + let settings_path = data_dir.join("settings.json"); + + let content = serde_json::to_string_pretty(&settings) + .map_err(|e| format!("Failed to serialize settings: {}", e))?; + + std::fs::write(&settings_path, content) + .map_err(|e| format!("Failed to write settings file: {}", e))?; + + Ok(()) +} + /// Helper function to validate table name exists fn is_valid_table_name(conn: &Connection, table_name: &str) -> Result { let count: i64 = conn diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ffc0212e3..51985bf0d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -42,6 +42,9 @@ use commands::storage::{ storage_list_tables, storage_read_table, storage_update_row, storage_delete_row, storage_insert_row, storage_execute_sql, storage_reset_database, }; +use commands::models::{ + get_available_models, save_custom_models, add_custom_model, remove_custom_model, get_official_models, +}; use commands::proxy::{get_proxy_settings, save_proxy_settings, apply_proxy_settings}; use process::ProcessRegistryState; use std::sync::Mutex; @@ -281,6 +284,13 @@ fn main() { commands::slash_commands::slash_command_save, commands::slash_commands::slash_command_delete, + // Model Management + get_available_models, + save_custom_models, + add_custom_model, + remove_custom_model, + get_official_models, + // Proxy Settings get_proxy_settings, save_proxy_settings, diff --git a/src/components/AgentExecution.tsx b/src/components/AgentExecution.tsx index 41fbb1fd7..3ac1da6a3 100644 --- a/src/components/AgentExecution.tsx +++ b/src/components/AgentExecution.tsx @@ -11,7 +11,10 @@ import { ChevronDown, Maximize2, X, - Settings2 + Settings2, + Zap, + Sparkles, + Brain } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -75,6 +78,53 @@ export interface ClaudeStreamMessage { [key: string]: any; } +/** + * Model type definition + */ +interface Model { + id: string; + name: string; + description: string; + icon: React.ReactNode; + group: "default" | "custom" | "env"; + shortName: string; + color: string; +} + +const DEFAULT_MODELS: Model[] = [ + { + id: "sonnet", + name: "Claude 4 Sonnet", + description: "Faster, efficient", + icon: , + group: "default", + shortName: "S", + color: "text-primary" + }, + { + id: "opus", + name: "Claude 4 Opus", + description: "More capable", + icon: , + group: "default", + shortName: "O", + color: "text-primary" + } +]; + +/** + * Get display name for a model + */ +const getModelDisplayName = (modelId: string, availableModels: Model[]): string => { + const model = availableModels.find(m => m.id === modelId); + if (model) { + return model.name; + } + + // Fallback for unknown models + return modelId.charAt(0).toUpperCase() + modelId.slice(1); +}; + /** * AgentExecution component for running CC agents * @@ -91,6 +141,7 @@ export const AgentExecution: React.FC = ({ const [projectPath] = useState(initialProjectPath || ""); const [task, setTask] = useState(agent.default_task || ""); const [model, setModel] = useState(agent.model || "sonnet"); + const [availableModels, setAvailableModels] = useState(DEFAULT_MODELS); const [isRunning, setIsRunning] = useState(false); // Get tab state functions @@ -125,6 +176,49 @@ export const AgentExecution: React.FC = ({ const elapsedTimeIntervalRef = useRef(null); const [runId, setRunId] = useState(null); + // Load available models on component mount + useEffect(() => { + const loadModels = async () => { + try { + const modelConfig = await api.getAvailableModels(); + const models: Model[] = [...DEFAULT_MODELS]; + + // Add custom models + modelConfig.custom_models.forEach(customModel => { + models.push({ + id: customModel.identifier, + name: customModel.name, + description: customModel.description || "Custom model", + icon: , + group: "custom", + shortName: customModel.name.charAt(0).toUpperCase(), + color: "text-primary" + }); + }); + + // Add environment model if available + if (modelConfig.env_model) { + models.push({ + id: modelConfig.env_model, + name: `${modelConfig.env_model}`, + description: "Model from ANTHROPIC_MODEL environment variable", + icon: , + group: "env", + shortName: "E", + color: "text-primary" + }); + } + + setAvailableModels(models); + } catch (error) { + console.error("Failed to load models:", error); + // Keep default models on error + } + }; + + loadModels(); + }, []); + // Filter out messages that shouldn't be displayed const displayableMessages = React.useMemo(() => { return messages.filter((message, index) => { @@ -497,7 +591,7 @@ export const AgentExecution: React.FC = ({ const handleCopyAsMarkdown = async () => { let markdown = `# Agent Execution: ${agent.name}\n\n`; markdown += `**Task:** ${task}\n`; - markdown += `**Model:** ${model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}\n`; + markdown += `**Model:** ${getModelDisplayName(model, availableModels)}\n`; markdown += `**Date:** ${new Date().toISOString()}\n\n`; markdown += `---\n\n`; @@ -581,7 +675,7 @@ export const AgentExecution: React.FC = ({

{agent.name}

- {isRunning ? 'Running' : messages.length > 0 ? 'Complete' : 'Ready'} • {model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'} + {isRunning ? 'Running' : messages.length > 0 ? 'Complete' : 'Ready'} • {getModelDisplayName(model, availableModels)}

@@ -620,66 +714,122 @@ export const AgentExecution: React.FC = ({ {/* Model Selection */}
-
- !isRunning && setModel("sonnet")} - whileTap={{ scale: 0.97 }} - transition={{ duration: 0.15 }} - className={cn( - "flex-1 px-4 py-3 rounded-md border transition-all", - model === "sonnet" - ? "border-primary bg-primary/10 text-primary" - : "border-border hover:border-primary/50 hover:bg-accent", - isRunning && "opacity-50 cursor-not-allowed" - )} - disabled={isRunning} - > -
-
- {model === "sonnet" && ( -
- )} -
-
-
Claude 4 Sonnet
-
Faster, efficient
-
+
+ {/* Default Models */} + {availableModels.filter(m => m.group === "default").length > 0 && ( +
+ {availableModels.filter(m => m.group === "default").map((availableModel) => ( + !isRunning && setModel(availableModel.id)} + whileTap={{ scale: 0.97 }} + transition={{ duration: 0.15 }} + className={cn( + "px-4 py-3 rounded-md border transition-all text-left", + model === availableModel.id + ? "border-primary bg-primary/10 text-primary" + : "border-border hover:border-primary/50 hover:bg-accent", + isRunning && "opacity-50 cursor-not-allowed" + )} + disabled={isRunning} + > +
+
+ {availableModel.icon} +
+
+
{availableModel.name}
+
{availableModel.description}
+
+
+
+ ))}
- - - !isRunning && setModel("opus")} - whileTap={{ scale: 0.97 }} - transition={{ duration: 0.15 }} - className={cn( - "flex-1 px-4 py-3 rounded-md border transition-all", - model === "opus" - ? "border-primary bg-primary/10 text-primary" - : "border-border hover:border-primary/50 hover:bg-accent", - isRunning && "opacity-50 cursor-not-allowed" - )} - disabled={isRunning} - > -
-
- {model === "opus" && ( -
- )} + )} + + {/* Custom Models */} + {availableModels.filter(m => m.group === "custom").length > 0 && ( +
+
Custom Models
+
+ {availableModels.filter(m => m.group === "custom").map((availableModel) => ( + !isRunning && setModel(availableModel.id)} + whileTap={{ scale: 0.97 }} + transition={{ duration: 0.15 }} + className={cn( + "px-4 py-3 rounded-md border transition-all text-left", + model === availableModel.id + ? "border-primary bg-primary/10 text-primary" + : "border-border hover:border-primary/50 hover:bg-accent", + isRunning && "opacity-50 cursor-not-allowed" + )} + disabled={isRunning} + > +
+
+ {availableModel.icon} +
+
+
{availableModel.name}
+
{availableModel.description}
+
Custom
+
+
+
+ ))}
-
-
Claude 4 Opus
-
More capable
+
+ )} + + {/* Environment Models */} + {availableModels.filter(m => m.group === "env").length > 0 && ( +
+
Environment Model
+
+ {availableModels.filter(m => m.group === "env").map((availableModel) => ( + !isRunning && setModel(availableModel.id)} + whileTap={{ scale: 0.97 }} + transition={{ duration: 0.15 }} + className={cn( + "px-4 py-3 rounded-md border transition-all text-left", + model === availableModel.id + ? "border-primary bg-primary/10 text-primary" + : "border-border hover:border-primary/50 hover:bg-accent", + isRunning && "opacity-50 cursor-not-allowed" + )} + disabled={isRunning} + > +
+
+ {availableModel.icon} +
+
+
{availableModel.name}
+
{availableModel.description}
+
Environment
+
+
+
+ ))}
- + )}
diff --git a/src/components/AgentRunOutputViewer.tsx b/src/components/AgentRunOutputViewer.tsx index d83fb9317..a52d150ec 100644 --- a/src/components/AgentRunOutputViewer.tsx +++ b/src/components/AgentRunOutputViewer.tsx @@ -28,6 +28,21 @@ import { AGENT_ICONS } from './CCAgents'; import type { ClaudeStreamMessage } from './AgentExecution'; import { useTabState } from '@/hooks/useTabState'; +/** + * Get display name for a model + */ +const getModelDisplayName = (modelId: string): string => { + switch (modelId) { + case 'sonnet': + return 'Claude 4 Sonnet'; + case 'opus': + return 'Claude 4 Opus'; + default: + // For custom models, return the model ID as-is, but capitalize first letter + return modelId.charAt(0).toUpperCase() + modelId.slice(1); + } +}; + interface AgentRunOutputViewerProps { /** * The agent run ID to display @@ -330,7 +345,7 @@ export function AgentRunOutputViewer({ if (!run) return; let markdown = `# Agent Execution: ${run.agent_name}\n\n`; markdown += `**Task:** ${run.task}\n`; - markdown += `**Model:** ${run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}\n`; + markdown += `**Model:** ${getModelDisplayName(run.model)}\n`; markdown += `**Date:** ${formatISOTimestamp(run.created_at)}\n`; if (run.metrics?.duration_ms) markdown += `**Duration:** ${(run.metrics.duration_ms / 1000).toFixed(2)}s\n`; if (run.metrics?.total_tokens) markdown += `**Total Tokens:** ${run.metrics.total_tokens}\n`; @@ -566,7 +581,7 @@ export function AgentRunOutputViewer({

- {run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'} + {getModelDisplayName(run.model)}
diff --git a/src/components/AgentRunView.tsx b/src/components/AgentRunView.tsx index 16ade6c51..69b1722f0 100644 --- a/src/components/AgentRunView.tsx +++ b/src/components/AgentRunView.tsx @@ -22,6 +22,21 @@ import { AGENT_ICONS } from "./CCAgents"; import type { ClaudeStreamMessage } from "./AgentExecution"; import { ErrorBoundary } from "./ErrorBoundary"; +/** + * Get display name for a model + */ +const getModelDisplayName = (modelId: string): string => { + switch (modelId) { + case 'sonnet': + return 'Claude 4 Sonnet'; + case 'opus': + return 'Claude 4 Opus'; + default: + // For custom models, return the model ID as-is, but capitalize first letter + return modelId.charAt(0).toUpperCase() + modelId.slice(1); + } +}; + interface AgentRunViewProps { /** * The run ID to view @@ -327,7 +342,7 @@ export const AgentRunView: React.FC = ({

Task:

{run.task}

- {run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'} + {getModelDisplayName(run.model)}
diff --git a/src/components/AgentsModal.tsx b/src/components/AgentsModal.tsx index f5eb4063c..fccdbb9fb 100644 --- a/src/components/AgentsModal.tsx +++ b/src/components/AgentsModal.tsx @@ -26,6 +26,21 @@ import { open as openDialog, save } from '@tauri-apps/plugin-dialog'; import { invoke } from '@tauri-apps/api/core'; import { GitHubAgentBrowser } from '@/components/GitHubAgentBrowser'; +/** + * Get display name for a model + */ +const getModelDisplayName = (modelId: string): string => { + switch (modelId) { + case 'sonnet': + return 'Claude 4 Sonnet'; + case 'opus': + return 'Claude 4 Opus'; + default: + // For custom models, return the model ID as-is, but capitalize first letter + return modelId.charAt(0).toUpperCase() + modelId.slice(1); + } +}; + interface AgentsModalProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -378,7 +393,7 @@ export const AgentsModal: React.FC = ({ open, onOpenChange })
Started: {formatISOTimestamp(run.created_at)} - {run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'} + {getModelDisplayName(run.model)}
diff --git a/src/components/ClaudeCodeSession.refactored.tsx b/src/components/ClaudeCodeSession.refactored.tsx index ae70af687..214812fa4 100644 --- a/src/components/ClaudeCodeSession.refactored.tsx +++ b/src/components/ClaudeCodeSession.refactored.tsx @@ -51,7 +51,7 @@ export const ClaudeCodeSession: React.FC = ({ const [showSlashCommandsSettings, setShowSlashCommandsSettings] = useState(false); const [forkCheckpointId, setForkCheckpointId] = useState(null); const [forkSessionName, setForkSessionName] = useState(""); - const [queuedPrompts, setQueuedPrompts] = useState>([]); + const [queuedPrompts, setQueuedPrompts] = useState>([]); const [showPreview, setShowPreview] = useState(false); const [previewUrl, setPreviewUrl] = useState(null); const [isPreviewMaximized, setIsPreviewMaximized] = useState(false); @@ -106,7 +106,7 @@ export const ClaudeCodeSession: React.FC = ({ }; // Handle sending prompts - const handleSendPrompt = useCallback(async (prompt: string, model: "sonnet" | "opus") => { + const handleSendPrompt = useCallback(async (prompt: string, model: string) => { if (!projectPath || !prompt.trim()) return; // Add to queue if streaming diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index d4563e242..508033122 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -94,7 +94,7 @@ export const ClaudeCodeSession: React.FC = ({ const [forkSessionName, setForkSessionName] = useState(""); // Queued prompts state - const [queuedPrompts, setQueuedPrompts] = useState>([]); + const [queuedPrompts, setQueuedPrompts] = useState>([]); // New state for preview feature const [showPreview, setShowPreview] = useState(false); @@ -110,7 +110,7 @@ export const ClaudeCodeSession: React.FC = ({ const unlistenRefs = useRef([]); const hasActiveSessionRef = useRef(false); const floatingPromptRef = useRef(null); - const queuedPromptsRef = useRef>([]); + const queuedPromptsRef = useRef>([]); const isMountedRef = useRef(true); const isListeningRef = useRef(false); const sessionStartTime = useRef(Date.now()); @@ -430,7 +430,7 @@ export const ClaudeCodeSession: React.FC = ({ // Project path selection handled by parent tab controls - const handleSendPrompt = async (prompt: string, model: "sonnet" | "opus") => { + const handleSendPrompt = async (prompt: string, model: string) => { console.log('[ClaudeCodeSession] handleSendPrompt called with:', { prompt, model, projectPath, claudeSessionId, effectiveSession }); if (!projectPath) { diff --git a/src/components/CreateAgent.tsx b/src/components/CreateAgent.tsx index 96861e8e0..f385d8aae 100644 --- a/src/components/CreateAgent.tsx +++ b/src/components/CreateAgent.tsx @@ -1,6 +1,6 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { motion } from "framer-motion"; -import { ArrowLeft, Save, Loader2, ChevronDown, Zap, AlertCircle } from "lucide-react"; +import { ArrowLeft, Save, Loader2, ChevronDown, Zap, AlertCircle, Brain, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -12,6 +12,39 @@ import MDEditor from "@uiw/react-md-editor"; import { type AgentIconName } from "./CCAgents"; import { IconPicker, ICON_MAP } from "./IconPicker"; +/** + * Model type definition + */ +interface Model { + id: string; + name: string; + description: string; + icon: React.ReactNode; + group: "default" | "custom" | "env"; + shortName: string; + color: string; +} + +const DEFAULT_MODELS: Model[] = [ + { + id: "sonnet", + name: "Claude 4 Sonnet", + description: "Faster, efficient for most tasks", + icon: , + group: "default", + shortName: "S", + color: "text-primary" + }, + { + id: "opus", + name: "Claude 4 Opus", + description: "More capable, better for complex tasks", + icon: , + group: "default", + shortName: "O", + color: "text-primary" + } +]; interface CreateAgentProps { /** @@ -49,6 +82,7 @@ export const CreateAgent: React.FC = ({ const [systemPrompt, setSystemPrompt] = useState(agent?.system_prompt || ""); const [defaultTask, setDefaultTask] = useState(agent?.default_task || ""); const [model, setModel] = useState(agent?.model || "sonnet"); + const [availableModels, setAvailableModels] = useState(DEFAULT_MODELS); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); @@ -56,6 +90,49 @@ export const CreateAgent: React.FC = ({ const isEditMode = !!agent; + // Load available models on component mount + useEffect(() => { + const loadModels = async () => { + try { + const modelConfig = await api.getAvailableModels(); + const models: Model[] = [...DEFAULT_MODELS]; + + // Add custom models + modelConfig.custom_models.forEach(customModel => { + models.push({ + id: customModel.identifier, + name: customModel.name, + description: customModel.description || "Custom model", + icon: , + group: "custom", + shortName: customModel.name.charAt(0).toUpperCase(), + color: "text-primary" + }); + }); + + // Add environment model if available + if (modelConfig.env_model) { + models.push({ + id: modelConfig.env_model, + name: `${modelConfig.env_model}`, + description: "Model from ANTHROPIC_MODEL environment variable", + icon: , + group: "env", + shortName: "E", + color: "text-primary" + }); + } + + setAvailableModels(models); + } catch (error) { + console.error("Failed to load models:", error); + // Keep default models on error + } + }; + + loadModels(); + }, []); + const handleSave = async () => { if (!name.trim()) { setError("Agent name is required"); @@ -238,54 +315,116 @@ export const CreateAgent: React.FC = ({ {/* Model Selection */}
-
- setModel("sonnet")} - whileTap={{ scale: 0.97 }} - transition={{ duration: 0.15 }} - className={cn( - "flex-1 px-4 py-3 rounded-md border transition-all", - model === "sonnet" - ? "border-primary bg-primary/10 text-primary" - : "border-border hover:border-primary/50 hover:bg-accent" - )} - > -
- -
-
Claude 4 Sonnet
-
Faster, efficient for most tasks
+
+ {/* Default Models */} + {availableModels.filter(m => m.group === "default").length > 0 && ( +
+ {availableModels.filter(m => m.group === "default").map((availableModel) => ( + setModel(availableModel.id)} + whileTap={{ scale: 0.97 }} + transition={{ duration: 0.15 }} + className={cn( + "px-4 py-3 rounded-md border transition-all text-left", + model === availableModel.id + ? "border-primary bg-primary/10 text-primary" + : "border-border hover:border-primary/50 hover:bg-accent" + )} + > +
+
+ {availableModel.icon} +
+
+
{availableModel.name}
+
{availableModel.description}
+
+
+
+ ))} +
+ )} + + {/* Custom Models */} + {availableModels.filter(m => m.group === "custom").length > 0 && ( +
+
Custom Models
+
+ {availableModels.filter(m => m.group === "custom").map((availableModel) => ( + setModel(availableModel.id)} + whileTap={{ scale: 0.97 }} + transition={{ duration: 0.15 }} + className={cn( + "px-4 py-3 rounded-md border transition-all text-left", + model === availableModel.id + ? "border-primary bg-primary/10 text-primary" + : "border-border hover:border-primary/50 hover:bg-accent" + )} + > +
+
+ {availableModel.icon} +
+
+
{availableModel.name}
+
{availableModel.description}
+
Custom
+
+
+
+ ))}
- - - setModel("opus")} - whileTap={{ scale: 0.97 }} - transition={{ duration: 0.15 }} - className={cn( - "flex-1 px-4 py-3 rounded-md border transition-all", - model === "opus" - ? "border-primary bg-primary/10 text-primary" - : "border-border hover:border-primary/50 hover:bg-accent" - )} - > -
- -
-
Claude 4 Opus
-
More capable, better for complex tasks
+ )} + + {/* Environment Models */} + {availableModels.filter(m => m.group === "env").length > 0 && ( +
+
Environment Model
+
+ {availableModels.filter(m => m.group === "env").map((availableModel) => ( + setModel(availableModel.id)} + whileTap={{ scale: 0.97 }} + transition={{ duration: 0.15 }} + className={cn( + "px-4 py-3 rounded-md border transition-all text-left", + model === availableModel.id + ? "border-primary bg-primary/10 text-primary" + : "border-border hover:border-primary/50 hover:bg-accent" + )} + > +
+
+ {availableModel.icon} +
+
+
{availableModel.name}
+
{availableModel.description}
+
Environment
+
+
+
+ ))}
- + )}
diff --git a/src/components/FloatingPromptInput.tsx b/src/components/FloatingPromptInput.tsx index c3f5ea28c..dfdd620a1 100644 --- a/src/components/FloatingPromptInput.tsx +++ b/src/components/FloatingPromptInput.tsx @@ -22,14 +22,14 @@ import { TooltipProvider, TooltipSimple, Tooltip, TooltipTrigger, TooltipContent import { FilePicker } from "./FilePicker"; import { SlashCommandPicker } from "./SlashCommandPicker"; import { ImagePreview } from "./ImagePreview"; -import { type FileEntry, type SlashCommand } from "@/lib/api"; +import { type FileEntry, type SlashCommand, api } from "@/lib/api"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; interface FloatingPromptInputProps { /** * Callback when prompt is sent */ - onSend: (prompt: string, model: "sonnet" | "opus") => void; + onSend: (prompt: string, model: string) => void; /** * Whether the input is loading */ @@ -41,7 +41,7 @@ interface FloatingPromptInputProps { /** * Default model to select */ - defaultModel?: "sonnet" | "opus"; + defaultModel?: string; /** * Project path for file picker */ @@ -161,28 +161,31 @@ const ThinkingModeIndicator: React.FC<{ level: number; color?: string }> = ({ le }; type Model = { - id: "sonnet" | "opus"; + id: string; name: string; description: string; icon: React.ReactNode; + group: "default" | "custom" | "env"; shortName: string; color: string; }; -const MODELS: Model[] = [ +const DEFAULT_MODELS: Model[] = [ { id: "sonnet", name: "Claude 4 Sonnet", description: "Faster, efficient for most tasks", icon: , + group: "default", shortName: "S", color: "text-primary" }, { - id: "opus", + id: "opus", name: "Claude 4 Opus", description: "More capable, better for complex tasks", - icon: , + icon: , + group: "default", shortName: "O", color: "text-primary" } @@ -213,12 +216,13 @@ const FloatingPromptInputInner = ( ref: React.Ref, ) => { const [prompt, setPrompt] = useState(""); - const [selectedModel, setSelectedModel] = useState<"sonnet" | "opus">(defaultModel); + const [selectedModel, setSelectedModel] = useState(defaultModel || "sonnet"); const [selectedThinkingMode, setSelectedThinkingMode] = useState("auto"); const [isExpanded, setIsExpanded] = useState(false); const [modelPickerOpen, setModelPickerOpen] = useState(false); const [thinkingModePickerOpen, setThinkingModePickerOpen] = useState(false); const [showFilePicker, setShowFilePicker] = useState(false); + const [availableModels, setAvailableModels] = useState(DEFAULT_MODELS); const [filePickerQuery, setFilePickerQuery] = useState(""); const [showSlashCommandPicker, setShowSlashCommandPicker] = useState(false); const [slashCommandQuery, setSlashCommandQuery] = useState(""); @@ -345,6 +349,49 @@ const FloatingPromptInputInner = ( } }, [prompt, projectPath, isExpanded]); + // Load available models on component mount + useEffect(() => { + const loadModels = async () => { + try { + const modelConfig = await api.getAvailableModels(); + const models: Model[] = [...DEFAULT_MODELS]; + + // Add custom models + modelConfig.custom_models.forEach(customModel => { + models.push({ + id: customModel.identifier, + name: customModel.name, + description: customModel.description || "Custom model", + icon: , + group: "custom", + shortName: customModel.name.charAt(0).toUpperCase(), + color: "text-primary" + }); + }); + + // Add environment model if available + if (modelConfig.env_model) { + models.push({ + id: modelConfig.env_model, + name: `${modelConfig.env_model}`, + description: "Model from ANTHROPIC_MODEL environment variable", + icon: , + group: "env", + shortName: "E", + color: "text-primary" + }); + } + + setAvailableModels(models); + } catch (error) { + console.error("Failed to load models:", error); + // Keep default models on error + } + }; + + loadModels(); + }, []); + // Set up Tauri drag-drop event listener useEffect(() => { // This effect runs only once on component mount to set up the listener. @@ -779,7 +826,7 @@ const FloatingPromptInputInner = ( setPrompt(newPrompt.trim()); }; - const selectedModelData = MODELS.find(m => m.id === selectedModel) || MODELS[0]; + const selectedModelData = availableModels.find(m => m.id === selectedModel) || availableModels[0]; return ( @@ -864,32 +911,114 @@ const FloatingPromptInputInner = ( } content={
- {MODELS.map((model) => ( - + ))} +
+ + )} + + {/* Environment Model Group */} + {availableModels.filter(m => m.group === "env").length > 0 && ( + <> +
+ Environment
- - ))} + {availableModels + .filter(m => m.group === "env") + .map((model) => ( + + ))} +
+ + )} + + {/* Default Models Group */} +
+ Default Models +
+ {availableModels + .filter(m => m.group === "default") + .map((model) => ( + + ))}
} open={modelPickerOpen} @@ -1047,32 +1176,114 @@ const FloatingPromptInputInner = ( } content={
- {MODELS.map((model) => ( - + ))} +
+ + )} + + {/* Environment Model Group */} + {availableModels.filter(m => m.group === "env").length > 0 && ( + <> +
+ Environment
- - ))} + {availableModels + .filter(m => m.group === "env") + .map((model) => ( + + ))} +
+ + )} + + {/* Default Models Group */} +
+ Default Models +
+ {availableModels + .filter(m => m.group === "default") + .map((model) => ( + + ))}
} open={modelPickerOpen} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 8d377f161..1971fd31f 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -6,6 +6,10 @@ import { Save, AlertCircle, Loader2, + RefreshCw, + ChevronDown, + ChevronUp, + BarChart3, Shield, Check, } from "lucide-react"; @@ -18,7 +22,9 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { api, type ClaudeSettings, - type ClaudeInstallation + type ClaudeInstallation, + type CustomModel, + type ModelConfig } from "@/lib/api"; import { cn } from "@/lib/utils"; import { Toast, ToastContainer } from "@/components/ui/toast"; @@ -78,6 +84,16 @@ export const Settings: React.FC = ({ // Environment variables state const [envVars, setEnvVars] = useState([]); + // Custom models state + const [customModels, setCustomModels] = useState([]); + const [modelConfig, setModelConfig] = useState(null); + const [modelsChanged, setModelsChanged] = useState(false); + + // Official models state + const [officialModels, setOfficialModels] = useState([]); + const [loadingOfficialModels, setLoadingOfficialModels] = useState(false); + const [officialModelsExpanded, setOfficialModelsExpanded] = useState(false); + // Hooks state const [userHooksChanged, setUserHooksChanged] = useState(false); const getUserHooks = React.useRef<(() => any) | null>(null); @@ -185,6 +201,17 @@ export const Settings: React.FC = ({ })) ); } + + // Load model configuration + try { + const modelConfig = await api.getAvailableModels(); + setModelConfig(modelConfig); + setCustomModels(modelConfig.custom_models); + } catch (err) { + console.error("Failed to load model configuration:", err); + setModelConfig({ custom_models: [], env_model: undefined }); + setCustomModels([]); + } } catch (err) { console.error("Failed to load settings:", err); setError("Failed to load settings. Please ensure ~/.claude directory exists."); @@ -194,6 +221,26 @@ export const Settings: React.FC = ({ } }; + /** + * Loads official Anthropic models + */ + const loadOfficialModels = async () => { + try { + setLoadingOfficialModels(true); + const models = await api.getOfficialModels(); + setOfficialModels(models); + // Auto-expand when models are loaded for the first time + if (models.length > 0 && !officialModelsExpanded) { + setOfficialModelsExpanded(true); + } + } catch (err) { + console.error("Failed to load official models:", err); + setToast({ message: "Failed to load official models", type: "error" }); + } finally { + setLoadingOfficialModels(false); + } + }; + /** * Saves the current settings */ @@ -336,6 +383,70 @@ export const Settings: React.FC = ({ setBinaryPathChanged(installation.path !== currentBinaryPath); }; + /** + * Adds a new custom model + */ + const addCustomModel = () => { + const newModel: CustomModel = { + name: "", + identifier: "", + description: "", + }; + setCustomModels(prev => [...prev, newModel]); + setModelsChanged(true); + }; + + /** + * Adds an official model to custom models list + */ + const addOfficialModelToCustom = (officialModel: CustomModel) => { + // Check if model already exists in custom models + const exists = customModels.some(model => model.identifier === officialModel.identifier); + if (exists) { + setToast({ message: "Model already exists in custom models", type: "error" }); + return; + } + + setCustomModels(prev => [...prev, officialModel]); + setModelsChanged(true); + setToast({ message: `Added ${officialModel.name} to custom models`, type: "success" }); + }; + + /** + * Updates a custom model + */ + const updateCustomModel = (index: number, field: keyof CustomModel, value: string) => { + setCustomModels(prev => prev.map((model, i) => + i === index ? { ...model, [field]: value } : model + )); + setModelsChanged(true); + }; + + /** + * Removes a custom model + */ + const removeCustomModel = (index: number) => { + setCustomModels(prev => prev.filter((_, i) => i !== index)); + setModelsChanged(true); + }; + + /** + * Saves custom models + */ + const saveCustomModels = async () => { + try { + setSaving(true); + await api.saveCustomModels(customModels); + setModelsChanged(false); + setToast({ message: "Custom models saved successfully!", type: "success" }); + } catch (err) { + console.error("Failed to save custom models:", err); + setToast({ message: "Failed to save custom models", type: "error" }); + } finally { + setSaving(false); + } + }; + return (
@@ -397,10 +508,11 @@ export const Settings: React.FC = ({ ) : (
- + General Permissions Environment + Models Advanced Hooks Commands @@ -975,6 +1087,270 @@ export const Settings: React.FC = ({
+ + {/* Models */} + + {/* Official Models */} + +
+
+
+

Official Models

+

+ Browse and select from Anthropic's official Claude models +

+
+
+ + +
+
+ + + {officialModelsExpanded && ( + +
+ {loadingOfficialModels ? ( +
+ + Loading official models... +
+ ) : officialModels.length === 0 ? ( +

+ No official models available. Click Refresh to load. +

+ ) : ( + officialModels.map((model, index) => ( + +
+

{model.name}

+ +
+
+
+ Model ID: {model.identifier} +
+ {model.description && ( +
+ Description: {model.description} +
+ )} +
+
+ )) + )} +
+
+ )} +
+ +
+

+ Notes: +

+
    +
  • • Click "Add" to add official models to your custom models list
  • +
  • • Official models are curated by Anthropic and regularly updated
  • +
  • • Added models will appear in the model selection dropdown
  • +
+
+
+
+ + {/* Custom Models */} + +
+
+
+

Custom Models

+

+ Add custom Claude models that will appear in the model selection dropdown +

+
+ +
+ +
+ {customModels.length === 0 ? ( +

+ No custom models configured. +

+ ) : ( + customModels.map((model, index) => ( + +
+

Model {index + 1}

+ +
+
+
+ + updateCustomModel(index, "name", e.target.value)} + className="text-sm" + /> +
+
+ + updateCustomModel(index, "identifier", e.target.value)} + className="text-sm font-mono" + /> +
+
+
+ + updateCustomModel(index, "description", e.target.value)} + className="text-sm" + /> +
+
+ )) + )} +
+ + {modelsChanged && ( + + +

+ You have unsaved changes to your custom models. +

+ +
+ )} + + {modelConfig?.env_model && ( +
+

+ Environment Model Detected +

+

+ ANTHROPIC_MODEL: {modelConfig.env_model} +

+
+ )} + +
+

+ Notes: +

+
    +
  • • Custom models will appear in a separate group in the model selection dropdown
  • +
  • • The model identifier should match exactly what Claude CLI expects
  • +
  • • Changes require saving and may need a restart to take full effect
  • +
+
+
+
+
+ {/* Advanced Settings */} @@ -1006,9 +1382,7 @@ export const Settings: React.FC = ({
{JSON.stringify(settings, null, 2)}
-

- This shows the raw JSON that will be saved to ~/.claude/settings.json -

+
diff --git a/src/components/claude-code-session/PromptQueue.tsx b/src/components/claude-code-session/PromptQueue.tsx index b2b2f546e..92756ae3d 100644 --- a/src/components/claude-code-session/PromptQueue.tsx +++ b/src/components/claude-code-session/PromptQueue.tsx @@ -8,7 +8,7 @@ import { cn } from '@/lib/utils'; interface QueuedPrompt { id: string; prompt: string; - model: "sonnet" | "opus"; + model: string; } interface PromptQueueProps { @@ -52,17 +52,21 @@ export const PromptQueue: React.FC = React.memo(({ className="flex items-start gap-2 p-2 rounded-md bg-background/50" >
- {queuedPrompt.model === "opus" ? ( + {queuedPrompt.model.includes("opus") ? ( - ) : ( + ) : queuedPrompt.model.includes("sonnet") ? ( + ) : ( + )}

{queuedPrompt.prompt}

- {queuedPrompt.model === "opus" ? "Opus" : "Sonnet"} + {queuedPrompt.model.includes("opus") ? "Opus" : + queuedPrompt.model.includes("sonnet") ? "Sonnet" : + queuedPrompt.model}
diff --git a/src/lib/api.ts b/src/lib/api.ts index dd3641db7..63606aaaa 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,6 +1,19 @@ import { invoke } from "@tauri-apps/api/core"; import type { HooksConfiguration } from '@/types/hooks'; +/** Custom model definition */ +export interface CustomModel { + name: string; + identifier: string; + description?: string; +} + +/** Model configuration including custom models and environment model */ +export interface ModelConfig { + custom_models: CustomModel[]; + env_model?: string; +} + /** Process type for tracking in ProcessRegistry */ export type ProcessType = | { AgentRun: { agent_id: number; agent_name: string } } @@ -1928,10 +1941,7 @@ export const api = { }, /** - * Deletes a slash command - * @param commandId - Unique identifier of the command to delete - * @param projectPath - Optional project path for deleting project commands - * @returns Promise resolving to deletion message + * Delete a slash command */ async slashCommandDelete(commandId: string, projectPath?: string): Promise { try { @@ -1942,4 +1952,65 @@ export const api = { } }, + // ===== Model Management ===== + + /** + * Get available models including custom models and environment model + */ + async getAvailableModels(): Promise { + try { + return await invoke("get_available_models"); + } catch (error) { + console.error("Failed to get available models:", error); + throw error; + } + }, + + /** + * Save custom models configuration + */ + async saveCustomModels(models: CustomModel[]): Promise { + try { + await invoke("save_custom_models", { models }); + } catch (error) { + console.error("Failed to save custom models:", error); + throw error; + } + }, + + /** + * Add a new custom model + */ + async addCustomModel(model: CustomModel): Promise { + try { + await invoke("add_custom_model", { model }); + } catch (error) { + console.error("Failed to add custom model:", error); + throw error; + } + }, + + /** + * Remove a custom model + */ + async removeCustomModel(modelName: string): Promise { + try { + await invoke("remove_custom_model", { modelName }); + } catch (error) { + console.error("Failed to remove custom model:", error); + throw error; + } + }, + + /** + * Get official Anthropic models from API + */ + async getOfficialModels(): Promise { + try { + return await invoke("get_official_models"); + } catch (error) { + console.error("Failed to get official models:", error); + throw error; + } + } };