893 lines
29 KiB
Rust
893 lines
29 KiB
Rust
use crate::shared::platform_name;
|
|
use crate::shared::BOTSERVER_VERSION;
|
|
use crossterm::{
|
|
cursor,
|
|
event::{self, Event, KeyCode, KeyEvent},
|
|
execute,
|
|
style::{Color, Print, ResetColor, SetForegroundColor},
|
|
terminal::{self, ClearType},
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::io::{self, Write};
|
|
use std::path::PathBuf;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct WizardConfig {
|
|
pub llm_provider: LlmProvider,
|
|
|
|
pub llm_api_key: Option<String>,
|
|
|
|
pub local_model_path: Option<String>,
|
|
|
|
pub components: Vec<ComponentChoice>,
|
|
|
|
pub admin: AdminConfig,
|
|
|
|
pub organization: OrgConfig,
|
|
|
|
pub template: Option<String>,
|
|
|
|
pub install_mode: InstallMode,
|
|
|
|
pub data_dir: PathBuf,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub enum LlmProvider {
|
|
Claude,
|
|
OpenAI,
|
|
Gemini,
|
|
Local,
|
|
None,
|
|
}
|
|
|
|
impl std::fmt::Display for LlmProvider {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
LlmProvider::Claude => write!(f, "Claude (Anthropic) - Best for complex reasoning"),
|
|
LlmProvider::OpenAI => write!(f, "GPT-4 (OpenAI) - General purpose"),
|
|
LlmProvider::Gemini => write!(f, "Gemini (Google) - Google integration"),
|
|
LlmProvider::Local => write!(f, "Local (Llama/Mistral) - Privacy focused"),
|
|
LlmProvider::None => write!(f, "None - Configure later"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub enum ComponentChoice {
|
|
Drive,
|
|
Email,
|
|
Meet,
|
|
Tables,
|
|
Cache,
|
|
VectorDb,
|
|
Proxy,
|
|
Directory,
|
|
BotModels,
|
|
}
|
|
|
|
impl std::fmt::Display for ComponentChoice {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
ComponentChoice::Drive => write!(f, "Drive (MinIO) - File storage"),
|
|
ComponentChoice::Email => write!(f, "Email Server - Send/receive emails"),
|
|
ComponentChoice::Meet => write!(f, "Meet (LiveKit) - Video meetings"),
|
|
ComponentChoice::Tables => write!(f, "Database (PostgreSQL) - Required"),
|
|
ComponentChoice::Cache => write!(f, "Cache (Redis) - Sessions & queues"),
|
|
ComponentChoice::VectorDb => write!(f, "Vector DB - AI embeddings"),
|
|
ComponentChoice::Proxy => write!(f, "Proxy (Caddy) - HTTPS & routing"),
|
|
ComponentChoice::Directory => write!(f, "Directory - Users & SSO"),
|
|
ComponentChoice::BotModels => write!(f, "BotModels - Local AI models"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct AdminConfig {
|
|
pub username: String,
|
|
pub email: String,
|
|
pub password: String,
|
|
pub display_name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct OrgConfig {
|
|
pub name: String,
|
|
pub slug: String,
|
|
pub domain: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub enum InstallMode {
|
|
Development,
|
|
Production,
|
|
Container,
|
|
}
|
|
|
|
impl Default for WizardConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
llm_provider: LlmProvider::None,
|
|
llm_api_key: None,
|
|
local_model_path: None,
|
|
components: vec![
|
|
ComponentChoice::Tables,
|
|
ComponentChoice::Cache,
|
|
ComponentChoice::Drive,
|
|
],
|
|
admin: AdminConfig::default(),
|
|
organization: OrgConfig::default(),
|
|
template: None,
|
|
install_mode: InstallMode::Development,
|
|
data_dir: PathBuf::from("./botserver-stack"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct StartupWizard {
|
|
config: WizardConfig,
|
|
current_step: usize,
|
|
total_steps: usize,
|
|
}
|
|
|
|
impl StartupWizard {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
config: WizardConfig::default(),
|
|
current_step: 0,
|
|
total_steps: 7,
|
|
}
|
|
}
|
|
|
|
pub fn run(&mut self) -> io::Result<WizardConfig> {
|
|
terminal::enable_raw_mode()?;
|
|
let mut stdout = io::stdout();
|
|
|
|
execute!(
|
|
stdout,
|
|
terminal::Clear(ClearType::All),
|
|
cursor::MoveTo(0, 0)
|
|
)?;
|
|
|
|
self.show_welcome(&mut stdout)?;
|
|
self.wait_for_enter()?;
|
|
|
|
self.current_step = 1;
|
|
self.step_install_mode(&mut stdout)?;
|
|
|
|
self.current_step = 2;
|
|
self.step_llm_provider(&mut stdout)?;
|
|
|
|
self.current_step = 3;
|
|
self.step_components(&mut stdout)?;
|
|
|
|
self.current_step = 4;
|
|
self.step_organization(&mut stdout)?;
|
|
|
|
self.current_step = 5;
|
|
self.step_admin_user(&mut stdout)?;
|
|
|
|
self.current_step = 6;
|
|
self.step_template(&mut stdout)?;
|
|
|
|
self.current_step = 7;
|
|
self.step_summary(&mut stdout)?;
|
|
|
|
terminal::disable_raw_mode()?;
|
|
Ok(self.config.clone())
|
|
}
|
|
|
|
fn show_welcome(&self, stdout: &mut io::Stdout) -> io::Result<()> {
|
|
execute!(
|
|
stdout,
|
|
terminal::Clear(ClearType::All),
|
|
cursor::MoveTo(0, 0)
|
|
)?;
|
|
|
|
let banner = r"
|
|
╔══════════════════════════════════════════════════════════════════╗
|
|
║ ║
|
|
║ ██████╗ ███████╗███╗ ██╗███████╗██████╗ █████╗ ██╗ ║
|
|
║ ██╔════╝ ██╔════╝████╗ ██║██╔════╝██╔══██╗██╔══██╗██║ ║
|
|
║ ██║ ███╗█████╗ ██╔██╗ ██║█████╗ ██████╔╝███████║██║ ║
|
|
║ ██║ ██║██╔══╝ ██║╚██╗██║██╔══╝ ██╔══██╗██╔══██║██║ ║
|
|
║ ╚██████╔╝███████╗██║ ╚████║███████╗██║ ██║██║ ██║███████╗ ║
|
|
║ ╚═════╝ ╚══════╝╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ ║
|
|
║ ██████╗ ██████╗ ████████╗███████╗ ║
|
|
║ ██╔══██╗██╔═══██╗╚══██╔══╝██╔════╝ ║
|
|
║ ██████╔╝██║ ██║ ██║ ███████╗ ║
|
|
║ ██╔══██╗██║ ██║ ██║ ╚════██║ ║
|
|
║ ██████╔╝╚██████╔╝ ██║ ███████║ ║
|
|
║ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝ ║
|
|
║ ║
|
|
╚══════════════════════════════════════════════════════════════════╝
|
|
";
|
|
|
|
execute!(
|
|
stdout,
|
|
SetForegroundColor(Color::Green),
|
|
Print(banner),
|
|
ResetColor
|
|
)?;
|
|
|
|
execute!(
|
|
stdout,
|
|
cursor::MoveTo(20, 18),
|
|
SetForegroundColor(Color::Cyan),
|
|
Print(format!(
|
|
"Welcome to {} Setup Wizard v{}",
|
|
platform_name(),
|
|
BOTSERVER_VERSION
|
|
)),
|
|
ResetColor
|
|
)?;
|
|
|
|
execute!(
|
|
stdout,
|
|
cursor::MoveTo(20, 20),
|
|
Print("This wizard will help you configure your bot server."),
|
|
cursor::MoveTo(20, 21),
|
|
Print("You can re-run this wizard anytime with: "),
|
|
SetForegroundColor(Color::Yellow),
|
|
Print("botserver --wizard"),
|
|
ResetColor
|
|
)?;
|
|
|
|
execute!(
|
|
stdout,
|
|
cursor::MoveTo(20, 24),
|
|
SetForegroundColor(Color::DarkGrey),
|
|
Print("Press ENTER to continue..."),
|
|
ResetColor
|
|
)?;
|
|
|
|
stdout.flush()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn show_step_header(&self, stdout: &mut io::Stdout, title: &str) -> io::Result<()> {
|
|
execute!(
|
|
stdout,
|
|
terminal::Clear(ClearType::All),
|
|
cursor::MoveTo(0, 0)
|
|
)?;
|
|
|
|
let progress = format!("Step {}/{}: {}", self.current_step, self.total_steps, title);
|
|
let bar_width = 50;
|
|
let filled = (self.current_step * bar_width) / self.total_steps;
|
|
|
|
execute!(
|
|
stdout,
|
|
SetForegroundColor(Color::Cyan),
|
|
Print("╔"),
|
|
Print("═".repeat(bar_width + 2)),
|
|
Print("╗\n"),
|
|
Print("║ "),
|
|
SetForegroundColor(Color::Green),
|
|
Print("█".repeat(filled)),
|
|
SetForegroundColor(Color::DarkGrey),
|
|
Print("░".repeat(bar_width - filled)),
|
|
SetForegroundColor(Color::Cyan),
|
|
Print(" ║\n"),
|
|
Print("╚"),
|
|
Print("═".repeat(bar_width + 2)),
|
|
Print("╝"),
|
|
ResetColor
|
|
)?;
|
|
|
|
execute!(
|
|
stdout,
|
|
cursor::MoveTo(0, 4),
|
|
SetForegroundColor(Color::White),
|
|
Print(format!(" {}\n", progress)),
|
|
ResetColor,
|
|
Print("\n")
|
|
)?;
|
|
|
|
stdout.flush()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn step_install_mode(&mut self, stdout: &mut io::Stdout) -> io::Result<()> {
|
|
self.show_step_header(stdout, "Installation Mode")?;
|
|
|
|
let options = vec![
|
|
(
|
|
"Development",
|
|
"Local development with hot reload",
|
|
InstallMode::Development,
|
|
),
|
|
(
|
|
"Production",
|
|
"Optimized for production servers",
|
|
InstallMode::Production,
|
|
),
|
|
(
|
|
"Container",
|
|
"Docker/LXC container deployment",
|
|
InstallMode::Container,
|
|
),
|
|
];
|
|
|
|
let selected = self.select_option(stdout, &options, 0)?;
|
|
self.config.install_mode = options[selected].2.clone();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn step_llm_provider(&mut self, stdout: &mut io::Stdout) -> io::Result<()> {
|
|
self.show_step_header(stdout, "AI/LLM Provider")?;
|
|
|
|
execute!(
|
|
stdout,
|
|
cursor::MoveTo(2, 7),
|
|
Print("Select your preferred AI provider:"),
|
|
cursor::MoveTo(2, 8),
|
|
SetForegroundColor(Color::DarkGrey),
|
|
Print("(You can use multiple providers later)"),
|
|
ResetColor
|
|
)?;
|
|
|
|
let options = vec![
|
|
(
|
|
"Claude (Anthropic)",
|
|
"Best reasoning, 200K context - Recommended",
|
|
LlmProvider::Claude,
|
|
),
|
|
(
|
|
"GPT-4 (OpenAI)",
|
|
"Widely compatible, good all-around",
|
|
LlmProvider::OpenAI,
|
|
),
|
|
(
|
|
"Gemini (Google)",
|
|
"Great for Google Workspace integration",
|
|
LlmProvider::Gemini,
|
|
),
|
|
(
|
|
"Local Models",
|
|
"Llama, Mistral - Full privacy, no API costs",
|
|
LlmProvider::Local,
|
|
),
|
|
(
|
|
"Skip for now",
|
|
"Configure AI providers later",
|
|
LlmProvider::None,
|
|
),
|
|
];
|
|
|
|
let selected = self.select_option(stdout, &options, 0)?;
|
|
self.config.llm_provider = options[selected].2.clone();
|
|
|
|
if self.config.llm_provider != LlmProvider::Local
|
|
&& self.config.llm_provider != LlmProvider::None
|
|
{
|
|
terminal::disable_raw_mode()?;
|
|
execute!(
|
|
stdout,
|
|
cursor::MoveTo(2, 20),
|
|
Print("Enter API key (or press Enter to skip): ")
|
|
)?;
|
|
stdout.flush()?;
|
|
|
|
let mut api_key = String::new();
|
|
io::stdin().read_line(&mut api_key)?;
|
|
let api_key = api_key.trim().to_string();
|
|
|
|
if !api_key.is_empty() {
|
|
self.config.llm_api_key = Some(api_key);
|
|
}
|
|
terminal::enable_raw_mode()?;
|
|
}
|
|
|
|
if self.config.llm_provider == LlmProvider::Local {
|
|
terminal::disable_raw_mode()?;
|
|
execute!(
|
|
stdout,
|
|
cursor::MoveTo(2, 20),
|
|
Print("Enter model path (default: ./models/llama-3.1-8b): ")
|
|
)?;
|
|
stdout.flush()?;
|
|
|
|
let mut model_path = String::new();
|
|
io::stdin().read_line(&mut model_path)?;
|
|
let model_path = model_path.trim().to_string();
|
|
|
|
self.config.local_model_path = Some(if model_path.is_empty() {
|
|
"./models/llama-3.1-8b".to_string()
|
|
} else {
|
|
model_path
|
|
});
|
|
terminal::enable_raw_mode()?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn step_components(&mut self, stdout: &mut io::Stdout) -> io::Result<()> {
|
|
self.show_step_header(stdout, "Components to Install")?;
|
|
|
|
execute!(
|
|
stdout,
|
|
cursor::MoveTo(2, 7),
|
|
Print("Select components to install (Space to toggle, Enter to confirm):"),
|
|
cursor::MoveTo(2, 8),
|
|
SetForegroundColor(Color::DarkGrey),
|
|
Print("PostgreSQL and Redis are required and pre-selected"),
|
|
ResetColor
|
|
)?;
|
|
|
|
let components = vec![
|
|
(ComponentChoice::Tables, true, false),
|
|
(ComponentChoice::Cache, true, false),
|
|
(ComponentChoice::Drive, true, true),
|
|
(ComponentChoice::VectorDb, true, true),
|
|
(ComponentChoice::Email, false, true),
|
|
(ComponentChoice::Meet, false, true),
|
|
(ComponentChoice::Proxy, true, true),
|
|
(ComponentChoice::Directory, false, true),
|
|
(ComponentChoice::BotModels, false, true),
|
|
];
|
|
|
|
let selected = self.multi_select(stdout, &components)?;
|
|
self.config.components = selected;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn step_organization(&mut self, stdout: &mut io::Stdout) -> io::Result<()> {
|
|
self.show_step_header(stdout, "Organization Setup")?;
|
|
|
|
terminal::disable_raw_mode()?;
|
|
|
|
execute!(stdout, cursor::MoveTo(2, 7), Print("Organization name: "))?;
|
|
stdout.flush()?;
|
|
|
|
let mut org_name = String::new();
|
|
io::stdin().read_line(&mut org_name)?;
|
|
self.config.organization.name = org_name.trim().to_string();
|
|
|
|
self.config.organization.slug = self
|
|
.config
|
|
.organization
|
|
.name
|
|
.to_lowercase()
|
|
.replace(' ', "-")
|
|
.chars()
|
|
.filter(|c| c.is_alphanumeric() || *c == '-')
|
|
.collect();
|
|
|
|
execute!(
|
|
stdout,
|
|
cursor::MoveTo(2, 9),
|
|
Print(format!("Slug ({}): ", self.config.organization.slug))
|
|
)?;
|
|
stdout.flush()?;
|
|
|
|
let mut slug = String::new();
|
|
io::stdin().read_line(&mut slug)?;
|
|
let slug = slug.trim();
|
|
if !slug.is_empty() {
|
|
self.config.organization.slug = slug.to_string();
|
|
}
|
|
|
|
execute!(
|
|
stdout,
|
|
cursor::MoveTo(2, 11),
|
|
Print("Domain (optional, e.g., example.com): ")
|
|
)?;
|
|
stdout.flush()?;
|
|
|
|
let mut domain = String::new();
|
|
io::stdin().read_line(&mut domain)?;
|
|
let domain = domain.trim();
|
|
if !domain.is_empty() {
|
|
self.config.organization.domain = Some(domain.to_string());
|
|
}
|
|
|
|
terminal::enable_raw_mode()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn step_admin_user(&mut self, stdout: &mut io::Stdout) -> io::Result<()> {
|
|
self.show_step_header(stdout, "Admin User")?;
|
|
|
|
terminal::disable_raw_mode()?;
|
|
|
|
execute!(stdout, cursor::MoveTo(2, 7), Print("Admin username: "))?;
|
|
stdout.flush()?;
|
|
|
|
let mut username = String::new();
|
|
io::stdin().read_line(&mut username)?;
|
|
self.config.admin.username = username.trim().to_string();
|
|
|
|
execute!(stdout, cursor::MoveTo(2, 9), Print("Admin email: "))?;
|
|
stdout.flush()?;
|
|
|
|
let mut email = String::new();
|
|
io::stdin().read_line(&mut email)?;
|
|
self.config.admin.email = email.trim().to_string();
|
|
|
|
execute!(stdout, cursor::MoveTo(2, 11), Print("Admin display name: "))?;
|
|
stdout.flush()?;
|
|
|
|
let mut display_name = String::new();
|
|
io::stdin().read_line(&mut display_name)?;
|
|
self.config.admin.display_name = display_name.trim().to_string();
|
|
|
|
execute!(stdout, cursor::MoveTo(2, 13), Print("Admin password: "))?;
|
|
stdout.flush()?;
|
|
|
|
let mut password = String::new();
|
|
io::stdin().read_line(&mut password)?;
|
|
self.config.admin.password = password.trim().to_string();
|
|
|
|
terminal::enable_raw_mode()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn step_template(&mut self, stdout: &mut io::Stdout) -> io::Result<()> {
|
|
self.show_step_header(stdout, "Bot Template")?;
|
|
|
|
execute!(
|
|
stdout,
|
|
cursor::MoveTo(2, 7),
|
|
Print("Select a template for your first bot:"),
|
|
)?;
|
|
|
|
let options = vec![
|
|
("default", "Basic bot with weather, email, and tools"),
|
|
("crm", "Customer relationship management"),
|
|
("edu", "Educational/course management"),
|
|
("store", "E-commerce bot"),
|
|
("hr", "Human resources assistant"),
|
|
("healthcare", "Healthcare appointment scheduling"),
|
|
("none", "Start from scratch"),
|
|
];
|
|
|
|
let templates: Vec<(&str, &str, Option<String>)> = options
|
|
.iter()
|
|
.map(|(name, desc)| {
|
|
(
|
|
*name,
|
|
*desc,
|
|
if *name == "none" {
|
|
None
|
|
} else {
|
|
Some(name.to_string())
|
|
},
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
let selected = self.select_option(stdout, &templates, 0)?;
|
|
self.config.template = templates[selected].2.clone();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn step_summary(&mut self, stdout: &mut io::Stdout) -> io::Result<()> {
|
|
self.show_step_header(stdout, "Configuration Summary")?;
|
|
|
|
let mode = match self.config.install_mode {
|
|
InstallMode::Development => "Development",
|
|
InstallMode::Production => "Production",
|
|
InstallMode::Container => "Container",
|
|
};
|
|
|
|
let llm = match &self.config.llm_provider {
|
|
LlmProvider::Claude => "Claude (Anthropic)",
|
|
LlmProvider::OpenAI => "GPT-4 (OpenAI)",
|
|
LlmProvider::Gemini => "Gemini (Google)",
|
|
LlmProvider::Local => "Local Models",
|
|
LlmProvider::None => "Not configured",
|
|
};
|
|
|
|
execute!(
|
|
stdout,
|
|
cursor::MoveTo(2, 7),
|
|
SetForegroundColor(Color::Cyan),
|
|
Print("═══════════════════════════════════════════════════"),
|
|
ResetColor,
|
|
cursor::MoveTo(2, 9),
|
|
Print(format!(" Installation Mode: {}", mode)),
|
|
cursor::MoveTo(2, 10),
|
|
Print(format!(" LLM Provider: {}", llm)),
|
|
cursor::MoveTo(2, 11),
|
|
Print(format!(
|
|
" Organization: {}",
|
|
self.config.organization.name
|
|
)),
|
|
cursor::MoveTo(2, 12),
|
|
Print(format!(
|
|
" Admin User: {}",
|
|
self.config.admin.username
|
|
)),
|
|
cursor::MoveTo(2, 13),
|
|
Print(format!(
|
|
" Template: {}",
|
|
self.config.template.as_deref().unwrap_or("None")
|
|
)),
|
|
cursor::MoveTo(2, 14),
|
|
Print(format!(
|
|
" Components: {}",
|
|
self.config.components.len()
|
|
)),
|
|
cursor::MoveTo(2, 16),
|
|
SetForegroundColor(Color::Cyan),
|
|
Print("═══════════════════════════════════════════════════"),
|
|
ResetColor,
|
|
cursor::MoveTo(2, 18),
|
|
Print("Components to install:"),
|
|
)?;
|
|
|
|
for (i, component) in self.config.components.iter().enumerate() {
|
|
execute!(
|
|
stdout,
|
|
cursor::MoveTo(4, 19 + i as u16),
|
|
SetForegroundColor(Color::Green),
|
|
Print("* "),
|
|
ResetColor,
|
|
Print(format!("{}", component))
|
|
)?;
|
|
}
|
|
|
|
let last_line = 19 + self.config.components.len() as u16 + 2;
|
|
execute!(
|
|
stdout,
|
|
cursor::MoveTo(2, last_line),
|
|
SetForegroundColor(Color::Yellow),
|
|
Print("Press ENTER to apply configuration, or ESC to cancel"),
|
|
ResetColor
|
|
)?;
|
|
|
|
stdout.flush()?;
|
|
|
|
loop {
|
|
if let Event::Key(KeyEvent { code, .. }) = event::read()? {
|
|
match code {
|
|
KeyCode::Enter => break,
|
|
KeyCode::Esc => {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::Interrupted,
|
|
"Wizard cancelled",
|
|
));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn select_option<T: Clone>(
|
|
&self,
|
|
stdout: &mut io::Stdout,
|
|
options: &[(&str, &str, T)],
|
|
default: usize,
|
|
) -> io::Result<usize> {
|
|
let mut selected = default;
|
|
let start_row = 10;
|
|
|
|
loop {
|
|
for (i, (name, desc, _)) in options.iter().enumerate() {
|
|
execute!(stdout, cursor::MoveTo(4, start_row + i as u16))?;
|
|
|
|
if i == selected {
|
|
execute!(
|
|
stdout,
|
|
SetForegroundColor(Color::Green),
|
|
Print("> "),
|
|
Print(format!("{:<25}", name)),
|
|
SetForegroundColor(Color::DarkGrey),
|
|
Print(format!(" {}", desc)),
|
|
ResetColor
|
|
)?;
|
|
} else {
|
|
execute!(
|
|
stdout,
|
|
Print(" "),
|
|
Print(format!("{:<25}", name)),
|
|
SetForegroundColor(Color::DarkGrey),
|
|
Print(format!(" {}", desc)),
|
|
ResetColor
|
|
)?;
|
|
}
|
|
}
|
|
|
|
stdout.flush()?;
|
|
|
|
if let Event::Key(KeyEvent { code, .. }) = event::read()? {
|
|
match code {
|
|
KeyCode::Up => {
|
|
if selected > 0 {
|
|
selected -= 1;
|
|
}
|
|
}
|
|
KeyCode::Down => {
|
|
if selected < options.len() - 1 {
|
|
selected += 1;
|
|
}
|
|
}
|
|
KeyCode::Enter => break,
|
|
KeyCode::Esc => {
|
|
return Err(io::Error::new(io::ErrorKind::Interrupted, "Cancelled"));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(selected)
|
|
}
|
|
|
|
fn multi_select(
|
|
&self,
|
|
stdout: &mut io::Stdout,
|
|
options: &[(ComponentChoice, bool, bool)],
|
|
) -> io::Result<Vec<ComponentChoice>> {
|
|
let mut selected: Vec<bool> = options.iter().map(|(_, s, _)| *s).collect();
|
|
let mut cursor = 0;
|
|
let start_row = 10;
|
|
|
|
loop {
|
|
for (i, (component, _, can_toggle)) in options.iter().enumerate() {
|
|
execute!(stdout, cursor::MoveTo(4, start_row + i as u16))?;
|
|
|
|
let checkbox = if selected[i] { "[*]" } else { "[ ]" };
|
|
let prefix = if i == cursor { ">" } else { " " };
|
|
|
|
if !can_toggle {
|
|
execute!(
|
|
stdout,
|
|
SetForegroundColor(Color::DarkGrey),
|
|
Print(format!("{} {} {} (required)", prefix, checkbox, component)),
|
|
ResetColor
|
|
)?;
|
|
} else if i == cursor {
|
|
execute!(
|
|
stdout,
|
|
SetForegroundColor(Color::Green),
|
|
Print(format!("{} {} {}", prefix, checkbox, component)),
|
|
ResetColor
|
|
)?;
|
|
} else {
|
|
execute!(
|
|
stdout,
|
|
Print(format!("{} {} {}", prefix, checkbox, component)),
|
|
)?;
|
|
}
|
|
}
|
|
|
|
execute!(
|
|
stdout,
|
|
cursor::MoveTo(4, start_row + options.len() as u16 + 2),
|
|
SetForegroundColor(Color::DarkGrey),
|
|
Print("Use ↑↓ to navigate, SPACE to toggle, ENTER to confirm"),
|
|
ResetColor
|
|
)?;
|
|
|
|
stdout.flush()?;
|
|
|
|
if let Event::Key(KeyEvent { code, .. }) = event::read()? {
|
|
match code {
|
|
KeyCode::Up => {
|
|
if cursor > 0 {
|
|
cursor -= 1;
|
|
}
|
|
}
|
|
KeyCode::Down => {
|
|
if cursor < options.len() - 1 {
|
|
cursor += 1;
|
|
}
|
|
}
|
|
KeyCode::Char(' ') => {
|
|
if options[cursor].2 {
|
|
selected[cursor] = !selected[cursor];
|
|
}
|
|
}
|
|
KeyCode::Enter => break,
|
|
KeyCode::Esc => {
|
|
return Err(io::Error::new(io::ErrorKind::Interrupted, "Cancelled"));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(options
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(i, _)| selected[*i])
|
|
.map(|(_, (c, _, _))| c.clone())
|
|
.collect())
|
|
}
|
|
|
|
fn wait_for_enter(&self) -> io::Result<()> {
|
|
loop {
|
|
if let Event::Key(KeyEvent { code, .. }) = event::read()? {
|
|
if code == KeyCode::Enter {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub fn save_wizard_config(config: &WizardConfig, path: &str) -> io::Result<()> {
|
|
let content = toml::to_string_pretty(config)
|
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
|
std::fs::write(path, content)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn load_wizard_config(path: &str) -> io::Result<WizardConfig> {
|
|
let content = std::fs::read_to_string(path)?;
|
|
let config: WizardConfig =
|
|
toml::from_str(&content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
|
Ok(config)
|
|
}
|
|
|
|
pub fn should_run_wizard() -> bool {
|
|
!std::path::Path::new("./botserver-stack").exists()
|
|
&& !std::path::Path::new("/opt/gbo").exists()
|
|
}
|
|
|
|
pub fn apply_wizard_config(config: &WizardConfig) -> io::Result<()> {
|
|
use std::fs;
|
|
|
|
fs::create_dir_all(&config.data_dir)?;
|
|
|
|
let subdirs = ["bots", "logs", "cache", "uploads", "config"];
|
|
for subdir in &subdirs {
|
|
fs::create_dir_all(config.data_dir.join(subdir))?;
|
|
}
|
|
|
|
save_wizard_config(
|
|
config,
|
|
&config.data_dir.join("config/wizard.toml").to_string_lossy(),
|
|
)?;
|
|
|
|
let mut env_content = String::new();
|
|
env_content.push_str(&format!(
|
|
"# Generated by {} Setup Wizard\n\n",
|
|
platform_name()
|
|
));
|
|
env_content.push_str(&format!("INSTALL_MODE={:?}\n", config.install_mode));
|
|
env_content.push_str(&format!("ORG_NAME={}\n", config.organization.name));
|
|
env_content.push_str(&format!("ORG_SLUG={}\n", config.organization.slug));
|
|
|
|
if let Some(domain) = &config.organization.domain {
|
|
env_content.push_str(&format!("DOMAIN={}\n", domain));
|
|
}
|
|
|
|
match &config.llm_provider {
|
|
LlmProvider::Claude => env_content.push_str("LLM_PROVIDER=anthropic\n"),
|
|
LlmProvider::OpenAI => env_content.push_str("LLM_PROVIDER=openai\n"),
|
|
LlmProvider::Gemini => env_content.push_str("LLM_PROVIDER=google\n"),
|
|
LlmProvider::Local => env_content.push_str("LLM_PROVIDER=local\n"),
|
|
LlmProvider::None => {}
|
|
}
|
|
|
|
if let Some(api_key) = &config.llm_api_key {
|
|
env_content.push_str(&format!("LLM_API_KEY={}\n", api_key));
|
|
}
|
|
|
|
if let Some(model_path) = &config.local_model_path {
|
|
env_content.push_str(&format!("LOCAL_MODEL_PATH={}\n", model_path));
|
|
}
|
|
|
|
fs::write(config.data_dir.join(".env"), env_content)?;
|
|
|
|
println!("\n Configuration applied successfully!");
|
|
println!(" Data directory: {}", config.data_dir.display());
|
|
println!("\n Next steps:");
|
|
println!(" 1. Run: botserver start");
|
|
println!(" 2. Open: http://localhost:4242");
|
|
println!(" 3. Login with: {}", config.admin.username);
|
|
|
|
Ok(())
|
|
}
|