@setoelkahfi / sigit / commits / cc72ab3

Show available models for download in picker

- Add "Available" source for supported models not yet downloaded - Allow selecting these models to trigger automatic download - Display download status and update system prompt based on tool support - Refactor model config lookup and max token logic - Update UI to indicate downloadable models and their status

paydii committed Apr 26, 2026 at 06:57 UTC cc72ab3979c089e0403e43c74c6d5c3ba28c83be
6 files changed +222 -58
Cargo.lock
-1
index 49a54f1..be1c485 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3799,7 +3799,6 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "onde" version = "0.1.8" -source = "git+https://github.com/ondeinference/onde?branch=development#8321bc566cfbca8ff1d4b71f187f2b007fd98433" dependencies = [ "anyhow", "cc",
Cargo.toml
+2 -2
index 21cf7bc..0248387 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,8 +21,8 @@ path = "src/main.rs" agent-client-protocol = { version = "0.10.4", features = ["unstable_session_fork", "unstable_session_additional_directories"] } # Onde Inference engine (local LLM) -# onde = { path = "../onde" } -onde = { git = "https://github.com/ondeinference/onde", branch = "development" } +onde = { path = "../onde" } +# onde = { git = "https://github.com/ondeinference/onde", branch = "development" } # Async runtime async-trait = "0.1"
src/chat.rs
+28 -6
index fca334d..966ad18 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -422,6 +422,14 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect .bg(Color::Black) .add_modifier(Modifier::BOLD), ), + ModelSource::Available => ( + "↓", + "Available for download", + Style::default() + .fg(Color::Blue) + .bg(Color::Black) + .add_modifier(Modifier::BOLD), + ), ModelSource::Fallback => ( "◎", "Fallback", @@ -453,15 +461,17 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect let health_badge = match item.cache_health { ModelCacheHealth::Complete => "", ModelCacheHealth::Incomplete => " ! incomplete cache", + ModelCacheHealth::NotDownloaded => " ↓ download", }; let current_badge = if current { " ← current" } else { "" }; let disabled_badge = match item.cache_health { - ModelCacheHealth::Complete => "", + ModelCacheHealth::Complete | ModelCacheHealth::NotDownloaded => "", ModelCacheHealth::Incomplete => " (unselectable)", }; let brand_mark = match item.source { ModelSource::Onde => "◉", ModelSource::HuggingFace => "○", + ModelSource::Available => "↓", ModelSource::Fallback => "◎", }; let source = format!(" [{} {}]", brand_mark, item.source_label); @@ -478,6 +488,7 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect match item.source { ModelSource::Onde => Style::default().fg(Color::Green).bg(Color::Black), ModelSource::HuggingFace => Style::default().fg(Color::Cyan).bg(Color::Black), + ModelSource::Available => Style::default().fg(Color::Blue).bg(Color::Black), ModelSource::Fallback => Style::default().fg(Color::Yellow).bg(Color::Black), } }; @@ -1063,11 +1074,17 @@ async fn exec_slash<B: ratatui::backend::Backend>( return; } + let loading_msg = if model.cache_health == ModelCacheHealth::NotDownloaded { + format!( + "Downloading and loading {} ({})… this may take a few minutes.", + model.display_name, model.description + ) + } else { + format!("Loading {}…", model.display_name) + }; + app.close_model_picker(); - app.messages.push(ChatMessage::system(format!( - "Loading {}…", - model.display_name - ))); + app.messages.push(ChatMessage::system(loading_msg)); terminal.draw(|frame| render(frame, app)).ok(); let (tx, rx) = mpsc::channel(1); @@ -1083,8 +1100,13 @@ async fn exec_slash<B: ratatui::backend::Backend>( // loading the new one. Calling unload_model() explicitly first // would create a window where no model is loaded — if a message // arrived in that gap it would fail with NoModelLoaded. + let system_prompt = crate::system_prompt_for_model(model.tool_calling); let update = match engine - .load_gguf_model(model.config.clone(), None, Some(sampling)) + .load_gguf_model( + model.config.clone(), + Some(system_prompt.to_string()), + Some(sampling), + ) .await { Ok(_) => {
src/main.rs
+100 -28
index d9889eb..c8b0db7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -193,6 +193,27 @@ specific and practical. Be direct and brief. Write clean, idiomatic code. When debugging, go for the \ root cause, not the symptom. Correct beats clever."; +/// Slim system prompt for models that do not support tool calling. +/// +/// These models (e.g. DeepSeek Coder v1) cannot use the agent tools, so +/// the long tool-oriented instructions in [`SYSTEM_PROMPT`] would waste +/// context and confuse the model. Keep this short and code-focused. +const SIMPLE_SYSTEM_PROMPT: &str = "\ +Your name is siGit — a coding assistant. \ +You are helpful, concise, and write clean, idiomatic code. \ +Answer any question the user asks — programming, general knowledge, or casual chat. \ +When debugging, address the root cause, not the symptom. \ +Be direct and brief."; + +/// Pick the right system prompt based on whether the model supports tool calling. +pub(crate) fn system_prompt_for_model(tool_calling: bool) -> &'static str { + if tool_calling { + SYSTEM_PROMPT + } else { + SIMPLE_SYSTEM_PROMPT + } +} + /// Maximum number of tool-calling rounds before forcing a text response. const MAX_TOOL_ROUNDS: usize = 10; @@ -297,12 +318,13 @@ impl SiGitAgent { &self, model_id: &str, ) -> agent_client_protocol::Result<GgufModelConfig> { - let (new_config, max_tokens) = resolve_model_config(model_id).ok_or_else(|| { - agent_client_protocol::Error::new( - -32602, - format!("unknown or unavailable model: {model_id}"), - ) - })?; + let (new_config, max_tokens, new_tool_calling) = resolve_model_config(model_id) + .ok_or_else(|| { + agent_client_protocol::Error::new( + -32602, + format!("unknown or unavailable model: {model_id}"), + ) + })?; log::info!( "switching model to {} (max_tokens={max_tokens})", @@ -323,7 +345,7 @@ impl SiGitAgent { let (result_tx, result_rx) = tokio::sync::oneshot::channel::<Result<(), String>>(); let loader_engine = Arc::clone(&self.engine); let loader_config = new_config.clone(); - let loader_system_prompt = SYSTEM_PROMPT.to_string(); + let loader_system_prompt = system_prompt_for_model(new_tool_calling).to_string(); let loader_sampling = sampling; std::thread::spawn(move || { @@ -393,17 +415,25 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon let options: Vec<SessionConfigSelectOption> = items .iter() - .filter(|item| item.cache_health == setup::ModelCacheHealth::Complete) + .filter(|item| item.cache_health != setup::ModelCacheHealth::Incomplete) .map(|item| { - let description = if item.tool_calling { - format!("{} - tool calling", item.description) + let mut desc_parts = Vec::new(); + if item.tool_calling { + desc_parts.push("tool calling".to_string()); + } + desc_parts.push(item.description.clone()); + if item.cache_health == setup::ModelCacheHealth::NotDownloaded { + desc_parts.push("↓ download on select".to_string()); + } + let description = desc_parts.join(" - "); + let source_badge = if item.cache_health == setup::ModelCacheHealth::NotDownloaded { + " [↓ Onde]" } else { - item.description.clone() - }; - let source_badge = match item.source_label.as_str() { - "Onde" => " [◉ Onde]", - "HuggingFace" => " [○ HuggingFace]", - _ => "", + match item.source_label.as_str() { + "Onde" => " [◉ Onde]", + "HuggingFace" => " [○ HuggingFace]", + _ => "", + } }; let name = format!("{}{}", item.display_name, source_badge); SessionConfigSelectOption::new( @@ -428,15 +458,17 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon } /// Look up the GgufModelConfig for a given model_id value from the picker items. -fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64)> { +/// +/// Returns `(config, max_tokens, tool_calling)`. +fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64, bool)> { let items = models::build_model_picker_items(); items .into_iter() .find(|item| { item.config.model_id == model_id - && item.cache_health == setup::ModelCacheHealth::Complete + && item.cache_health != setup::ModelCacheHealth::Incomplete }) - .map(|item| (item.config, item.max_tokens)) + .map(|item| (item.config, item.max_tokens, item.tool_calling)) } #[derive(Debug, Clone)] @@ -510,6 +542,7 @@ fn format_models_list(current_model: &GgufModelConfig) -> String { let health_badge = match item.cache_health { setup::ModelCacheHealth::Complete => "", setup::ModelCacheHealth::Incomplete => " ! incomplete cache", + setup::ModelCacheHealth::NotDownloaded => " ↓ download on select", }; let source = match source_key { "Onde" => " [Onde]", @@ -599,6 +632,38 @@ async fn exec_slash_acp( ), ) .await; + } else if model.cache_health == setup::ModelCacheHealth::NotDownloaded { + agent + .send_assistant_message( + session_id.clone(), + format!( + "Downloading and loading {} ({})… this may take a few minutes.", + model.display_name, model.description + ), + ) + .await; + + match agent.switch_model_by_id(&model.config.model_id).await { + Ok(new_config) => { + agent + .send_assistant_message( + session_id, + format!( + "✓ Downloaded and switched to {}", + new_config.display_name + ), + ) + .await; + } + Err(err) => { + agent + .send_assistant_message( + session_id, + format!("error downloading model: {}", err.message), + ) + .await; + } + } } else { agent .send_assistant_message( @@ -1231,7 +1296,12 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>(); let loader_engine = Arc::clone(&engine); - let system_prompt = SYSTEM_PROMPT.to_string(); + let tool_calling = models::build_model_picker_items() + .iter() + .find(|item| item.config.model_id == config.model_id) + .map(|item| item.tool_calling) + .unwrap_or(false); + let system_prompt = system_prompt_for_model(tool_calling).to_string(); std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime"); let result = @@ -1304,13 +1374,14 @@ async fn run_acp_server() -> anyhow::Result<()> { }) .unwrap_or_else(GgufModelConfig::qwen3_4b); - let max_tokens = if config.display_name == "Qwen 3 4B (Q4_K_M)" - || config.display_name == "Qwen 3 8B (Q4_K_M)" - { - 4096 - } else { - 512 - }; + let acp_tool_calling = models::build_model_picker_items() + .iter() + .find(|item| item.config.model_id == config.model_id) + .map(|item| (item.tool_calling, item.max_tokens)) + .unwrap_or((true, 4096)); + + let max_tokens = acp_tool_calling.1; + let tool_calling = acp_tool_calling.0; let sampling = SamplingConfig { max_tokens: Some(max_tokens), @@ -1320,8 +1391,9 @@ async fn run_acp_server() -> anyhow::Result<()> { log::info!("ACP startup model: {}", config.display_name); let startup_config = config.clone(); + let acp_system_prompt = system_prompt_for_model(tool_calling).to_string(); engine - .load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), Some(sampling)) + .load_gguf_model(config, Some(acp_system_prompt), Some(sampling)) .await .map_err(|error| anyhow::anyhow!("model load failed: {error}"))?;
src/models.rs
+91 -21
index c04b53c..853868a 100644 --- a/src/models.rs +++ b/src/models.rs @@ -16,6 +16,9 @@ pub(crate) use crate::setup::ModelCacheHealth; pub(crate) enum ModelSource { Onde, HuggingFace, + /// Supported model that is not yet downloaded locally. When selected it + /// will be downloaded into the Onde app-group cache automatically. + Available, Fallback, } @@ -32,32 +35,108 @@ pub(crate) struct ModelPickerItem { pub(crate) cache_health: ModelCacheHealth, } +// ── Model ID → GgufModelConfig mapping ──────────────────────────────────────── + +/// Map a HuggingFace model ID to the corresponding [`GgufModelConfig`] +/// constructor. Returns `None` for model IDs that siGit does not know how +/// to load. +pub(crate) fn model_id_to_config(model_id: &str) -> Option<GgufModelConfig> { + Some(match model_id { + "bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(), + "bartowski/Qwen_Qwen3-8B-GGUF" => GgufModelConfig::qwen3_8b(), + "bartowski/Qwen_Qwen3-1.7B-GGUF" => GgufModelConfig::qwen3_1_7b(), + "bartowski/Qwen2.5-3B-Instruct-GGUF" => GgufModelConfig::qwen25_3b(), + "bartowski/Qwen2.5-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_1_5b(), + "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_3b(), + "bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_1_5b(), + "TheBloke/deepseek-coder-6.7B-instruct-GGUF" => GgufModelConfig::deepseek_coder_6_7b(), + _ => return None, + }) +} + +/// Whether a model ID supports tool calling (Qwen 3 family). +fn is_tool_calling(model_id: &str) -> bool { + matches!( + model_id, + "bartowski/Qwen_Qwen3-4B-GGUF" + | "bartowski/Qwen_Qwen3-8B-GGUF" + | "bartowski/Qwen_Qwen3-1.7B-GGUF" + ) +} + +/// Max tokens for a given model (tool-calling models need higher budgets +/// because the `<think>…</think>` block consumes tokens before the real +/// response). +fn max_tokens_for(model_id: &str) -> u64 { + if is_tool_calling(model_id) { 4096 } else { 512 } +} + // ── Builder ─────────────────────────────────────────────────────────────────── -/// Build the full list of available model picker items from the local cache. +/// Build the full list of model picker items. /// /// Items are sourced from: -/// 1. The Onde app-group model cache (macOS shared container). -/// 2. The HuggingFace hub cache (`HF_HUB_CACHE` / `HF_HOME` / `~/.cache/huggingface/hub`). +/// 1. **Locally cached** models in the Onde app-group and HuggingFace caches. +/// 2. **All supported models** from [`onde::inference::models::SUPPORTED_MODEL_INFO`] +/// that are not yet downloaded locally — shown as `Available` so the user +/// can select them to trigger a download into the app-group cache. /// -/// If no models are discovered at all, a single fallback entry for the -/// platform-default model is returned so the picker is never empty. +/// If no models are discovered *and* no supported models are known, a single +/// fallback entry for the platform-default model is returned so the picker +/// is never empty. /// -/// Items are sorted by source priority (Onde first, then HuggingFace, then -/// Fallback) and then alphabetically by display name within each group. +/// Items are sorted: Onde first, then HuggingFace, then Available (not +/// downloaded), then Fallback, and alphabetically within each group. pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> { let mut items = Vec::new(); + // ── 1. Locally discovered models ───────────────────────────────────── for discovered in crate::setup::discover_local_models() { if let Some(item) = discovered_model_to_picker_item(discovered) { items.push(item); } } + // ── 2. Supported models not yet downloaded ─────────────────────────── + // + // Walk SUPPORTED_MODEL_INFO and add an entry for every model ID that + // does not already appear in the local items list (by model_id). + // These entries have `cache_health: NotDownloaded` and + // `source: Available`. When the user selects one, `load_gguf_model` + // will download the GGUF file from HuggingFace into the app-group + // cache automatically. + for info in onde::inference::models::SUPPORTED_MODEL_INFO { + let already_present = items.iter().any(|item| item.config.model_id == info.id); + if already_present { + continue; + } + + let config = match model_id_to_config(info.id) { + Some(config) => config, + None => continue, + }; + + let tool_calling = is_tool_calling(info.id); + let max_tokens = max_tokens_for(info.id); + + items.push(ModelPickerItem { + display_name: config.display_name.clone(), + description: config.approx_memory.clone(), + tool_calling, + max_tokens, + config, + source_label: "Onde".to_string(), + + source: ModelSource::Available, + cache_health: ModelCacheHealth::NotDownloaded, + }); + } + + // ── 3. Fallback ────────────────────────────────────────────────────── if items.is_empty() { let config = GgufModelConfig::platform_default(); - let tool_calling = config.display_name == "Qwen 3 4B (Q4_K_M)"; - let max_tokens = if tool_calling { 4096 } else { 512 }; + let tool_calling = is_tool_calling(&config.model_id); + let max_tokens = max_tokens_for(&config.model_id); items.push(ModelPickerItem { display_name: config.display_name.clone(), @@ -90,19 +169,10 @@ fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPicker "HuggingFace".to_string() }; - let config = match model.model_id.as_str() { - "bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(), - "bartowski/Qwen_Qwen3-8B-GGUF" => GgufModelConfig::qwen3_8b(), - "bartowski/Qwen2.5-3B-Instruct-GGUF" => GgufModelConfig::qwen25_3b(), - "bartowski/Qwen2.5-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_1_5b(), - "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_3b(), - "bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_1_5b(), - _ => return None, - }; + let config = model_id_to_config(&model.model_id)?; - let tool_calling = model.model_id == "bartowski/Qwen_Qwen3-4B-GGUF" - || model.model_id == "bartowski/Qwen_Qwen3-8B-GGUF"; - let max_tokens = if tool_calling { 4096 } else { 512 }; + let tool_calling = is_tool_calling(&model.model_id); + let max_tokens = max_tokens_for(&model.model_id); Some(ModelPickerItem { display_name: config.display_name.clone(),
src/setup.rs
+1
index 62960dd..a26af72 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -123,6 +123,7 @@ pub struct DiscoveredModel { pub enum ModelCacheHealth { Complete, Incomplete, + NotDownloaded, } /// Return all locally discovered GGUF models.