feat: 添加交互式配置向导,支持强制覆盖和跳过频道配置选项
This commit is contained in:
parent
c36650c9aa
commit
4a24758262
937
src/cli/init.rs
Normal file
937
src/cli/init.rs
Normal file
@ -0,0 +1,937 @@
|
|||||||
|
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<tokio::io::Stdin>,
|
||||||
|
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<Config, InitError> {
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, InitError> {
|
||||||
|
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<String, InitError> {
|
||||||
|
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_yes_no(&mut self, label: &str, default: bool) -> Result<bool, InitError> {
|
||||||
|
let default_str = if default { "yes" } else { "no" };
|
||||||
|
println!("{} [yes/no, default: {}]: ", label, default_str);
|
||||||
|
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_lowercase();
|
||||||
|
if input.is_empty() {
|
||||||
|
Ok(default)
|
||||||
|
} else {
|
||||||
|
Ok(input == "yes" || input == "y" || input == "1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prompt_select(
|
||||||
|
&mut self,
|
||||||
|
label: &str,
|
||||||
|
options: &[String],
|
||||||
|
default: usize,
|
||||||
|
) -> Result<usize, InitError> {
|
||||||
|
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<HashMap<String, ProviderConfig>, InitError> {
|
||||||
|
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||||
|
println!("Step 1: Configure Provider (API Endpoint)");
|
||||||
|
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let provider_names: Vec<String> = 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<HashMap<String, ProviderConfig>, 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<HashMap<String, ProviderConfig>, InitError> {
|
||||||
|
// Select which provider to modify
|
||||||
|
let provider_names: Vec<String> = 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<HashMap<String, ModelConfig>, InitError> {
|
||||||
|
println!();
|
||||||
|
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||||
|
println!("Step 2: Configure Model");
|
||||||
|
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let model_names: Vec<String> = 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<HashMap<String, ModelConfig>, 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<f32> = 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<u32> = 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<String, ProviderConfig>,
|
||||||
|
models: &HashMap<String, ModelConfig>,
|
||||||
|
) -> Result<HashMap<String, AgentConfig>, 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<String> = 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<String, ProviderConfig>,
|
||||||
|
models: &HashMap<String, ModelConfig>,
|
||||||
|
) -> Result<HashMap<String, AgentConfig>, InitError> {
|
||||||
|
let agent_name = self
|
||||||
|
.prompt_with_default("Agent name", "default")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Select provider
|
||||||
|
let provider_names: Vec<String> = 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<String> = 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: 20000,
|
||||||
|
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<String, ProviderConfig>,
|
||||||
|
models: &HashMap<String, ModelConfig>,
|
||||||
|
) -> Result<HashMap<String, AgentConfig>, InitError> {
|
||||||
|
// Select which agent to modify
|
||||||
|
let agent_names: Vec<String> = 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<String> = 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<String> = 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<HashMap<String, ChannelConfig>, 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<String, ChannelConfig>,
|
||||||
|
) -> Result<HashMap<String, ChannelConfig>, 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<String, ChannelConfig>,
|
||||||
|
) -> Result<HashMap<String, ChannelConfig>, 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<String, ProviderConfig>,
|
||||||
|
models: HashMap<String, ModelConfig>,
|
||||||
|
agents: HashMap<String, AgentConfig>,
|
||||||
|
channels: HashMap<String, ChannelConfig>,
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<std::io::Error> for InitError {
|
||||||
|
fn from(e: std::io::Error) -> Self {
|
||||||
|
InitError::IoError(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
pub mod channel;
|
pub mod channel;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
|
pub mod init;
|
||||||
|
|
||||||
pub use channel::CliChannel;
|
pub use channel::CliChannel;
|
||||||
pub use input::{InputCommand, InputEvent, InputHandler};
|
pub use input::{InputCommand, InputEvent, InputHandler};
|
||||||
|
pub use init::InitWizard;
|
||||||
|
|||||||
15
src/main.rs
15
src/main.rs
@ -4,6 +4,15 @@ use clap::{CommandFactory, Parser};
|
|||||||
#[command(name = "picobot")]
|
#[command(name = "picobot")]
|
||||||
#[command(about = "A CLI chatbot", long_about = None)]
|
#[command(about = "A CLI chatbot", long_about = None)]
|
||||||
enum Command {
|
enum Command {
|
||||||
|
/// Interactive configuration wizard
|
||||||
|
Init {
|
||||||
|
/// Force overwrite existing config
|
||||||
|
#[arg(short, long)]
|
||||||
|
force: bool,
|
||||||
|
/// Only configure provider, skip channels
|
||||||
|
#[arg(long)]
|
||||||
|
skip_channels: bool,
|
||||||
|
},
|
||||||
/// Connect to gateway
|
/// Connect to gateway
|
||||||
Agent {
|
Agent {
|
||||||
/// Gateway WebSocket URL (e.g., ws://127.0.0.1:19876/ws)
|
/// Gateway WebSocket URL (e.g., ws://127.0.0.1:19876/ws)
|
||||||
@ -31,10 +40,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if std::env::args().len() <= 1 {
|
if std::env::args().len() <= 1 {
|
||||||
cmd.print_help()?;
|
cmd.print_help()?;
|
||||||
println!();
|
println!();
|
||||||
return Ok(());
|
return Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
match Command::parse() {
|
match Command::parse() {
|
||||||
|
Command::Init { force, skip_channels } => {
|
||||||
|
let mut wizard = picobot::cli::InitWizard::new();
|
||||||
|
wizard.run(force, skip_channels).await?;
|
||||||
|
}
|
||||||
Command::Agent { gateway_url } => {
|
Command::Agent { gateway_url } => {
|
||||||
let config = picobot::config::Config::load_default().ok();
|
let config = picobot::config::Config::load_default().ok();
|
||||||
let url = gateway_url
|
let url = gateway_url
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user