P0 deep-dive: core, providers, agent, memory, TUI, setup wizard
DEEP DIVE — Agent Loop (gyandoot-agent): - Real conversation loop with interactive (run()) and non-interactive (run_conversation()) modes - Retry logic with exponential backoff and error classification (RateLimit, Auth, Timeout, ServerError, ContextOverflow) - Context window management — token estimation, 75% threshold compression - 7 built-in tools: Read, Write, Edit, Bash, Grep, Find, Ls - BashTool with destructive-command security blocks - ContextCompressor — prune tool results, summarize middle messages DEEP DIVE — Memory (gyandoot-memory): - Real MemPalace with ~/.gyandoot/palace/wings/<wing>/ directory tree - Daily transcript files (YYYY-MM-DD_transcript.md) with append-only format - L0/L1 identity persistence (< 157 tokens for L0) - Session digest to drawer.md - File persistence support for transcripts and identity store DEEP DIVE — Core (gyandoot-core): - GyandootConfig with TOML deserialization for all sections - ConfigLoader with env > profile > file > default chain - Rich types: Message, ToolCall, ToolResult, SessionId - Comprehensive error types with thiserror derive DEEP DIVE — Providers (gyandoot-providers): - Provider trait with chat/chat_stream/models - OpenAI provider with streaming SSE and function calling - Anthropic provider with Messages API + tool use - Ollama provider with merge_system/no_tools auto-detect - SseParser, StreamEvent, TokenStream for SSE parsing - ProviderFactory for creation from config NEW — TUI crate (gyandoot-tui): - Banner: Kapil-approved robot head ASCII art with ANSI colors - Setup wizard: 7 providers, interactive config, connection test - Terminal helpers: spinner, confirm, text input, selection menu - Ratatui TUI: keyboard input, commands, status display All 12 crates + CLI binary compile with 0 errors.
This commit is contained in:
510
crates/gyandoot-tui/src/setup.rs
Normal file
510
crates/gyandoot-tui/src/setup.rs
Normal file
@@ -0,0 +1,510 @@
|
||||
//! Interactive setup wizard — configures providers and initializes GyanDoot.
|
||||
//!
|
||||
//! Flow:
|
||||
//! 1. Print compact banner header
|
||||
//! 2. Provider selection (numbered menu)
|
||||
//! 3. Per-provider config (Base URL, API Key, Model, Temperature, Max Tokens)
|
||||
//! 4. Summary display before saving
|
||||
//! 5. Connection test (/models endpoint)
|
||||
//! 6. Write config.toml to ~/.config/gyandoot/config.toml
|
||||
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::banner;
|
||||
use crate::terminal;
|
||||
|
||||
/// Supported LLM providers.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ProviderKind {
|
||||
OpenAI,
|
||||
Anthropic,
|
||||
Ollama,
|
||||
GoogleGemini,
|
||||
DeepSeek,
|
||||
OpenRouter,
|
||||
Groq,
|
||||
}
|
||||
|
||||
impl ProviderKind {
|
||||
/// All known providers in display order.
|
||||
pub fn all() -> &'static [ProviderKind] {
|
||||
&[
|
||||
ProviderKind::OpenAI,
|
||||
ProviderKind::Anthropic,
|
||||
ProviderKind::Ollama,
|
||||
ProviderKind::GoogleGemini,
|
||||
ProviderKind::DeepSeek,
|
||||
ProviderKind::OpenRouter,
|
||||
ProviderKind::Groq,
|
||||
]
|
||||
}
|
||||
|
||||
/// Display name for menus.
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
ProviderKind::OpenAI => "OpenAI",
|
||||
ProviderKind::Anthropic => "Anthropic",
|
||||
ProviderKind::Ollama => "Ollama",
|
||||
ProviderKind::GoogleGemini => "Google Gemini",
|
||||
ProviderKind::DeepSeek => "DeepSeek",
|
||||
ProviderKind::OpenRouter => "OpenRouter",
|
||||
ProviderKind::Groq => "Groq",
|
||||
}
|
||||
}
|
||||
|
||||
/// Default base URL for the provider.
|
||||
pub fn default_base_url(&self) -> &'static str {
|
||||
match self {
|
||||
ProviderKind::OpenAI => "https://api.openai.com/v1",
|
||||
ProviderKind::Anthropic => "https://api.anthropic.com/v1",
|
||||
ProviderKind::Ollama => "http://localhost:11434",
|
||||
ProviderKind::GoogleGemini => "https://generativelanguage.googleapis.com/v1",
|
||||
ProviderKind::DeepSeek => "https://api.deepseek.com/v1",
|
||||
ProviderKind::OpenRouter => "https://openrouter.ai/api/v1",
|
||||
ProviderKind::Groq => "https://api.groq.com/openai/v1",
|
||||
}
|
||||
}
|
||||
|
||||
/// Default model name.
|
||||
pub fn default_model(&self) -> &'static str {
|
||||
match self {
|
||||
ProviderKind::OpenAI => "gpt-4o",
|
||||
ProviderKind::Anthropic => "claude-sonnet-4-20250514",
|
||||
ProviderKind::Ollama => "llama3",
|
||||
ProviderKind::GoogleGemini => "gemini-2.0-flash",
|
||||
ProviderKind::DeepSeek => "deepseek-chat",
|
||||
ProviderKind::OpenRouter => "openai/gpt-4o",
|
||||
ProviderKind::Groq => "llama3-70b-8192",
|
||||
}
|
||||
}
|
||||
|
||||
/// Default temperature.
|
||||
pub fn default_temperature(&self) -> f64 {
|
||||
0.7
|
||||
}
|
||||
|
||||
/// Default max tokens.
|
||||
pub fn default_max_tokens(&self) -> u32 {
|
||||
4096
|
||||
}
|
||||
|
||||
/// Provider-specific API key env var hint.
|
||||
pub fn api_key_env_hint(&self) -> &'static str {
|
||||
match self {
|
||||
ProviderKind::OpenAI => "OPENAI_API_KEY",
|
||||
ProviderKind::Anthropic => "ANTHROPIC_API_KEY",
|
||||
ProviderKind::Ollama => "OLLAMA_API_KEY (optional)",
|
||||
ProviderKind::GoogleGemini => "GEMINI_API_KEY",
|
||||
ProviderKind::DeepSeek => "DEEPSEEK_API_KEY",
|
||||
ProviderKind::OpenRouter => "OPENROUTER_API_KEY",
|
||||
ProviderKind::Groq => "GROQ_API_KEY",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ProviderKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.display_name())
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-provider configuration collected during setup.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
/// Which provider this config is for.
|
||||
pub kind: ProviderKind,
|
||||
/// API base URL.
|
||||
pub base_url: String,
|
||||
/// API key (may be empty for local Ollama).
|
||||
pub api_key: String,
|
||||
/// Model identifier.
|
||||
pub model: String,
|
||||
/// Temperature parameter (0.0–2.0).
|
||||
pub temperature: f64,
|
||||
/// Max tokens per response.
|
||||
pub max_tokens: u32,
|
||||
/// Ollama-specific: merge system messages.
|
||||
#[serde(default)]
|
||||
pub merge_system: bool,
|
||||
/// Ollama-specific: disable tool calling.
|
||||
#[serde(default)]
|
||||
pub no_tools: bool,
|
||||
}
|
||||
|
||||
impl ProviderConfig {
|
||||
/// Create a new provider config with defaults.
|
||||
pub fn new(kind: ProviderKind) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
base_url: kind.default_base_url().to_string(),
|
||||
api_key: String::new(),
|
||||
model: kind.default_model().to_string(),
|
||||
temperature: kind.default_temperature(),
|
||||
max_tokens: kind.default_max_tokens(),
|
||||
merge_system: false,
|
||||
no_tools: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of the setup wizard.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct WizardResult {
|
||||
/// Selected provider kind.
|
||||
pub provider: ProviderKind,
|
||||
/// Full provider config.
|
||||
pub config: ProviderConfig,
|
||||
/// Whether the connection test passed.
|
||||
pub connection_ok: bool,
|
||||
/// Path where config was written.
|
||||
pub config_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Default config directory.
|
||||
fn config_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".config")
|
||||
.join("gyandoot")
|
||||
}
|
||||
|
||||
/// Run the full interactive setup wizard.
|
||||
///
|
||||
/// Returns the wizard result on success, or an error if the user cancels or
|
||||
/// something goes wrong during write.
|
||||
pub fn run_setup_wizard() -> anyhow::Result<Option<WizardResult>> {
|
||||
// Step 1: Print compact banner
|
||||
println!();
|
||||
println!("{}", banner::render_fallback_colored());
|
||||
println!();
|
||||
terminal::section_header("Setup Wizard");
|
||||
|
||||
// Step 2: Provider selection
|
||||
println!("Select your LLM provider:");
|
||||
let provider_names: Vec<&str> = ProviderKind::all()
|
||||
.iter()
|
||||
.map(|p| p.display_name())
|
||||
.collect();
|
||||
|
||||
let choice = terminal::selection_menu("Choose a provider:", &provider_names);
|
||||
let choice = match choice {
|
||||
Some(idx) => ProviderKind::all()[idx],
|
||||
None => {
|
||||
terminal::show_status(terminal::StatusKind::Info, "Setup cancelled.");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
println!();
|
||||
terminal::show_status(
|
||||
terminal::StatusKind::Info,
|
||||
&format!("Configuring provider: {}", choice.display_name()),
|
||||
);
|
||||
|
||||
// Step 3: Per-provider config
|
||||
let mut config = ProviderConfig::new(choice);
|
||||
|
||||
// Base URL
|
||||
println!();
|
||||
let url = terminal::text_input("Base URL", Some(&config.base_url));
|
||||
match url {
|
||||
Some(u) if !u.trim().is_empty() => config.base_url = u.trim().to_string(),
|
||||
Some(_) => {} // keep default
|
||||
None => return Ok(None),
|
||||
}
|
||||
|
||||
// API Key (with env hint)
|
||||
let api_key_hint = choice.api_key_env_hint();
|
||||
println!(" (Hint: set via {} env var or enter now)", api_key_hint);
|
||||
let key = terminal::text_input("API Key", None);
|
||||
match key {
|
||||
Some(k) => config.api_key = k.trim().to_string(),
|
||||
None => return Ok(None),
|
||||
}
|
||||
|
||||
// Model
|
||||
let model = terminal::text_input("Model", Some(&config.model));
|
||||
match model {
|
||||
Some(m) if !m.trim().is_empty() => config.model = m.trim().to_string(),
|
||||
Some(_) => {} // keep default
|
||||
None => return Ok(None),
|
||||
}
|
||||
|
||||
// Temperature
|
||||
let temp_input = terminal::text_input("Temperature (0.0-2.0)", Some("0.7"));
|
||||
match temp_input {
|
||||
Some(t) if !t.trim().is_empty() => {
|
||||
if let Ok(v) = t.trim().parse::<f64>() {
|
||||
config.temperature = v.clamp(0.0, 2.0);
|
||||
}
|
||||
}
|
||||
Some(_) => {} // keep default
|
||||
None => return Ok(None),
|
||||
}
|
||||
|
||||
// Max Tokens
|
||||
let tokens_input = terminal::text_input("Max Tokens", Some("4096"));
|
||||
match tokens_input {
|
||||
Some(t) if !t.trim().is_empty() => {
|
||||
if let Ok(v) = t.trim().parse::<u32>() {
|
||||
config.max_tokens = v;
|
||||
}
|
||||
}
|
||||
Some(_) => {} // keep default
|
||||
None => return Ok(None),
|
||||
}
|
||||
|
||||
// Auto-detect Ollama localhost settings
|
||||
if choice == ProviderKind::Ollama && config.base_url.contains("localhost") {
|
||||
config.merge_system = true;
|
||||
config.no_tools = true;
|
||||
println!();
|
||||
terminal::show_status(
|
||||
terminal::StatusKind::Info,
|
||||
"Local Ollama detected: merge_system=true, no_tools=true",
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: Summary
|
||||
println!();
|
||||
terminal::section_header("Configuration Summary");
|
||||
println!(" Provider: {}", config.kind.display_name());
|
||||
println!(" Base URL: {}", config.base_url);
|
||||
println!(
|
||||
" API Key: {}",
|
||||
if config.api_key.is_empty() {
|
||||
"(none)".to_string()
|
||||
} else {
|
||||
format!("{}…{}", &config.api_key[..3.min(config.api_key.len())], &config.api_key[config.api_key.len().saturating_sub(4)..])
|
||||
}
|
||||
);
|
||||
println!(" Model: {}", config.model);
|
||||
println!(" Temperature: {}", config.temperature);
|
||||
println!(" Max Tokens: {}", config.max_tokens);
|
||||
if config.merge_system {
|
||||
println!(" Merge System: true");
|
||||
}
|
||||
if config.no_tools {
|
||||
println!(" No Tools: true");
|
||||
}
|
||||
|
||||
println!();
|
||||
if !terminal::confirm_prompt("Save this configuration?", Some(true)) {
|
||||
terminal::show_status(terminal::StatusKind::Info, "Setup cancelled.");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Step 5: Connection test
|
||||
println!();
|
||||
terminal::show_status(
|
||||
terminal::StatusKind::Info,
|
||||
"Running connection test...",
|
||||
);
|
||||
|
||||
let test_passed = test_provider_connection(&config);
|
||||
|
||||
if test_passed {
|
||||
terminal::show_status(
|
||||
terminal::StatusKind::Success,
|
||||
"Connection successful!",
|
||||
);
|
||||
} else {
|
||||
terminal::show_status(
|
||||
terminal::StatusKind::Warning,
|
||||
"Connection test failed — config will still be saved.",
|
||||
);
|
||||
println!(" (You can retry later or adjust settings manually)");
|
||||
}
|
||||
|
||||
// Step 6: Save config
|
||||
println!();
|
||||
if !terminal::confirm_prompt("Write config file?", Some(true)) {
|
||||
terminal::show_status(terminal::StatusKind::Info, "Setup cancelled.");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let config_path = write_config_file(&config)?;
|
||||
|
||||
terminal::show_status(
|
||||
terminal::StatusKind::Success,
|
||||
&format!("Configuration saved to {}", config_path.display()),
|
||||
);
|
||||
|
||||
Ok(Some(WizardResult {
|
||||
provider: choice,
|
||||
config,
|
||||
connection_ok: test_passed,
|
||||
config_path,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Test provider connectivity by calling the /models endpoint.
|
||||
fn test_provider_connection(config: &ProviderConfig) -> bool {
|
||||
if config.api_key.is_empty() && config.kind != ProviderKind::Ollama {
|
||||
return false;
|
||||
}
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.ok();
|
||||
|
||||
let client = match client {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let url = format!("{}/models", config.base_url.trim_end_matches('/'));
|
||||
|
||||
let request = client.get(&url);
|
||||
let request = if !config.api_key.is_empty() {
|
||||
request.header("Authorization", format!("Bearer {}", config.api_key))
|
||||
} else {
|
||||
request
|
||||
};
|
||||
|
||||
match request.send() {
|
||||
Ok(resp) => resp.status().is_success() || resp.status().as_u16() == 401 || resp.status().as_u16() == 403,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the provider configuration to ~/.config/gyandoot/config.toml.
|
||||
fn write_config_file(config: &ProviderConfig) -> anyhow::Result<PathBuf> {
|
||||
let dir = config_dir();
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
|
||||
let path = dir.join("config.toml");
|
||||
|
||||
let toml_content = toml::to_string_pretty(&SerializedConfig::from(config))?;
|
||||
std::fs::write(&path, &toml_content)?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Serializable wrapper for TOML output.
|
||||
#[derive(Serialize)]
|
||||
struct SerializedConfig {
|
||||
provider: SerializedProvider,
|
||||
agent: SerializedAgent,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SerializedProvider {
|
||||
kind: String,
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
temperature: f64,
|
||||
max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
merge_system: bool,
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
no_tools: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SerializedAgent {
|
||||
name: String,
|
||||
}
|
||||
|
||||
fn is_false(v: &bool) -> bool {
|
||||
!v
|
||||
}
|
||||
|
||||
impl From<&ProviderConfig> for SerializedConfig {
|
||||
fn from(c: &ProviderConfig) -> Self {
|
||||
let kind_str = match c.kind {
|
||||
ProviderKind::OpenAI => "openai",
|
||||
ProviderKind::Anthropic => "anthropic",
|
||||
ProviderKind::Ollama => "ollama",
|
||||
ProviderKind::GoogleGemini => "google-gemini",
|
||||
ProviderKind::DeepSeek => "deepseek",
|
||||
ProviderKind::OpenRouter => "openrouter",
|
||||
ProviderKind::Groq => "groq",
|
||||
};
|
||||
|
||||
SerializedConfig {
|
||||
provider: SerializedProvider {
|
||||
kind: kind_str.to_string(),
|
||||
base_url: c.base_url.clone(),
|
||||
api_key: c.api_key.clone(),
|
||||
model: c.model.clone(),
|
||||
temperature: c.temperature,
|
||||
max_tokens: c.max_tokens,
|
||||
merge_system: c.merge_system,
|
||||
no_tools: c.no_tools,
|
||||
},
|
||||
agent: SerializedAgent {
|
||||
name: "gyandoot".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_provider_kind_all() {
|
||||
let all = ProviderKind::all();
|
||||
assert!(!all.is_empty());
|
||||
assert_eq!(all.len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_display_names() {
|
||||
assert_eq!(ProviderKind::OpenAI.display_name(), "OpenAI");
|
||||
assert_eq!(ProviderKind::Ollama.display_name(), "Ollama");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_defaults() {
|
||||
for kind in ProviderKind::all() {
|
||||
assert!(!kind.default_base_url().is_empty());
|
||||
assert!(!kind.default_model().is_empty());
|
||||
assert!(kind.default_temperature() > 0.0);
|
||||
assert!(kind.default_max_tokens() > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_config_new() {
|
||||
let cfg = ProviderConfig::new(ProviderKind::OpenAI);
|
||||
assert_eq!(cfg.kind, ProviderKind::OpenAI);
|
||||
assert!(cfg.base_url.contains("openai"));
|
||||
assert_eq!(cfg.model, "gpt-4o");
|
||||
assert_eq!(cfg.temperature, 0.7);
|
||||
assert_eq!(cfg.max_tokens, 4096);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_dir_has_gyandoot() {
|
||||
let dir = config_dir();
|
||||
assert!(dir.to_string_lossy().contains("gyandoot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialized_config_roundtrip() {
|
||||
let cfg = ProviderConfig::new(ProviderKind::Anthropic);
|
||||
let serialized = SerializedConfig::from(&cfg);
|
||||
let toml_str = toml::to_string_pretty(&serialized).unwrap();
|
||||
assert!(toml_str.contains("anthropic"));
|
||||
assert!(toml_str.contains("base_url"));
|
||||
assert!(toml_str.contains("api_key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ollama_autodetect() {
|
||||
let mut cfg = ProviderConfig::new(ProviderKind::Ollama);
|
||||
cfg.base_url = "http://localhost:11434".to_string();
|
||||
cfg.merge_system = true;
|
||||
cfg.no_tools = true;
|
||||
assert!(cfg.merge_system);
|
||||
assert!(cfg.no_tools);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user