use std::collections::HashMap; use std::path::PathBuf; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use crate::config::{ AgentConfig, ChannelConfig, Config, FeishuChannelConfig, GatewayConfig, ModelConfig, ProviderConfig, SchedulerConfig, TaggedChannelConfig, WechatChannelConfig, }; /// Interactive configuration wizard for PicoBot pub struct InitWizard { read: BufReader, write: tokio::io::Stdout, config_path: PathBuf, } impl InitWizard { pub fn new() -> Self { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); Self { read: BufReader::new(tokio::io::stdin()), write: tokio::io::stdout(), config_path: home.join(".picobot").join("config.json"), } } pub async fn run(&mut self, force: bool, skip_channels: bool) -> Result<(), InitError> { let existing = self.load_existing_config(force)?; self.show_welcome_message(); // Step 1: Provider let providers = self.configure_provider(&existing).await?; // Step 2: Model let models = self.configure_model(&existing).await?; // Step 3: Agent let agents = self.configure_agent(&existing, &providers, &models).await?; // Step 4: Channels let channels = if !skip_channels { self.configure_channels(&existing).await? } else { existing.channels.clone() }; let config = self.build_config(providers, models, agents, channels, &existing); self.save_config(&config)?; self.show_completion_message(&config); Ok(()) } fn load_existing_config(&self, force: bool) -> Result { if self.config_path.exists() && !force { Config::load_default().or_else(|_| Ok(Self::empty_config())) } else { Ok(Self::empty_config()) } } fn empty_config() -> Config { Config { providers: HashMap::new(), models: HashMap::new(), agents: HashMap::new(), time: crate::config::TimeConfig::default(), gateway: GatewayConfig::default(), scheduler: SchedulerConfig::default(), client: crate::config::ClientConfig::default(), channels: HashMap::new(), skills: crate::config::SkillsConfig::default(), tools: crate::config::ToolsConfig::default(), memory_maintenance: crate::config::MemoryMaintenanceConfig::default(), mcp_servers: HashMap::new(), image_context: crate::config::ImageContextConfig::default(), subagents: crate::config::SubagentsConfig::default(), } } fn show_welcome_message(&mut self) { println!(); println!("╔══════════════════════════════════════════════════════╗"); println!("║ PicoBot Configuration Wizard ║"); println!("╚══════════════════════════════════════════════════════╝"); println!(); println!("This wizard will help you configure PicoBot."); println!("Press Enter to use default values where shown."); println!(); } // ==================== Prompt Helpers ==================== async fn prompt_with_default( &mut self, label: &str, default: &str, ) -> Result { if !default.is_empty() { println!("{} [default: {}]: ", label, default); } else { println!("{}: ", label); } self.write.flush().await?; let mut line = String::new(); let bytes_read = self.read.read_line(&mut line).await?; if bytes_read == 0 { return Err(InitError::InputError("EOF reached".to_string())); } let input = line.trim().to_string(); Ok(if input.is_empty() { default.to_string() } else { input }) } async fn prompt_required(&mut self, label: &str) -> Result { loop { println!("{}: ", label); self.write.flush().await?; let mut line = String::new(); let bytes_read = self.read.read_line(&mut line).await?; if bytes_read == 0 { return Err(InitError::InputError("EOF reached".to_string())); } let input = line.trim().to_string(); if !input.is_empty() { return Ok(input); } println!("{} is required. Please enter a value.", label); } } async fn prompt_select( &mut self, label: &str, options: &[String], default: usize, ) -> Result { for (i, opt) in options.iter().enumerate() { println!(" {}. {}", i + 1, opt); } println!(); let default_str = (default + 1).to_string(); let input = self.prompt_with_default(label, &default_str).await?; let selected: usize = input.parse().map_err(|_| { InitError::InputError(format!("Invalid selection: {}", input)) })?; if selected == 0 || selected > options.len() { return Err(InitError::InputError(format!( "Selection must be between 1 and {}", options.len() ))); } Ok(selected - 1) } // ==================== Step 1: Provider ==================== async fn configure_provider( &mut self, existing: &Config, ) -> Result, InitError> { println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!("Step 1: Configure Provider (API Endpoint)"); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!(); let provider_names: Vec = existing.providers.keys().cloned().collect(); if !provider_names.is_empty() { println!("Existing providers:"); for (i, name) in provider_names.iter().enumerate() { println!(" {}. {}", i + 1, name); } println!(); println!("Options:"); println!(" 1. Add new provider"); println!(" 2. Modify existing provider"); println!(" 3. Use existing provider"); println!(" 4. Skip"); println!(); let choice = self .prompt_with_default("Select option", "1") .await?; match choice.as_str() { "1" => return self.add_provider(existing).await, "2" => return self.modify_provider(existing).await, "3" => { println!("Keeping existing providers."); return Ok(existing.providers.clone()); } "4" => { println!("Skipping provider configuration."); return Ok(existing.providers.clone()); } _ => { println!("Invalid option, adding new provider."); return self.add_provider(existing).await; } } } else { println!("No existing providers found."); println!(); println!("Options:"); println!(" 1. Add new provider"); println!(" 2. Skip"); println!(); let choice = self.prompt_with_default("Select option", "1").await?; match choice.as_str() { "1" => self.add_provider(existing).await, "2" => { println!("Skipping provider configuration."); Ok(existing.providers.clone()) } _ => { println!("Invalid option, adding new provider."); self.add_provider(existing).await } } } } async fn add_provider( &mut self, existing: &Config, ) -> Result, InitError> { let provider_name = self .prompt_with_default("Provider name", "default") .await?; println!("Provider type:"); println!(" 1. openai"); println!(" 2. anthropic"); println!(); let type_choice = self.prompt_with_default("Select type", "1").await?; let provider_type = match type_choice.as_str() { "1" => "openai", "2" => "anthropic", _ => "openai", }; let base_url = self.prompt_required("API base URL").await?; println!(); println!("API Key is required and will be stored in config.json."); let api_key = self.prompt_required("API Key").await?; let provider = ProviderConfig { provider_type: provider_type.to_string(), base_url, api_key, extra_headers: HashMap::new(), llm_timeout_secs: 120, memory_maintenance_timeout_secs: 600, }; let mut providers = existing.providers.clone(); providers.insert(provider_name.clone(), provider); println!(); println!("Provider '{}' configured.", provider_name); Ok(providers) } async fn modify_provider( &mut self, existing: &Config, ) -> Result, InitError> { // Select which provider to modify let provider_names: Vec = existing.providers.keys().cloned().collect(); println!("Select provider to modify:"); let provider_idx = self.prompt_select("", &provider_names, 0).await?; let selected_provider_name = &provider_names[provider_idx]; let current_provider = existing.providers.get(selected_provider_name).unwrap(); println!(); println!( "Current config: type={}, base_url={}", current_provider.provider_type, current_provider.base_url ); println!(); let current_type_idx = if current_provider.provider_type == "anthropic" { 1 } else { 0 }; let type_options = vec!["openai".to_string(), "anthropic".to_string()]; println!("Provider type:"); let type_idx = self.prompt_select("", &type_options, current_type_idx).await?; let provider_type = &type_options[type_idx]; let base_url = self .prompt_with_default("API base URL", ¤t_provider.base_url) .await?; println!(); println!("API Key (press Enter to keep current key):"); let api_key = self .prompt_with_default("API Key", ¤t_provider.api_key) .await?; let provider = ProviderConfig { provider_type: provider_type.clone(), base_url, api_key, extra_headers: current_provider.extra_headers.clone(), llm_timeout_secs: current_provider.llm_timeout_secs, memory_maintenance_timeout_secs: current_provider.memory_maintenance_timeout_secs, }; let mut providers = existing.providers.clone(); providers.insert(selected_provider_name.clone(), provider); println!(); println!("Provider '{}' modified.", selected_provider_name); Ok(providers) } // ==================== Step 2: Model ==================== async fn configure_model( &mut self, existing: &Config, ) -> Result, InitError> { println!(); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!("Step 2: Configure Model"); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!(); let model_names: Vec = existing.models.keys().cloned().collect(); if !model_names.is_empty() { println!("Existing models:"); for (i, name) in model_names.iter().enumerate() { let model = existing.models.get(name).unwrap(); println!(" {}. {} (model_id: {})", i + 1, name, model.model_id); } println!(); println!("Options:"); println!(" 1. Use existing model"); println!(" 2. Add new model"); println!(" 3. Skip"); println!(); let choice = self.prompt_with_default("Select option", "1").await?; match choice.as_str() { "1" => { println!("Keeping existing models."); return Ok(existing.models.clone()); } "2" => return self.add_model(existing).await, "3" => { println!("Skipping model configuration."); return Ok(existing.models.clone()); } _ => { println!("Invalid option, keeping existing models."); return Ok(existing.models.clone()); } } } else { println!("No existing models found."); println!(); println!("Options:"); println!(" 1. Add new model"); println!(" 2. Skip"); println!(); let choice = self.prompt_with_default("Select option", "1").await?; match choice.as_str() { "1" => self.add_model(existing).await, "2" => { println!("Skipping model configuration."); Ok(existing.models.clone()) } _ => self.add_model(existing).await, } } } async fn add_model( &mut self, existing: &Config, ) -> Result, InitError> { let model_name = self .prompt_with_default("Model name (reference key)", "default") .await?; let model_id = self.prompt_required("Model ID (actual identifier)").await?; let temperature_str = self .prompt_with_default("Temperature (0.0-1.0)", "0.7") .await?; let temperature: Option = temperature_str .parse() .ok() .filter(|v| (0.0..=1.0).contains(v)); let max_tokens_str = self .prompt_with_default("Max tokens (optional)", "4096") .await?; let max_tokens: Option = max_tokens_str.parse().ok(); let model = ModelConfig { model_id, temperature, max_tokens, context_window_tokens: Some(128000), extra: HashMap::new(), }; let mut models = existing.models.clone(); models.insert(model_name.clone(), model); println!(); println!("Model '{}' configured.", model_name); Ok(models) } // ==================== Step 3: Agent ==================== async fn configure_agent( &mut self, existing: &Config, providers: &HashMap, models: &HashMap, ) -> Result, InitError> { println!(); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!("Step 3: Configure Agent"); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!(); // Check if we have providers and models to select from if providers.is_empty() { println!("Warning: No providers configured. Please configure a provider first."); return Ok(existing.agents.clone()); } if models.is_empty() { println!("Warning: No models configured. Please configure a model first."); return Ok(existing.agents.clone()); } let agent_names: Vec = existing.agents.keys().cloned().collect(); if !agent_names.is_empty() { println!("Existing agents:"); for (i, name) in agent_names.iter().enumerate() { let agent = existing.agents.get(name).unwrap(); println!( " {}. {} (provider: {}, model: {})", i + 1, name, agent.provider, agent.model ); } println!(); println!("Options:"); println!(" 1. Add new agent"); println!(" 2. Modify existing agent"); println!(" 3. Use existing agent"); println!(" 4. Skip"); println!(); let choice = self.prompt_with_default("Select option", "1").await?; match choice.as_str() { "1" => return self.add_agent(existing, providers, models).await, "2" => return self.modify_agent(existing, providers, models).await, "3" => { println!("Keeping existing agents."); return Ok(existing.agents.clone()); } "4" => { println!("Skipping agent configuration."); return Ok(existing.agents.clone()); } _ => { println!("Invalid option, adding new agent."); return self.add_agent(existing, providers, models).await; } } } else { println!("No existing agents found."); println!(); println!("Options:"); println!(" 1. Add new agent"); println!(" 2. Skip"); println!(); let choice = self.prompt_with_default("Select option", "1").await?; match choice.as_str() { "1" => self.add_agent(existing, providers, models).await, "2" => { println!("Skipping agent configuration."); Ok(existing.agents.clone()) } _ => self.add_agent(existing, providers, models).await, } } } async fn add_agent( &mut self, existing: &Config, providers: &HashMap, models: &HashMap, ) -> Result, InitError> { let agent_name = self .prompt_with_default("Agent name", "default") .await?; // Select provider let provider_names: Vec = providers.keys().cloned().collect(); println!("Select provider:"); let provider_idx = self.prompt_select("", &provider_names, 0).await?; let selected_provider = &provider_names[provider_idx]; // Select model (independently) let model_names: Vec = models.keys().cloned().collect(); println!(); println!("Select model:"); let model_idx = self.prompt_select("", &model_names, 0).await?; let selected_model = &model_names[model_idx]; let agent = AgentConfig { provider: selected_provider.clone(), model: selected_model.clone(), max_tool_iterations: 100, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 2000, }; let mut agents = existing.agents.clone(); agents.insert(agent_name.clone(), agent); println!(); println!( "Agent '{}' configured (provider: {}, model: {}).", agent_name, selected_provider, selected_model ); Ok(agents) } async fn modify_agent( &mut self, existing: &Config, providers: &HashMap, models: &HashMap, ) -> Result, InitError> { // Select which agent to modify let agent_names: Vec = existing.agents.keys().cloned().collect(); println!("Select agent to modify:"); let agent_idx = self.prompt_select("", &agent_names, 0).await?; let selected_agent_name = &agent_names[agent_idx]; let current_agent = existing.agents.get(selected_agent_name).unwrap(); println!(); println!( "Current config: provider={}, model={}", current_agent.provider, current_agent.model ); println!(); // Select new provider let provider_names: Vec = providers.keys().cloned().collect(); let current_provider_idx = provider_names .iter() .position(|p| p == ¤t_agent.provider) .unwrap_or(0); println!("Select provider:"); let provider_idx = self.prompt_select("", &provider_names, current_provider_idx).await?; let selected_provider = &provider_names[provider_idx]; // Select new model let model_names: Vec = models.keys().cloned().collect(); let current_model_idx = model_names .iter() .position(|m| m == ¤t_agent.model) .unwrap_or(0); println!(); println!("Select model:"); let model_idx = self.prompt_select("", &model_names, current_model_idx).await?; let selected_model = &model_names[model_idx]; let agent = AgentConfig { provider: selected_provider.clone(), model: selected_model.clone(), max_tool_iterations: current_agent.max_tool_iterations, tool_result_max_chars: current_agent.tool_result_max_chars, context_tool_result_trim_chars: current_agent.context_tool_result_trim_chars, }; let mut agents = existing.agents.clone(); agents.insert(selected_agent_name.clone(), agent); println!(); println!( "Agent '{}' modified (provider: {}, model: {}).", selected_agent_name, selected_provider, selected_model ); Ok(agents) } // ==================== Step 4: Channels ==================== async fn configure_channels( &mut self, existing: &Config, ) -> Result, InitError> { println!(); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!("Step 4: Configure Channels"); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!(); // Show existing channels if !existing.channels.is_empty() { println!("Existing channels:"); for (name, config) in &existing.channels { let status = if config.enabled() { "enabled" } else { "disabled" }; println!(" - {} ({})", name, status); } println!(); } let mut channels = existing.channels.clone(); // Loop for configuring multiple channels loop { println!("Options:"); println!(" 1. Add Feishu channel"); println!(" 2. Add WeChat channel"); println!(" 3. Skip / Done"); println!(); let choice = self.prompt_with_default("Select option", "3").await?; match choice.as_str() { "1" => { let channel_config = self.configure_feishu_channel(&channels).await?; for (name, config) in channel_config { channels.insert(name, config); } } "2" => { let channel_config = self.configure_wechat_channel(&channels).await?; for (name, config) in channel_config { channels.insert(name, config); } } "3" => break, _ => { println!("Invalid selection."); continue; } } } Ok(channels) } async fn configure_feishu_channel( &mut self, existing: &HashMap, ) -> Result, InitError> { println!(); println!("Configuring Feishu channel..."); println!(); let channel_name = self .prompt_with_default("Channel name", "feishu") .await?; let _existing_config = existing.get(&channel_name).and_then(|c| c.as_feishu()); let app_id = self.prompt_required("Feishu App ID").await?; let app_secret = self.prompt_required("Feishu App Secret").await?; let config = ChannelConfig::Tagged(TaggedChannelConfig::Feishu(FeishuChannelConfig { enabled: true, app_id, app_secret, allow_from: vec!["*".to_string()], agent: "default".to_string(), media_dir: Self::default_feishu_media_dir(), reaction_emoji: "Typing".to_string(), max_message_chars: 20000, reply_context_max_chars: 20000, })); let mut result = HashMap::new(); result.insert(channel_name.clone(), config); println!(); println!("Feishu channel '{}' configured.", channel_name); Ok(result) } async fn configure_wechat_channel( &mut self, _existing: &HashMap, ) -> Result, InitError> { println!(); println!("Configuring WeChat channel..."); println!(); println!("WeChat login requires scanning a QR code."); println!("The QR code URL will be displayed after configuration."); println!(); // Use default values directly let channel_name = "wechat"; let base_url = "https://ilinkai.weixin.qq.com"; let cred_path = Self::default_wechat_cred_path(); let force_login = false; let config = ChannelConfig::Tagged(TaggedChannelConfig::Wechat(WechatChannelConfig { enabled: true, allow_from: vec!["*".to_string()], agent: "default".to_string(), base_url: base_url.to_string(), cred_path, force_login, })); let mut result = HashMap::new(); result.insert(channel_name.to_string(), config); println!(); println!("WeChat channel '{}' configured.", channel_name); // Auto login after configuration println!(); println!("Starting WeChat login..."); self.do_wechat_login(base_url, &Self::default_wechat_cred_path()).await?; println!(); println!("WeChat login successful! Credentials saved."); Ok(result) } async fn do_wechat_login(&mut self, base_url: &str, cred_path: &str) -> Result<(), InitError> { use wechatbot::{BotOptions, WeChatBot}; let bot = WeChatBot::new(BotOptions { base_url: Some(base_url.to_string()), cred_path: Some(cred_path.to_string()), on_qr_url: Some(Box::new(|url| { println!(); println!("┌──────────────────────────────────────────────────────┐"); println!("│ WeChat QR Code │"); println!("└──────────────────────────────────────────────────────┘"); println!(); println!("Scan this URL in WeChat:"); println!("{}", url); println!(); println!("Waiting for confirmation..."); })), on_error: Some(Box::new(|error| { eprintln!("WeChat login error: {}", error); })), }); let creds = bot.login(true).await.map_err(|e| { InitError::WeChatError(format!("WeChat login failed: {}", e)) })?; println!(); println!( "Logged in as: {} (account: {})", creds.user_id, creds.account_id ); Ok(()) } // ==================== Build & Save ==================== fn build_config( &self, providers: HashMap, models: HashMap, agents: HashMap, channels: HashMap, existing: &Config, ) -> Config { Config { providers, models, agents, channels, time: existing.time.clone(), gateway: existing.gateway.clone(), scheduler: existing.scheduler.clone(), client: existing.client.clone(), skills: existing.skills.clone(), tools: existing.tools.clone(), memory_maintenance: existing.memory_maintenance.clone(), mcp_servers: existing.mcp_servers.clone(), image_context: existing.image_context.clone(), subagents: existing.subagents.clone(), } } fn save_config(&self, config: &Config) -> Result<(), InitError> { let dir = self .config_path .parent() .ok_or_else(|| InitError::IoError("Invalid config path".to_string()))?; std::fs::create_dir_all(dir) .map_err(|e| InitError::IoError(format!("Failed to create directory: {}", e)))?; let content = serde_json::to_string_pretty(config) .map_err(|e| InitError::SerializeError(e.to_string()))?; std::fs::write(&self.config_path, content) .map_err(|e| InitError::IoError(format!("Failed to write config: {}", e)))?; Ok(()) } fn show_completion_message(&mut self, config: &Config) { println!(); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!("Configuration complete!"); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!(); println!("Config saved to: {}", self.config_path.display()); println!(); println!("Summary:"); println!(" Providers: {}", config.providers.len()); println!(" Models: {}", config.models.len()); println!(" Agents: {}", config.agents.len()); println!(" Channels: {}", config.channels.len()); println!(); println!("Next steps:"); println!(" 1. Start the gateway: picobot gateway"); println!(" 2. Connect with CLI: picobot agent"); println!(); println!("For more options, run: picobot --help"); println!(); } fn default_feishu_media_dir() -> String { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); home.join(".picobot/media/feishu") .to_string_lossy() .to_string() } fn default_wechat_cred_path() -> String { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); home.join(".picobot/wechat/credentials.json") .to_string_lossy() .to_string() } } impl Default for InitWizard { fn default() -> Self { Self::new() } } #[derive(Debug)] pub enum InitError { IoError(String), SerializeError(String), InputError(String), WeChatError(String), } impl std::fmt::Display for InitError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { InitError::IoError(msg) => write!(f, "IO error: {}", msg), InitError::SerializeError(msg) => write!(f, "Serialization error: {}", msg), InitError::InputError(msg) => write!(f, "Input error: {}", msg), InitError::WeChatError(msg) => write!(f, "WeChat error: {}", msg), } } } impl std::error::Error for InitError {} impl From for InitError { fn from(e: std::io::Error) -> Self { InitError::IoError(e.to_string()) } }