use super::types::*;
pub struct OnboardingWizard {
pub step: OnboardingStep,
pub mode: WizardMode,
pub ps: crate::tui::provider_selector::ProviderSelectorState,
pub auth_field: AuthField,
pub workspace_path: String,
pub seed_templates: bool,
pub channel_toggles: Vec<(String, bool)>,
pub telegram_field: TelegramField,
pub telegram_token_input: String,
pub telegram_user_id_input: String,
pub discord_field: DiscordField,
pub discord_token_input: String,
pub discord_channel_id_input: String,
pub discord_allowed_list_input: String,
pub telegram_respond_to: usize,
pub discord_respond_to: usize,
pub slack_respond_to: usize,
pub whatsapp_field: WhatsAppField,
pub whatsapp_qr_text: Option<String>,
pub whatsapp_connecting: bool,
pub whatsapp_connected: bool,
pub whatsapp_error: Option<String>,
pub whatsapp_phone_input: String,
pub slack_field: SlackField,
pub slack_bot_token_input: String,
pub slack_app_token_input: String,
pub slack_channel_id_input: String,
pub slack_allowed_list_input: String,
pub trello_field: TrelloField,
pub trello_api_key_input: String,
pub trello_api_token_input: String,
pub trello_board_id_input: String,
pub trello_allowed_users_input: String,
pub channel_input_cursor: usize,
pub channel_test_status: ChannelTestStatus,
pub voice_field: VoiceField,
pub stt_provider: SttProvider,
pub groq_api_key_input: String,
pub selected_local_stt_model: usize,
pub stt_model_download_progress: Option<f64>,
pub stt_model_download_error: Option<String>,
pub stt_model_downloaded: bool,
pub tts_enabled: bool,
pub tts_provider: TtsProvider,
pub selected_tts_voice: usize,
pub tts_voice_download_progress: Option<f64>,
pub tts_voice_download_error: Option<String>,
pub tts_voice_downloaded: bool,
pub stt_openai_compat_base_url: String,
pub stt_openai_compat_model: String,
pub stt_openai_compat_key_input: String,
pub stt_voicebox_base_url: String,
pub tts_openai_compat_base_url: String,
pub tts_openai_compat_model: String,
pub tts_openai_compat_voice: String,
pub tts_openai_compat_key_input: String,
pub tts_voicebox_base_url: String,
pub tts_voicebox_profile_id: String,
pub tts_voicebox_engine: String,
pub image_field: ImageField,
pub image_vision_enabled: bool,
pub image_generation_enabled: bool,
pub image_generation_model_input: String,
pub image_api_key_input: String,
pub install_daemon: bool,
pub health_results: Vec<(String, HealthStatus)>,
pub health_running: bool,
pub health_complete: bool,
pub brain_field: BrainField,
pub about_me: String,
pub about_opencrabs: String,
pub original_about_me: String,
pub original_about_opencrabs: String,
pub brain_me_edited: bool,
pub brain_agent_edited: bool,
pub brain_generating: bool,
pub brain_generated: bool,
pub brain_error: Option<String>,
pub preview_shown: bool,
pub formatted_about_me: String,
pub formatted_about_agent: String,
pub generated_soul: Option<String>,
pub generated_identity: Option<String>,
pub generated_user: Option<String>,
pub generated_agents: Option<String>,
pub generated_tools: Option<String>,
pub generated_memory: Option<String>,
pub github_user_code: Option<String>,
pub github_device_flow_status: GitHubDeviceFlowStatus,
pub focused_field: usize,
pub error_message: Option<String>,
pub quick_jump: bool,
pub quick_jump_done: bool,
pub is_first_time: bool,
}
impl Default for OnboardingWizard {
fn default() -> Self {
Self::new()
}
}
impl OnboardingWizard {
pub fn new() -> Self {
let default_workspace = crate::config::opencrabs_home();
let config_models = Vec::new();
let existing_config = crate::config::Config::load().ok();
let mut custom_provider_name_init: Option<String> = None;
let (selected_provider, api_key_input, custom_base_url, custom_model) =
if let Some(ref config) = existing_config {
if config
.providers
.anthropic
.as_ref()
.is_some_and(|p| p.enabled)
{
(
0,
EXISTING_KEY_SENTINEL.to_string(),
String::new(),
String::new(),
)
} else if config.providers.openai.as_ref().is_some_and(|p| p.enabled) {
(
1,
EXISTING_KEY_SENTINEL.to_string(),
String::new(),
String::new(),
)
} else if config.providers.github.as_ref().is_some_and(|p| p.enabled) {
(
2,
EXISTING_KEY_SENTINEL.to_string(),
String::new(),
String::new(),
)
} else if config.providers.gemini.as_ref().is_some_and(|p| p.enabled) {
(
3,
EXISTING_KEY_SENTINEL.to_string(),
String::new(),
String::new(),
)
} else if config
.providers
.openrouter
.as_ref()
.is_some_and(|p| p.enabled)
{
(
4,
EXISTING_KEY_SENTINEL.to_string(),
String::new(),
String::new(),
)
} else if config.providers.minimax.as_ref().is_some_and(|p| p.enabled) {
(
5,
EXISTING_KEY_SENTINEL.to_string(),
String::new(),
String::new(),
)
} else if config.providers.zhipu.as_ref().is_some_and(|p| p.enabled) {
(
6,
EXISTING_KEY_SENTINEL.to_string(),
String::new(),
String::new(),
)
} else if config
.providers
.claude_cli
.as_ref()
.is_some_and(|p| p.enabled)
{
(
crate::tui::provider_selector::index_of_provider("claude-cli").unwrap_or(0),
String::new(),
String::new(),
String::new(),
)
} else if config
.providers
.opencode_cli
.as_ref()
.is_some_and(|p| p.enabled)
{
(
crate::tui::provider_selector::index_of_provider("opencode-cli")
.unwrap_or(0),
String::new(),
String::new(),
String::new(),
)
} else if config
.providers
.codex_cli
.as_ref()
.is_some_and(|p| p.enabled)
{
(
crate::tui::provider_selector::index_of_provider("codex-cli").unwrap_or(0),
String::new(),
String::new(),
String::new(),
)
} else if config.providers.qwen.as_ref().is_some_and(|p| p.enabled) {
(
crate::tui::provider_selector::index_of_provider("qwen").unwrap_or(0),
EXISTING_KEY_SENTINEL.to_string(),
String::new(),
String::new(),
)
} else if let Some((name, c)) = config.providers.active_custom().or_else(|| {
config
.providers
.custom
.as_ref()
.and_then(|m| m.iter().next())
.map(|(n, c)| (n.as_str(), c))
}) {
let base = c.base_url.clone().unwrap_or_default();
let model = c.default_model.clone().unwrap_or_default();
custom_provider_name_init = Some(name.to_string());
use crate::tui::provider_selector::{
CUSTOM_INSTANCES_START, CUSTOM_PROVIDER_IDX,
};
let idx = config
.providers
.custom
.as_ref()
.and_then(|m| {
m.keys()
.position(|k| k == name)
.map(|pos| CUSTOM_INSTANCES_START + pos)
})
.unwrap_or(CUSTOM_PROVIDER_IDX);
(idx, EXISTING_KEY_SENTINEL.to_string(), base, model)
} else {
(0, String::new(), String::new(), String::new())
}
} else {
(0, String::new(), String::new(), String::new())
};
let ps = crate::tui::provider_selector::ProviderSelectorState {
selected_provider,
api_key_input,
api_key_cursor: 0,
selected_model: 0,
custom_name: custom_provider_name_init.unwrap_or_default(),
base_url: custom_base_url,
custom_model,
context_window: String::new(),
models: Vec::new(),
models_fetching: false,
config_models,
custom_names: existing_config
.as_ref()
.and_then(|c| c.providers.custom.as_ref())
.map(|m| m.keys().cloned().collect())
.unwrap_or_default(),
zhipu_endpoint_type: 0, model_filter: String::new(),
..Default::default()
};
let mut wizard = Self {
step: OnboardingStep::ModeSelect,
mode: WizardMode::QuickStart,
ps,
auth_field: AuthField::Provider,
workspace_path: default_workspace.to_string_lossy().to_string(),
seed_templates: true,
channel_toggles: CHANNEL_NAMES
.iter()
.map(|(name, _desc)| (name.to_string(), false))
.collect(),
telegram_field: TelegramField::BotToken,
telegram_token_input: String::new(),
telegram_user_id_input: String::new(),
discord_field: DiscordField::BotToken,
discord_token_input: String::new(),
discord_channel_id_input: String::new(),
discord_allowed_list_input: String::new(),
telegram_respond_to: 0, discord_respond_to: 2, slack_respond_to: 2,
whatsapp_field: WhatsAppField::Connection,
whatsapp_qr_text: None,
whatsapp_connecting: false,
whatsapp_connected: false,
whatsapp_error: None,
whatsapp_phone_input: String::new(),
slack_field: SlackField::BotToken,
slack_bot_token_input: String::new(),
slack_app_token_input: String::new(),
slack_channel_id_input: String::new(),
slack_allowed_list_input: String::new(),
trello_field: TrelloField::ApiKey,
trello_api_key_input: String::new(),
trello_api_token_input: String::new(),
trello_board_id_input: String::new(),
trello_allowed_users_input: String::new(),
channel_input_cursor: 0,
channel_test_status: ChannelTestStatus::Idle,
voice_field: VoiceField::SttModeSelect,
stt_provider: SttProvider::Off,
groq_api_key_input: String::new(),
selected_local_stt_model: 0,
stt_model_download_progress: None,
stt_model_download_error: None,
stt_model_downloaded: false,
tts_enabled: false,
tts_provider: TtsProvider::Off,
selected_tts_voice: 0,
tts_voice_download_progress: None,
tts_voice_download_error: None,
tts_voice_downloaded: false,
stt_openai_compat_base_url: String::new(),
stt_openai_compat_model: "whisper-1".to_string(),
stt_openai_compat_key_input: String::new(),
stt_voicebox_base_url: "http://localhost:8000".to_string(),
tts_openai_compat_base_url: String::new(),
tts_openai_compat_model: "tts-1".to_string(),
tts_openai_compat_voice: "alloy".to_string(),
tts_openai_compat_key_input: String::new(),
tts_voicebox_base_url: String::new(),
tts_voicebox_profile_id: String::new(),
tts_voicebox_engine: String::new(),
image_field: ImageField::VisionToggle,
image_vision_enabled: false,
image_generation_enabled: false,
image_generation_model_input: String::new(),
image_api_key_input: String::new(),
install_daemon: false,
health_results: Vec::new(),
health_running: false,
health_complete: false,
brain_field: BrainField::AboutMe,
about_me: String::new(),
about_opencrabs: String::new(),
original_about_me: String::new(),
original_about_opencrabs: String::new(),
brain_me_edited: false,
brain_agent_edited: false,
brain_generating: false,
brain_generated: false,
brain_error: None,
preview_shown: false,
formatted_about_me: String::new(),
formatted_about_agent: String::new(),
generated_soul: None,
generated_identity: None,
generated_user: None,
generated_agents: None,
generated_tools: None,
generated_memory: None,
github_user_code: None,
github_device_flow_status: GitHubDeviceFlowStatus::Idle,
focused_field: 0,
error_message: None,
quick_jump: false,
quick_jump_done: false,
is_first_time: false,
};
let workspace = std::path::Path::new(&wizard.workspace_path);
if let Ok(content) = std::fs::read_to_string(workspace.join("USER.md")) {
let truncated = Self::truncate_preview(&content, 200);
wizard.about_me = truncated.clone();
wizard.original_about_me = truncated;
}
if let Ok(content) = std::fs::read_to_string(workspace.join("IDENTITY.md")) {
let truncated = Self::truncate_preview(&content, 200);
wizard.about_opencrabs = truncated.clone();
wizard.original_about_opencrabs = truncated;
}
wizard
}
pub fn from_config(config: &crate::config::Config) -> Self {
let mut wizard = Self::new();
use crate::tui::provider_selector::index_of_provider;
let resolve = |id: &str| index_of_provider(id).unwrap_or(0);
if config
.providers
.anthropic
.as_ref()
.is_some_and(|p| p.enabled)
{
wizard.ps.selected_provider = resolve("anthropic");
if let Some(model) = &config
.providers
.anthropic
.as_ref()
.and_then(|p| p.default_model.clone())
{
wizard.ps.custom_model = model.clone();
}
} else if config.providers.openai.as_ref().is_some_and(|p| p.enabled) {
wizard.ps.selected_provider = resolve("openai");
if let Some(base_url) = &config
.providers
.openai
.as_ref()
.and_then(|p| p.base_url.clone())
{
wizard.ps.base_url = base_url.clone();
}
if let Some(model) = &config
.providers
.openai
.as_ref()
.and_then(|p| p.default_model.clone())
{
wizard.ps.custom_model = model.clone();
}
} else if config.providers.github.as_ref().is_some_and(|p| p.enabled) {
wizard.ps.selected_provider = resolve("github");
if let Some(model) = &config
.providers
.github
.as_ref()
.and_then(|p| p.default_model.clone())
{
wizard.ps.custom_model = model.clone();
}
} else if config.providers.gemini.as_ref().is_some_and(|p| p.enabled) {
wizard.ps.selected_provider = resolve("gemini");
} else if config
.providers
.openrouter
.as_ref()
.is_some_and(|p| p.enabled)
{
wizard.ps.selected_provider = resolve("openrouter");
if let Some(model) = &config
.providers
.openrouter
.as_ref()
.and_then(|p| p.default_model.clone())
{
wizard.ps.custom_model = model.clone();
}
} else if config.providers.minimax.as_ref().is_some_and(|p| p.enabled) {
wizard.ps.selected_provider = resolve("minimax");
if let Some(model) = &config
.providers
.minimax
.as_ref()
.and_then(|p| p.default_model.clone())
{
wizard.ps.custom_model = model.clone();
}
} else if config.providers.zhipu.as_ref().is_some_and(|p| p.enabled) {
wizard.ps.selected_provider = resolve("zhipu");
if let Some(model) = &config
.providers
.zhipu
.as_ref()
.and_then(|p| p.default_model.clone())
{
wizard.ps.custom_model = model.clone();
}
} else if config
.providers
.claude_cli
.as_ref()
.is_some_and(|p| p.enabled)
{
wizard.ps.selected_provider = resolve("claude-cli");
if let Some(model) = &config
.providers
.claude_cli
.as_ref()
.and_then(|p| p.default_model.clone())
{
wizard.ps.custom_model = model.clone();
}
} else if config
.providers
.opencode_cli
.as_ref()
.is_some_and(|p| p.enabled)
{
wizard.ps.selected_provider = resolve("opencode-cli");
if let Some(model) = &config
.providers
.opencode_cli
.as_ref()
.and_then(|p| p.default_model.clone())
{
wizard.ps.custom_model = model.clone();
}
} else if config
.providers
.codex_cli
.as_ref()
.is_some_and(|p| p.enabled)
{
wizard.ps.selected_provider = resolve("codex-cli");
if let Some(model) = &config
.providers
.codex_cli
.as_ref()
.and_then(|p| p.default_model.clone())
{
wizard.ps.custom_model = model.clone();
}
}
wizard.ps.detect_existing_key();
wizard.ps.reload_config_models();
wizard.ps.resolve_selected_model_index();
wizard.channel_toggles[0].1 = config.channels.telegram.enabled; wizard.channel_toggles[1].1 = config.channels.discord.enabled; wizard.channel_toggles[2].1 = config.channels.whatsapp.enabled; wizard.channel_toggles[3].1 = config.channels.slack.enabled; wizard.channel_toggles[4].1 = config.channels.trello.enabled;
use crate::config::RespondTo;
wizard.telegram_respond_to = match config.channels.telegram.respond_to {
RespondTo::All => 0,
RespondTo::DmOnly => 1,
RespondTo::Mention => 2,
};
wizard.discord_respond_to = match config.channels.discord.respond_to {
RespondTo::All => 0,
RespondTo::DmOnly => 1,
RespondTo::Mention => 2,
};
wizard.slack_respond_to = match config.channels.slack.respond_to {
RespondTo::All => 0,
RespondTo::DmOnly => 1,
RespondTo::Mention => 2,
};
let vc = config.voice_config();
wizard.stt_provider = if !vc.stt_enabled {
SttProvider::Off
} else {
match vc.stt_mode {
crate::config::SttMode::Api => SttProvider::Groq,
crate::config::SttMode::Local => SttProvider::Local,
}
};
wizard.tts_enabled = vc.tts_enabled;
wizard.tts_provider = if !vc.tts_enabled {
TtsProvider::Off
} else {
match vc.tts_mode {
crate::config::TtsMode::Api => TtsProvider::OpenAi,
crate::config::TtsMode::Local => TtsProvider::Local,
}
};
if wizard.stt_provider == SttProvider::Local
&& !crate::channels::voice::local_stt_available()
{
wizard.stt_provider = SttProvider::Off;
}
if wizard.tts_provider == TtsProvider::Local
&& !crate::channels::voice::local_tts_available()
{
wizard.tts_provider = TtsProvider::Off;
wizard.tts_enabled = false;
}
wizard.detect_existing_groq_key();
if let Some(stt) = config.providers.stt.as_ref() {
if stt.voicebox.as_ref().is_some_and(|v| v.enabled) {
wizard.stt_provider = SttProvider::Voicebox;
wizard.stt_voicebox_base_url = stt.voicebox.as_ref().unwrap().base_url.clone();
} else if stt.openai_compatible.as_ref().is_some_and(|v| v.enabled) {
wizard.stt_provider = SttProvider::OpenAiCompatible;
let oc = stt.openai_compatible.as_ref().unwrap();
wizard.stt_openai_compat_base_url = oc.base_url.clone().unwrap_or_default();
wizard.stt_openai_compat_model =
oc.model.clone().unwrap_or_else(|| "whisper-1".to_string());
wizard.stt_openai_compat_key_input =
super::types::EXISTING_KEY_SENTINEL.to_string();
} else if stt.local.as_ref().is_some_and(|l| l.enabled) {
wizard.stt_provider = SttProvider::Local;
} else if stt.groq.as_ref().is_some_and(|g| g.enabled) {
wizard.stt_provider = SttProvider::Groq;
} else {
wizard.stt_provider = SttProvider::Off;
}
}
if let Some(tts) = config.providers.tts.as_ref() {
if tts.voicebox.as_ref().is_some_and(|v| v.enabled) {
wizard.tts_provider = TtsProvider::Voicebox;
wizard.tts_enabled = true;
let vb = tts.voicebox.as_ref().unwrap();
wizard.tts_voicebox_base_url = vb.base_url.clone();
wizard.tts_voicebox_profile_id = vb.profile_id.clone();
wizard.tts_voicebox_engine = vb.engine.clone();
wizard.tts_voicebox_engine = vb.engine.clone();
} else if tts.openai_compatible.as_ref().is_some_and(|v| v.enabled) {
wizard.tts_provider = TtsProvider::OpenAiCompatible;
wizard.tts_enabled = true;
let oc = tts.openai_compatible.as_ref().unwrap();
wizard.tts_openai_compat_base_url = oc.base_url.clone().unwrap_or_default();
wizard.tts_openai_compat_model =
oc.model.clone().unwrap_or_else(|| "tts-1".to_string());
wizard.tts_openai_compat_voice =
oc.voice.clone().unwrap_or_else(|| "alloy".to_string());
wizard.tts_openai_compat_key_input =
super::types::EXISTING_KEY_SENTINEL.to_string();
} else if tts.local.as_ref().is_some_and(|l| l.enabled) {
wizard.tts_provider = TtsProvider::Local;
wizard.tts_enabled = true;
} else if tts.openai.as_ref().is_some_and(|o| o.enabled) {
wizard.tts_provider = TtsProvider::OpenAi;
wizard.tts_enabled = true;
} else {
wizard.tts_provider = TtsProvider::Off;
wizard.tts_enabled = false;
}
}
if wizard.stt_provider == SttProvider::Local
&& !crate::channels::voice::local_stt_available()
{
wizard.stt_provider = SttProvider::Off;
}
if wizard.tts_provider == TtsProvider::Local
&& !crate::channels::voice::local_tts_available()
{
wizard.tts_provider = TtsProvider::Off;
wizard.tts_enabled = false;
}
#[cfg(feature = "local-tts")]
{
use crate::channels::voice::local_tts::{PIPER_VOICES, piper_voice_exists};
if let Some(idx) = PIPER_VOICES.iter().position(|v| v.id == vc.local_tts_voice) {
wizard.selected_tts_voice = idx;
wizard.tts_voice_downloaded = piper_voice_exists(PIPER_VOICES[idx].id);
}
}
#[cfg(feature = "local-stt")]
{
use crate::channels::voice::local_whisper::{LOCAL_MODEL_PRESETS, is_model_downloaded};
if let Some(idx) = LOCAL_MODEL_PRESETS
.iter()
.position(|p| p.id == vc.local_stt_model)
{
wizard.selected_local_stt_model = idx;
wizard.stt_model_downloaded = is_model_downloaded(&LOCAL_MODEL_PRESETS[idx]);
}
}
wizard.image_vision_enabled = config.image.vision.enabled;
wizard.image_generation_enabled = config.image.generation.enabled;
wizard.image_generation_model_input =
if config.image.generation.model == crate::config::default_image_model() {
String::new()
} else {
config.image.generation.model.clone()
};
wizard.detect_existing_image_key();
use super::types::EXISTING_KEY_SENTINEL;
let sentinel = || EXISTING_KEY_SENTINEL.to_string();
if config
.channels
.telegram
.token
.as_ref()
.is_some_and(|t| !t.is_empty())
{
wizard.telegram_token_input = sentinel();
}
if let Some(first) = config.channels.telegram.allowed_users.first() {
wizard.telegram_user_id_input = first.clone();
}
if config
.channels
.discord
.token
.as_ref()
.is_some_and(|t| !t.is_empty())
{
wizard.discord_token_input = sentinel();
}
if !config.channels.discord.allowed_channels.is_empty() {
wizard.discord_channel_id_input = sentinel();
}
if !config.channels.discord.allowed_users.is_empty() {
wizard.discord_allowed_list_input = sentinel();
}
if config
.channels
.slack
.token
.as_ref()
.is_some_and(|t| !t.is_empty())
{
wizard.slack_bot_token_input = sentinel();
}
if config
.channels
.slack
.app_token
.as_ref()
.is_some_and(|t| !t.is_empty())
{
wizard.slack_app_token_input = sentinel();
}
if !config.channels.slack.allowed_channels.is_empty() {
wizard.slack_channel_id_input = sentinel();
}
if !config.channels.slack.allowed_users.is_empty() {
wizard.slack_allowed_list_input = sentinel();
}
if config
.channels
.trello
.app_token
.as_ref()
.is_some_and(|t| !t.is_empty())
{
wizard.trello_api_key_input = sentinel();
}
if config
.channels
.trello
.token
.as_ref()
.is_some_and(|t| !t.is_empty())
{
wizard.trello_api_token_input = sentinel();
}
if !config.channels.trello.board_ids.is_empty() {
wizard.trello_board_id_input = sentinel();
}
if !config.channels.trello.allowed_users.is_empty() {
wizard.trello_allowed_users_input = sentinel();
}
if !config.channels.whatsapp.allowed_phones.is_empty() {
wizard.whatsapp_phone_input = sentinel();
}
let wa_session = crate::config::opencrabs_home()
.join("whatsapp")
.join("session.db");
wizard.whatsapp_connected = wa_session.exists();
wizard.step = OnboardingStep::ProviderAuth;
wizard.auth_field = AuthField::Provider;
wizard
}
}