feat: add provider, key, model, models, export, and stats commands with CLI config helpers
This commit is contained in:
parent
659de0dc04
commit
e41a641c94
7 changed files with 580 additions and 48 deletions
14
src/cli.rs
14
src/cli.rs
|
|
@ -98,6 +98,20 @@ pub enum ConfigSubcommand {
|
|||
#[arg(help = "Optional key directly passed; if omitted, securely prompted")]
|
||||
key: Option<String>,
|
||||
},
|
||||
#[command(about = "Set and persist provider base URL")]
|
||||
SetUrl {
|
||||
#[arg(
|
||||
help = "Provider API base URL (e.g. https://openrouter.ai/api/v1 or http://localhost:11434/v1)"
|
||||
)]
|
||||
url: String,
|
||||
},
|
||||
#[command(about = "Set and persist Stage B router model")]
|
||||
SetRouterModel {
|
||||
#[arg(help = "Model identifier to use for Stage B classification")]
|
||||
model: String,
|
||||
},
|
||||
#[command(about = "Display active configuration and credentials status")]
|
||||
Show,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use futures_util::StreamExt;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::fmt;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
|
|
@ -38,16 +39,17 @@ pub trait ModelClient: Send + Sync {
|
|||
}
|
||||
|
||||
pub struct OpenRouterClient {
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
api_key: RwLock<String>,
|
||||
base_url: RwLock<String>,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl fmt::Debug for OpenRouterClient {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let base_url = self.base_url.read().map(|b| b.clone()).unwrap_or_default();
|
||||
f.debug_struct("OpenRouterClient")
|
||||
.field("api_key", &"[REDACTED]")
|
||||
.field("base_url", &self.base_url)
|
||||
.field("base_url", &base_url)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
|
@ -97,12 +99,32 @@ impl OpenRouterClient {
|
|||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
|
||||
Self {
|
||||
api_key,
|
||||
base_url,
|
||||
api_key: RwLock::new(api_key),
|
||||
base_url: RwLock::new(base_url),
|
||||
http,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_api_key(&self, key: String) {
|
||||
if let Ok(mut write) = self.api_key.write() {
|
||||
*write = key;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_base_url(&self, url: String) {
|
||||
if let Ok(mut write) = self.base_url.write() {
|
||||
*write = url;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_api_key(&self) -> String {
|
||||
self.api_key.read().map(|k| k.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn get_base_url(&self) -> String {
|
||||
self.base_url.read().map(|b| b.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Parses an SSE formatted line into a token delta if available
|
||||
pub fn parse_sse_line(line: &str) -> Option<String> {
|
||||
let trimmed = line.trim();
|
||||
|
|
@ -175,7 +197,9 @@ impl OpenRouterClient {
|
|||
max_tokens: Option<u32>,
|
||||
temperature: Option<f32>,
|
||||
) -> Result<String, ClientError> {
|
||||
let endpoint = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
|
||||
let base_url = self.get_base_url();
|
||||
let api_key = self.get_api_key();
|
||||
let endpoint = format!("{}/chat/completions", base_url.trim_end_matches('/'));
|
||||
|
||||
let body = ChatCompletionRequest {
|
||||
model,
|
||||
|
|
@ -194,8 +218,7 @@ impl OpenRouterClient {
|
|||
temperature,
|
||||
};
|
||||
|
||||
let mut auth_val =
|
||||
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", self.api_key))
|
||||
let mut auth_val = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", api_key))
|
||||
.unwrap_or_else(|_| reqwest::header::HeaderValue::from_static(""));
|
||||
auth_val.set_sensitive(true);
|
||||
|
||||
|
|
@ -245,7 +268,9 @@ impl ModelClient for OpenRouterClient {
|
|||
user: &str,
|
||||
chunk_sender: mpsc::Sender<String>,
|
||||
) -> Result<String, ClientError> {
|
||||
let endpoint = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
|
||||
let base_url = self.get_base_url();
|
||||
let api_key = self.get_api_key();
|
||||
let endpoint = format!("{}/chat/completions", base_url.trim_end_matches('/'));
|
||||
|
||||
let body = ChatCompletionRequest {
|
||||
model,
|
||||
|
|
@ -264,8 +289,7 @@ impl ModelClient for OpenRouterClient {
|
|||
temperature: None,
|
||||
};
|
||||
|
||||
let mut auth_val =
|
||||
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", self.api_key))
|
||||
let mut auth_val = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", api_key))
|
||||
.unwrap_or_else(|_| reqwest::header::HeaderValue::from_static(""));
|
||||
auth_val.set_sensitive(true);
|
||||
|
||||
|
|
|
|||
|
|
@ -168,6 +168,63 @@ impl AppConfig {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Persists the provider base URL into `config.toml`
|
||||
pub fn save_openrouter_base_url(config_dir: &Path, base_url: &str) -> Result<(), String> {
|
||||
let config_file_path = config_dir.join("config.toml");
|
||||
let mut app_file = if config_file_path.exists() {
|
||||
let data = fs::read_to_string(&config_file_path)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", config_file_path.display()))?;
|
||||
toml::from_str::<AppConfigFile>(&data).unwrap_or_default()
|
||||
} else {
|
||||
AppConfigFile::default()
|
||||
};
|
||||
|
||||
app_file.openrouter_base_url = base_url.to_string();
|
||||
let serialized = toml::to_string_pretty(&app_file)
|
||||
.map_err(|e| format!("Failed to serialize config: {e}"))?;
|
||||
|
||||
fs::create_dir_all(config_dir).map_err(|e| format!("Failed to create config dir: {e}"))?;
|
||||
fs::write(&config_file_path, serialized)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", config_file_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persists the selected router model into `config.toml`
|
||||
pub fn save_router_model(config_dir: &Path, router_model: &str) -> Result<(), String> {
|
||||
let config_file_path = config_dir.join("config.toml");
|
||||
let mut app_file = if config_file_path.exists() {
|
||||
let data = fs::read_to_string(&config_file_path)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", config_file_path.display()))?;
|
||||
toml::from_str::<AppConfigFile>(&data).unwrap_or_default()
|
||||
} else {
|
||||
AppConfigFile::default()
|
||||
};
|
||||
|
||||
app_file.router_model = router_model.to_string();
|
||||
let serialized = toml::to_string_pretty(&app_file)
|
||||
.map_err(|e| format!("Failed to serialize config: {e}"))?;
|
||||
|
||||
fs::create_dir_all(config_dir).map_err(|e| format!("Failed to create config dir: {e}"))?;
|
||||
fs::write(&config_file_path, serialized)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", config_file_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Masks an API key for safe display (e.g. `sk-or-v••••••••`)
|
||||
pub fn mask_api_key(key: &str) -> String {
|
||||
let trimmed = key.trim();
|
||||
if trimmed.is_empty() {
|
||||
return "not configured".to_string();
|
||||
}
|
||||
if trimmed.len() <= 8 {
|
||||
return "••••••••".to_string();
|
||||
}
|
||||
let prefix = &trimmed[..trimmed.len().min(7)];
|
||||
format!("{prefix}••••••••")
|
||||
}
|
||||
|
||||
/// Resolves the API key using precedence:
|
||||
/// 1. CLI argument
|
||||
/// 2. `OPENROUTER_API_KEY` environment variable
|
||||
|
|
@ -277,4 +334,29 @@ mod tests {
|
|||
.expect("load config");
|
||||
assert_eq!(loaded.theme, "dracula");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_url_and_router_model_persistence() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
AppConfig::save_openrouter_base_url(temp_dir.path(), "http://localhost:11434/v1")
|
||||
.expect("save base url");
|
||||
AppConfig::save_router_model(temp_dir.path(), "qwen/qwen-2.5-7b:free")
|
||||
.expect("save router model");
|
||||
|
||||
let loaded =
|
||||
AppConfig::load_from_paths(Some(&temp_dir.path().join("config.toml")), None, None)
|
||||
.expect("load config");
|
||||
assert_eq!(loaded.openrouter_base_url, "http://localhost:11434/v1");
|
||||
assert_eq!(loaded.router_model, "qwen/qwen-2.5-7b:free");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_api_key() {
|
||||
assert_eq!(AppConfig::mask_api_key(""), "not configured");
|
||||
assert_eq!(AppConfig::mask_api_key("short"), "••••••••");
|
||||
assert_eq!(
|
||||
AppConfig::mask_api_key("sk-or-v1-abcdef123456"),
|
||||
"sk-or-v••••••••"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
57
src/main.rs
57
src/main.rs
|
|
@ -150,6 +150,63 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ConfigSubcommand::SetUrl { url } => {
|
||||
AppConfig::save_openrouter_base_url(&config.config_dir, &url)
|
||||
.map_err(|e| format!("Failed to save provider base URL: {e}"))?;
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"✓ Provider base URL updated to '{}' and saved to config.toml",
|
||||
url
|
||||
)
|
||||
.bright_green()
|
||||
.bold()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ConfigSubcommand::SetRouterModel { model } => {
|
||||
AppConfig::save_router_model(&config.config_dir, &model)
|
||||
.map_err(|e| format!("Failed to save router model: {e}"))?;
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"✓ Stage B router model updated to '{}' and saved to config.toml",
|
||||
model
|
||||
)
|
||||
.bright_green()
|
||||
.bold()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ConfigSubcommand::Show => {
|
||||
let key_str = config.api_key.as_deref().unwrap_or("");
|
||||
let masked = AppConfig::mask_api_key(key_str);
|
||||
println!("\n{}", "BrokeCrew Configuration:".bold().underline());
|
||||
println!(
|
||||
" Provider Base URL: {}",
|
||||
config.openrouter_base_url.bright_cyan()
|
||||
);
|
||||
println!(
|
||||
" Router Model: {}",
|
||||
config.router_model.bright_white().bold()
|
||||
);
|
||||
println!(
|
||||
" Default Persona: {}",
|
||||
config.default_persona.bright_yellow()
|
||||
);
|
||||
println!(" API Key: {}", masked);
|
||||
println!(" Stealth Allowed: {}", config.allow_stealth);
|
||||
println!(" Complexity Threshold: {}", config.complexity_threshold);
|
||||
println!(" Theme: {}", config.theme.bright_magenta());
|
||||
println!(
|
||||
" Config Directory: {}\n",
|
||||
config.config_dir.display().to_string().dimmed()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
|
||||
Some(Commands::Personas(personas_args)) => match personas_args.action {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ pub struct RouteDecision {
|
|||
|
||||
pub struct Router {
|
||||
pub default_persona: String,
|
||||
pub router_model: String,
|
||||
router_model: std::sync::RwLock<String>,
|
||||
pub complexity_threshold: u8,
|
||||
}
|
||||
|
||||
|
|
@ -31,11 +31,24 @@ impl Router {
|
|||
pub fn new(default_persona: String, router_model: String, complexity_threshold: u8) -> Self {
|
||||
Self {
|
||||
default_persona,
|
||||
router_model,
|
||||
router_model: std::sync::RwLock::new(router_model),
|
||||
complexity_threshold,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_router_model(&self, model: String) {
|
||||
if let Ok(mut write) = self.router_model.write() {
|
||||
*write = model;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_router_model(&self) -> String {
|
||||
self.router_model
|
||||
.read()
|
||||
.map(|m| m.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub async fn route(
|
||||
&self,
|
||||
registry: &PersonaRegistry,
|
||||
|
|
@ -58,13 +71,9 @@ impl Router {
|
|||
|
||||
// Stage B: LLM classification fallback
|
||||
let known: Vec<String> = registry.list().iter().map(|p| p.name.clone()).collect();
|
||||
let selected = LlmRouter::classify(
|
||||
client,
|
||||
&self.router_model,
|
||||
&known,
|
||||
&self.default_persona,
|
||||
prompt,
|
||||
)
|
||||
let router_model = self.get_router_model();
|
||||
let selected =
|
||||
LlmRouter::classify(client, &router_model, &known, &self.default_persona, prompt)
|
||||
.await?;
|
||||
|
||||
Ok(RouteDecision {
|
||||
|
|
|
|||
147
src/tui/app.rs
147
src/tui/app.rs
|
|
@ -52,7 +52,23 @@ pub struct CommandSuggestion {
|
|||
pub const ALL_COMMANDS: &[CommandSuggestion] = &[
|
||||
CommandSuggestion {
|
||||
name: "/settings",
|
||||
description: "Open settings menu",
|
||||
description: "Open settings and feature menu",
|
||||
},
|
||||
CommandSuggestion {
|
||||
name: "/provider",
|
||||
description: "View or change LLM provider base URL",
|
||||
},
|
||||
CommandSuggestion {
|
||||
name: "/key",
|
||||
description: "View or update OpenRouter API key",
|
||||
},
|
||||
CommandSuggestion {
|
||||
name: "/model",
|
||||
description: "View or set Stage B router model",
|
||||
},
|
||||
CommandSuggestion {
|
||||
name: "/models",
|
||||
description: "Search live cached models catalog",
|
||||
},
|
||||
CommandSuggestion {
|
||||
name: "/session",
|
||||
|
|
@ -70,6 +86,14 @@ pub const ALL_COMMANDS: &[CommandSuggestion] = &[
|
|||
name: "/usage",
|
||||
description: "View rate limit and budget usage counters",
|
||||
},
|
||||
CommandSuggestion {
|
||||
name: "/stats",
|
||||
description: "Detailed usage and token analytics",
|
||||
},
|
||||
CommandSuggestion {
|
||||
name: "/export",
|
||||
description: "Export current chat to Markdown or JSON",
|
||||
},
|
||||
CommandSuggestion {
|
||||
name: "/config",
|
||||
description: "View active system configuration",
|
||||
|
|
@ -83,12 +107,12 @@ pub const ALL_COMMANDS: &[CommandSuggestion] = &[
|
|||
description: "Toggle complexity escalation",
|
||||
},
|
||||
CommandSuggestion {
|
||||
name: "/personas",
|
||||
description: "Open persona selector modal",
|
||||
name: "/persona",
|
||||
description: "Select active persona or pass name",
|
||||
},
|
||||
CommandSuggestion {
|
||||
name: "/pipelines",
|
||||
description: "Open pipeline selector modal",
|
||||
name: "/pipeline",
|
||||
description: "Select active pipeline or pass name",
|
||||
},
|
||||
CommandSuggestion {
|
||||
name: "/clear",
|
||||
|
|
@ -569,6 +593,94 @@ impl AppState {
|
|||
self.log_working_event("[session] started fresh session");
|
||||
}
|
||||
|
||||
/// Exports active conversation to Markdown file in `~/.config/brokecrew/exports/`
|
||||
pub fn export_active_session_md(&self) -> Result<PathBuf, std::io::Error> {
|
||||
let export_dir = self.config_dir.join("exports");
|
||||
std::fs::create_dir_all(&export_dir)?;
|
||||
|
||||
let filename = format!("{}.md", self.active_session.id);
|
||||
let path = export_dir.join(filename);
|
||||
|
||||
let mut content = format!("# BrokeCrew Session: {}\n", self.active_session.title);
|
||||
content.push_str(&format!("- **ID:** `{}`\n", self.active_session.id));
|
||||
content.push_str(&format!(
|
||||
"- **Created:** {}\n",
|
||||
self.active_session.created_at
|
||||
));
|
||||
content.push_str(&format!(
|
||||
"- **Updated:** {}\n\n---\n\n",
|
||||
self.active_session.updated_at
|
||||
));
|
||||
|
||||
for msg in &self.messages {
|
||||
match &msg.sender {
|
||||
MessageSender::User => {
|
||||
content.push_str(&format!("### User\n\n{}\n\n", msg.content.trim()));
|
||||
}
|
||||
MessageSender::Persona { name, model, tier } => {
|
||||
let tier_s = match tier {
|
||||
Tier::Free => "free",
|
||||
Tier::Paid => "paid",
|
||||
};
|
||||
content.push_str(&format!(
|
||||
"### Assistant ({} • {} [{}])\n\n{}\n\n",
|
||||
name,
|
||||
model,
|
||||
tier_s,
|
||||
msg.content.trim()
|
||||
));
|
||||
}
|
||||
MessageSender::PipelineStep {
|
||||
step_index,
|
||||
total_steps,
|
||||
persona,
|
||||
model,
|
||||
..
|
||||
} => {
|
||||
content.push_str(&format!(
|
||||
"### Pipeline Step {}/{}: {} ({})\n\n{}\n\n",
|
||||
step_index + 1,
|
||||
total_steps,
|
||||
persona,
|
||||
model,
|
||||
msg.content.trim()
|
||||
));
|
||||
}
|
||||
MessageSender::Tool {
|
||||
command,
|
||||
exit_code,
|
||||
duration_ms,
|
||||
} => {
|
||||
let dur_s = (*duration_ms as f64) / 1000.0;
|
||||
content.push_str(&format!(
|
||||
"```bash\n$ {command}\n# exit {exit_code} ({dur_s:.1}s)\n{}\n```\n\n",
|
||||
msg.content.trim()
|
||||
));
|
||||
}
|
||||
MessageSender::System => {
|
||||
content.push_str(&format!("> *System: {}*\n\n", msg.content.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::write(&path, content)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Exports active conversation to JSON file in `~/.config/brokecrew/exports/`
|
||||
pub fn export_active_session_json(&self) -> Result<PathBuf, std::io::Error> {
|
||||
let export_dir = self.config_dir.join("exports");
|
||||
std::fs::create_dir_all(&export_dir)?;
|
||||
|
||||
let filename = format!("{}.json", self.active_session.id);
|
||||
let path = export_dir.join(filename);
|
||||
|
||||
let json = serde_json::to_string_pretty(&self.active_session)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
std::fs::write(&path, json)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn clear_chat(&mut self) {
|
||||
self.messages.clear();
|
||||
self.add_system_message("Chat history cleared.".to_string());
|
||||
|
|
@ -701,4 +813,29 @@ mod tests {
|
|||
app.cycle_theme();
|
||||
assert_eq!(app.current_theme, "tokyo_night");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_export() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let mut app = AppState::new(
|
||||
vec![],
|
||||
vec![],
|
||||
20,
|
||||
50,
|
||||
temp_dir.path().to_path_buf(),
|
||||
"opencode".to_string(),
|
||||
);
|
||||
app.add_user_message("Hello from user".to_string());
|
||||
app.start_assistant_message("coder", "qwen-coder", Tier::Free);
|
||||
app.append_to_current_message("Hello from coder");
|
||||
|
||||
let md_path = app.export_active_session_md().expect("export md");
|
||||
assert!(md_path.exists());
|
||||
let md_content = std::fs::read_to_string(&md_path).unwrap();
|
||||
assert!(md_content.contains("Hello from user"));
|
||||
assert!(md_content.contains("Hello from coder"));
|
||||
|
||||
let json_path = app.export_active_session_json().expect("export json");
|
||||
assert!(json_path.exists());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
247
src/tui/mod.rs
247
src/tui/mod.rs
|
|
@ -193,7 +193,8 @@ pub async fn run_tui(
|
|||
&client,
|
||||
&router,
|
||||
worker_tx.clone(),
|
||||
);
|
||||
)
|
||||
.await;
|
||||
if should_quit {
|
||||
break;
|
||||
}
|
||||
|
|
@ -444,7 +445,7 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e
|
|||
}
|
||||
}
|
||||
|
||||
fn handle_key_event(
|
||||
async fn handle_key_event(
|
||||
app: &mut AppState,
|
||||
key: KeyEvent,
|
||||
config: &AppConfig,
|
||||
|
|
@ -645,7 +646,15 @@ fn handle_key_event(
|
|||
app.input.clear();
|
||||
app.cursor_pos = 0;
|
||||
app.selected_suggestion_idx = 0;
|
||||
handle_slash_command(app, config, &cmd);
|
||||
handle_slash_command(
|
||||
app,
|
||||
config,
|
||||
client,
|
||||
router,
|
||||
model_registry,
|
||||
&cmd,
|
||||
)
|
||||
.await;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -693,7 +702,15 @@ fn handle_key_event(
|
|||
app.selected_suggestion_idx = 0;
|
||||
|
||||
if prompt.starts_with('/') {
|
||||
handle_slash_command(app, config, &prompt);
|
||||
handle_slash_command(
|
||||
app,
|
||||
config,
|
||||
client,
|
||||
router,
|
||||
model_registry,
|
||||
&prompt,
|
||||
)
|
||||
.await;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -729,7 +746,14 @@ fn handle_key_event(
|
|||
false
|
||||
}
|
||||
|
||||
fn handle_slash_command(app: &mut AppState, config: &AppConfig, input: &str) {
|
||||
async fn handle_slash_command(
|
||||
app: &mut AppState,
|
||||
config: &AppConfig,
|
||||
client: &Arc<OpenRouterClient>,
|
||||
router: &Arc<Router>,
|
||||
model_registry: &Arc<ModelRegistry>,
|
||||
input: &str,
|
||||
) {
|
||||
let parts: Vec<&str> = input.split_whitespace().collect();
|
||||
let cmd = parts.first().copied().unwrap_or("");
|
||||
|
||||
|
|
@ -742,33 +766,175 @@ fn handle_slash_command(app: &mut AppState, config: &AppConfig, input: &str) {
|
|||
app.selected_session_idx = 0;
|
||||
app.mode = TuiMode::SessionSelect;
|
||||
}
|
||||
"/config" => {
|
||||
let key_status = if config.api_key.is_some() {
|
||||
"configured"
|
||||
} else {
|
||||
"not configured"
|
||||
"/provider" => {
|
||||
if parts.len() >= 3 && parts[1].eq_ignore_ascii_case("set") {
|
||||
let target = parts[2];
|
||||
let target_lower = target.to_lowercase();
|
||||
let resolved_url = match target_lower.as_str() {
|
||||
"openrouter" | "default" => "https://openrouter.ai/api/v1",
|
||||
"ollama" => "http://localhost:11434/v1",
|
||||
"vllm" | "local" => "http://localhost:8000/v1",
|
||||
_ => target,
|
||||
};
|
||||
client.set_base_url(resolved_url.to_string());
|
||||
let _ = AppConfig::save_openrouter_base_url(&app.config_dir, resolved_url);
|
||||
|
||||
let reg_clone = Arc::clone(model_registry);
|
||||
let url_string = resolved_url.to_string();
|
||||
tokio::spawn(async move {
|
||||
let _ = reg_clone.fetch_live(&url_string).await;
|
||||
});
|
||||
|
||||
app.log_working_event(&format!("[provider] endpoint switched to {resolved_url}"));
|
||||
app.add_system_message(format!(
|
||||
"Config Overview:\n Router Model: {}\n Default Persona: {}\n Stealth Candidates: {}\n Complexity Escalation: {}\n OpenRouter Key: {}\n Base URL: {}\n Config Dir: {}",
|
||||
config.router_model,
|
||||
"Provider base URL updated to: {resolved_url}\nSaved to config.toml. Syncing model catalog..."
|
||||
));
|
||||
} else {
|
||||
let current_url = client.get_base_url();
|
||||
app.add_system_message(format!(
|
||||
"Provider Endpoint Status:\n Active URL: {current_url}\n\nTo change provider:\n /provider set <url|preset>\n\nBuilt-in presets:\n /provider set openrouter -> https://openrouter.ai/api/v1\n /provider set ollama -> http://localhost:11434/v1\n /provider set local -> http://localhost:8000/v1"
|
||||
));
|
||||
}
|
||||
}
|
||||
"/key" => {
|
||||
if parts.len() >= 2 {
|
||||
let new_key = parts[1].trim().to_string();
|
||||
if new_key.is_empty() {
|
||||
app.add_system_message("Error: API key cannot be empty.".to_string());
|
||||
} else {
|
||||
client.set_api_key(new_key.clone());
|
||||
let _ = AppConfig::save_api_key_to_keyring(&new_key);
|
||||
let masked = AppConfig::mask_api_key(&new_key);
|
||||
app.log_working_event("[key] API key updated");
|
||||
app.add_system_message(format!(
|
||||
"API key updated to {masked} and saved securely in OS credential store."
|
||||
));
|
||||
}
|
||||
} else {
|
||||
let current_key = client.get_api_key();
|
||||
let masked = AppConfig::mask_api_key(¤t_key);
|
||||
app.add_system_message(format!(
|
||||
"OpenRouter API Key Status:\n Active Key: {masked}\n\nTo update:\n /key <new-api-key>"
|
||||
));
|
||||
}
|
||||
}
|
||||
"/model" => {
|
||||
if parts.len() >= 2 {
|
||||
let new_model = parts[1].trim().to_string();
|
||||
router.set_router_model(new_model.clone());
|
||||
let _ = AppConfig::save_router_model(&app.config_dir, &new_model);
|
||||
app.log_working_event(&format!("[router] model set to {new_model}"));
|
||||
app.add_system_message(format!(
|
||||
"Stage B router model updated to: {new_model}\nSaved to config.toml."
|
||||
));
|
||||
} else {
|
||||
let current_model = router.get_router_model();
|
||||
app.add_system_message(format!(
|
||||
"Stage B Router Model:\n Active Model: {current_model}\n\nTo change:\n /model <model-identifier>"
|
||||
));
|
||||
}
|
||||
}
|
||||
"/models" => {
|
||||
let query = if parts.len() >= 2 {
|
||||
parts[1..].join(" ").to_lowercase()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let count = app.models_cached;
|
||||
if count == 0 {
|
||||
app.add_system_message(
|
||||
"Model catalog empty or still syncing from provider... Try again in a moment."
|
||||
.to_string(),
|
||||
);
|
||||
} else {
|
||||
let free_models = model_registry
|
||||
.discover_free_candidates_for_role(&query)
|
||||
.await;
|
||||
if free_models.is_empty() {
|
||||
app.add_system_message(format!(
|
||||
"No cached models matching query '{query}'. (Total cached in catalog: {count})"
|
||||
));
|
||||
} else {
|
||||
let display_models: Vec<String> = free_models
|
||||
.iter()
|
||||
.take(12)
|
||||
.map(|m| format!(" • {}", m.id))
|
||||
.collect();
|
||||
let total_found = free_models.len();
|
||||
app.add_system_message(format!(
|
||||
"Available Free Models for '{}' ({}/{}):\n{}\n\nUse any model ID via /model <id>",
|
||||
if query.is_empty() { "all" } else { &query },
|
||||
display_models.len(),
|
||||
total_found,
|
||||
display_models.join("\n")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
"/export" => {
|
||||
let format = parts.get(1).copied().unwrap_or("md").to_lowercase();
|
||||
match format.as_str() {
|
||||
"json" => match app.export_active_session_json() {
|
||||
Ok(path) => {
|
||||
app.add_system_message(format!(
|
||||
"✓ Session exported to JSON:\n {}",
|
||||
path.display()
|
||||
));
|
||||
app.log_working_event(&format!("[export] saved to {}", path.display()));
|
||||
}
|
||||
Err(e) => {
|
||||
app.add_system_message(format!("! Failed to export session to JSON: {e}"));
|
||||
}
|
||||
},
|
||||
_ => match app.export_active_session_md() {
|
||||
Ok(path) => {
|
||||
app.add_system_message(format!(
|
||||
"✓ Session exported to Markdown:\n {}",
|
||||
path.display()
|
||||
));
|
||||
app.log_working_event(&format!("[export] saved to {}", path.display()));
|
||||
}
|
||||
Err(e) => {
|
||||
app.add_system_message(format!(
|
||||
"! Failed to export session to Markdown: {e}"
|
||||
));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
"/config" => {
|
||||
let key = client.get_api_key();
|
||||
let masked = AppConfig::mask_api_key(&key);
|
||||
app.add_system_message(format!(
|
||||
"Config Overview:\n Router Model: {}\n Default Persona: {}\n Stealth Candidates: {}\n Complexity Escalation: {}\n OpenRouter Key: {}\n Provider Base URL: {}\n Config Dir: {}",
|
||||
router.get_router_model(),
|
||||
config.default_persona,
|
||||
app.allow_stealth,
|
||||
app.escalate,
|
||||
key_status,
|
||||
config.openrouter_base_url,
|
||||
masked,
|
||||
client.get_base_url(),
|
||||
config.config_dir.display()
|
||||
));
|
||||
}
|
||||
"/usage" => {
|
||||
"/usage" | "/stats" => {
|
||||
let min_pct = (app.minute_requests * 100) / app.minute_limit.max(1);
|
||||
let day_pct = (app.daily_requests * 100) / app.daily_limit.max(1);
|
||||
let total_tokens = app.tokens_in + app.tokens_out;
|
||||
app.add_system_message(format!(
|
||||
"Rate Limit & Budget Usage:\n 1-Minute Rate: {}/{} requests\n 24-Hour Budget: {}/{} requests\n Tokens In: {} (est)\n Tokens Out: {} (est)\n Cached Models: {} available",
|
||||
"Rate Limit & Token Telemetry:\n • Rate Limit (1m): {}/{} requests ({}%)\n • Daily Budget (24h): {}/{} requests ({}%)\n • Tokens In: {} (est)\n • Tokens Out: {} (est)\n • Total Tokens: {} (est)\n • Models Cached: {} online\n • Provider Base: {}\n • Active Theme: {}",
|
||||
app.minute_requests,
|
||||
app.minute_limit,
|
||||
min_pct,
|
||||
app.daily_requests,
|
||||
app.daily_limit,
|
||||
day_pct,
|
||||
app::AppState::format_tokens(app.tokens_in),
|
||||
app::AppState::format_tokens(app.tokens_out),
|
||||
app.models_cached
|
||||
app::AppState::format_tokens(total_tokens),
|
||||
app.models_cached,
|
||||
client.get_base_url(),
|
||||
app.current_theme
|
||||
));
|
||||
}
|
||||
"/theme" => {
|
||||
|
|
@ -825,14 +991,57 @@ fn handle_slash_command(app: &mut AppState, config: &AppConfig, input: &str) {
|
|||
app.clear_chat();
|
||||
}
|
||||
"/persona" | "/personas" => {
|
||||
if let Some(target) = parts.get(1) {
|
||||
let target_lower = target.to_lowercase();
|
||||
if let Some(pos) = app
|
||||
.persona_items
|
||||
.iter()
|
||||
.position(|p| p.name.to_lowercase() == target_lower)
|
||||
{
|
||||
app.selected_persona_idx = pos;
|
||||
app.active_pipeline_for_prompt = None;
|
||||
let persona_name = app.current_persona_name().to_string();
|
||||
app.add_system_message(format!("Switched active persona to: {persona_name}"));
|
||||
app.log_working_event(&format!("[persona] switched to {persona_name}"));
|
||||
} else {
|
||||
let available: Vec<String> =
|
||||
app.persona_items.iter().map(|p| p.name.clone()).collect();
|
||||
app.add_system_message(format!(
|
||||
"Persona '{target}' not found. Available personas:\n {}",
|
||||
available.join(", ")
|
||||
));
|
||||
}
|
||||
} else {
|
||||
app.mode = TuiMode::PersonaSelect;
|
||||
}
|
||||
}
|
||||
"/pipeline" | "/pipelines" => {
|
||||
if let Some(target) = parts.get(1) {
|
||||
let target_lower = target.to_lowercase();
|
||||
if let Some(pipe) = app
|
||||
.pipelines
|
||||
.iter()
|
||||
.find(|p| p.name.to_lowercase() == target_lower)
|
||||
{
|
||||
app.active_pipeline_for_prompt = Some(pipe.name.clone());
|
||||
let pipe_name = pipe.name.clone();
|
||||
app.add_system_message(format!("Selected multi-agent pipeline: {pipe_name}"));
|
||||
app.log_working_event(&format!("[pipeline] selected {pipe_name}"));
|
||||
} else {
|
||||
let available: Vec<String> =
|
||||
app.pipelines.iter().map(|p| p.name.clone()).collect();
|
||||
app.add_system_message(format!(
|
||||
"Pipeline '{target}' not found. Available pipelines:\n {}",
|
||||
available.join(", ")
|
||||
));
|
||||
}
|
||||
} else {
|
||||
app.mode = TuiMode::PipelineSelect;
|
||||
}
|
||||
}
|
||||
"/help" => {
|
||||
app.add_system_message(
|
||||
"Available Commands:\n /new - Start a fresh session\n /session - Open session switcher modal\n /config - View active system configuration\n /usage - View budget and rate-limit counters\n /theme - Cycle color theme (default, warm, forest, mono)\n /settings - View and adjust runtime settings\n /stealth - Toggle stealth model eligibility\n /escalate - Toggle complexity escalation\n /persona - Open persona selection modal\n /pipeline - Open pipeline selection modal\n /clear - Clear chat screen\n /help - Show this help summary"
|
||||
"BrokeCrew Commands Reference:\n\n Session & Workflows:\n /new - Start a fresh chat session\n /session - Switch, resume, or delete saved sessions\n /export [md|json] - Export active conversation to disk\n /clear - Clear chat screen\n\n Providers & Models:\n /provider [set <url|preset>] - View or change endpoint (openrouter, ollama, local)\n /key [new-key] - View key status or update API key securely\n /model [model-id] - View or change Stage B router model\n /models [query] - Search live cached catalog for free models\n\n Personas & Pipelines:\n /persona [name] - Select persona (or open interactive picker)\n /pipeline [name] - Select pipeline (or open interactive picker)\n /stealth - Toggle experimental stealth models\n /escalate - Toggle automatic complexity escalation\n\n Telemetry & Settings:\n /usage, /stats - Rate limits, token usage, and live budget\n /theme [next|name] - Interactive color theme selector\n /config - View active system configuration\n /settings - Display runtime feature toggles\n /help - Display this reference"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
|
@ -1003,7 +1212,7 @@ fn spawn_dispatch_task(
|
|||
let d = match decision.stage_used {
|
||||
RouteStage::StageAKeyword => "Matched keyword in prompt".to_string(),
|
||||
RouteStage::StageBLlm => {
|
||||
format!("LLM classifier (model: {})", router.router_model)
|
||||
format!("LLM classifier (model: {})", router.get_router_model())
|
||||
}
|
||||
};
|
||||
(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue