diff --git a/Cargo.lock b/Cargo.lock index 6ee27f3..9db9f13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -423,18 +423,18 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.10.2" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" dependencies = [ "proc-macro2", "quote", @@ -521,7 +521,7 @@ dependencies = [ "num_cpus", "objc2-foundation", "objc2-metal", - "rand 0.9.4", + "rand 0.9.5", "rand_distr 0.5.1", "rayon", "safetensors 0.7.0", @@ -614,9 +614,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.66" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -1464,9 +1464,9 @@ checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" [[package]] name = "exr" -version = "1.74.1" +version = "1.74.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6be87932f10230a4339ab394edd8e4611fcb72553d8295b4d52ea55249b3daa5" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" dependencies = [ "bit_field", "half", @@ -1551,7 +1551,7 @@ checksum = "c2d1f04709a8ac06e8e8042875a3c466cc4832d3c1a18dbcb9dba3c6e83046bc" dependencies = [ "half", "num-traits", - "rand 0.9.4", + "rand 0.9.5", "rand_distr 0.5.1", ] @@ -2108,7 +2108,7 @@ dependencies = [ "cfg-if", "crunchy", "num-traits", - "rand 0.9.4", + "rand 0.9.5", "rand_distr 0.5.1", "zerocopy", ] @@ -2204,7 +2204,7 @@ dependencies = [ "libc", "log", "num_cpus", - "rand 0.9.4", + "rand 0.9.5", "reqwest 0.12.28", "serde", "serde_json", @@ -3099,7 +3099,7 @@ dependencies = [ "num-complex", "num-rational", "num-traits", - "rand 0.8.6", + "rand 0.8.7", "rand_distr 0.4.3", "simba", "typenum", @@ -3351,7 +3351,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "smbcloud-gresiq-sdk 0.4.8", + "smbcloud-gresiq-sdk", "thiserror 2.0.18", "tokio", "tsync", @@ -3379,7 +3379,7 @@ dependencies = [ "serde_json", "sha2", "smbcloud-auth-sdk", - "smbcloud-gresiq-sdk 0.3.41", + "smbcloud-gresiq-sdk", "smbcloud-model 0.3.41", "smbcloud-network 0.3.41", "tokenizers 0.20.4", @@ -3402,7 +3402,7 @@ dependencies = [ "indexmap 2.14.0", "onde-mistralrs-core", "onde-mistralrs-macros", - "rand 0.9.4", + "rand 0.9.5", "reqwest 0.13.4", "schemars 1.2.1", "serde", @@ -3485,7 +3485,7 @@ dependencies = [ "ordered-float", "parking_lot", "radix_trie", - "rand 0.9.4", + "rand 0.9.5", "rand_distr 0.5.1", "rand_isaac", "rayon", @@ -4046,9 +4046,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -4057,9 +4057,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -4127,7 +4127,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.7", ] [[package]] @@ -4137,7 +4137,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand 0.9.4", + "rand 0.9.5", ] [[package]] @@ -5143,32 +5143,16 @@ dependencies = [ [[package]] name = "smbcloud-gresiq-sdk" -version = "0.3.41" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02a8c916737a712020c57191bcab728303a3963f3fd1c03bd3ddeaf67c4c0a64" +checksum = "37717cda8a547e6840d8230634f790a6e14e5e666981aea76874d69f1febbf95" dependencies = [ "anyhow", "log", "reqwest 0.12.28", "serde", "serde_json", - "smbcloud-network 0.3.41", - "thiserror 2.0.18", - "tokio", -] - -[[package]] -name = "smbcloud-gresiq-sdk" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27abf6a2b9d9db0246b3d68e4ce447b5569f43ede7ca4ceb08afeed4232f3c02" -dependencies = [ - "anyhow", - "log", - "reqwest 0.12.28", - "serde", - "serde_json", - "smbcloud-network 0.4.8", + "smbcloud-network 0.4.9", "thiserror 2.0.18", "tokio", ] @@ -5193,9 +5177,9 @@ dependencies = [ [[package]] name = "smbcloud-model" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21047dc7f858988cf73f0f70b0e4c087963464c4ba0f4d8c45fbcea4c6d9390a" +checksum = "d8309cf17d429115d32165921c4f1fbbb0700e7d60f1bccd73f04f24e24cd837" dependencies = [ "chrono", "log", @@ -5226,16 +5210,16 @@ dependencies = [ [[package]] name = "smbcloud-network" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be96c7f572f0c91032c0a47818201f30481cdcf1965d86e5269a2cb8742ad2b" +checksum = "9955a28b5ae5284ad0250d41f67b10ee1486c932ccdc152bfe9993345df7778e" dependencies = [ "clap 4.6.1", "log", "reqwest 0.12.28", "serde", "serde_json", - "smbcloud-model 0.4.8", + "smbcloud-model 0.4.9", "wasm-bindgen", ] @@ -5324,7 +5308,7 @@ dependencies = [ "approx", "nalgebra", "num-traits", - "rand 0.8.6", + "rand 0.8.7", ] [[package]] @@ -5829,9 +5813,9 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -5892,9 +5876,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -5922,7 +5906,7 @@ dependencies = [ "monostate", "onig", "paste", - "rand 0.8.6", + "rand 0.8.7", "rayon", "rayon-cond 0.3.0", "regex", @@ -5955,7 +5939,7 @@ dependencies = [ "macro_rules_attribute", "monostate", "paste", - "rand 0.9.4", + "rand 0.9.5", "rayon", "rayon-cond 0.4.0", "regex", @@ -5988,7 +5972,7 @@ dependencies = [ "monostate", "onig", "paste", - "rand 0.9.4", + "rand 0.9.5", "rayon", "rayon-cond 0.4.0", "regex", @@ -6296,7 +6280,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.4", + "rand 0.9.5", "sha1", "thiserror 2.0.18", "utf-8", diff --git a/Cargo.toml b/Cargo.toml index 3222af6..c03b42a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros"] } smbcloud-auth-sdk = "0.3.34" smbcloud-model = "0.3.35" smbcloud-network = "0.3.35" -smbcloud-gresiq-sdk = "0.3.35" +smbcloud-gresiq-sdk = "0.4.9" candle-core = "0.10.2" candle-nn = "0.10.2" tokenizers = { version = "0.20", default-features = false, features = ["onig"] } diff --git a/README.md b/README.md index 46baa32..a0895b7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Onde Inference + Onde Inference

Onde Inference CLI

diff --git a/src/app.rs b/src/app.rs index 31d137b..136c1cb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -47,6 +47,8 @@ pub enum Screen { GgufDetail, CloneRepo, Chat, + /// Pick which app to assign a just-registered fine-tuned model to. + PublishModel, } /// An entry in the Onde inference model picker. @@ -247,6 +249,9 @@ pub enum AuthEvent { model_id: String, }, ModelAssignFailed(String), + // publish: register a freshly HF-uploaded fine-tune into the catalog + ModelRegistered(OndeModel), + ModelRegisterFailed(String), // downloads (catalog merged with local HF cache) DownloadsLoaded(Vec), #[allow(dead_code)] // reserved for future explicit error reporting @@ -302,6 +307,11 @@ pub struct App { pub rename_input: String, // which app we're picking a model for pub assigning_for_app_index: Option, + // the model just registered from GgufDetail, pending assignment on the + // PublishModel screen. Held by id/name directly (not a models_cursor + // index) so a later models reload can never shift it onto a different row. + pub publish_model_id: Option, + pub publish_model_name: Option, // local HF cache downloads merged with remote catalog pub downloads: Vec, pub downloads_cursor: usize, @@ -392,6 +402,8 @@ impl App { renaming_app: false, rename_input: String::new(), assigning_for_app_index: None, + publish_model_id: None, + publish_model_name: None, downloads: Vec::new(), downloads_cursor: 0, downloads_offset: 0, @@ -597,6 +609,31 @@ impl App { self.busy = false; self.status = Status::error(msg); } + // publish + AuthEvent::ModelRegistered(model) => { + self.busy = false; + let model_name = model + .name + .clone() + .or_else(|| model.hf_repo_id.clone()) + .unwrap_or_else(|| "Model".to_string()); + // Held by id/name directly rather than a models_cursor index, + // so this can't drift onto a different row if app.models is + // ever reloaded/reordered while this screen is open. + self.publish_model_id = Some(model.id.clone()); + self.publish_model_name = Some(model_name.clone()); + self.models.push(model); + self.screen = Screen::PublishModel; + self.apps_cursor = 0; + self.apps_offset = 0; + self.status = Status::success(format!( + "{model_name} registered. Pick an app to assign it to." + )); + } + AuthEvent::ModelRegisterFailed(msg) => { + self.busy = false; + self.status = Status::error(format!("Registration failed: {msg}")); + } // downloads AuthEvent::DownloadsLoaded(models) => { self.busy = false; @@ -927,6 +964,7 @@ fn handle_key( Screen::FineTune => handle_key_finetune(app, key, tx), Screen::CloneRepo => handle_key_clone_repo(app, key, tx), Screen::Chat => handle_key_chat(app, key, tx), + Screen::PublishModel => handle_key_publish_model(app, key, tx), } } @@ -1370,7 +1408,13 @@ fn handle_key_model_detail( app.upload_progress = None; app.upload_running = false; app.screen = Screen::GgufDetail; - app.status = Status::neutral("GGUF model details. u · upload to HuggingFace"); + // Upload only applies to local fine-tune outputs; GGUFs that + // came from HF (App Group / HF cache) have nowhere to go. + app.status = if entry.is_uploadable() { + Status::neutral("GGUF model details. Enter · upload to HuggingFace") + } else { + Status::neutral("GGUF model details. c · test in chat") + }; } } // Enter or 'm' — merge the selected LoRA adapter (skip fine-tuning) @@ -1629,6 +1673,9 @@ fn handle_key_gguf_detail( app.upload_progress = None; app.status = Status::success(format!("Uploaded: {url_owned}")); } + (Char('a'), KeyModifiers::NONE) => { + trigger_publish_model(app, tx); + } (Char('c'), KeyModifiers::CONTROL) => { app.should_quit = true; } @@ -1718,6 +1765,214 @@ fn handle_key_gguf_detail( } } +/// Registers the just-uploaded GGUF into the GresIQ catalog, private to this +/// account, then moves to the app picker so it can be assigned right away. +fn trigger_publish_model(app: &mut App, tx: mpsc::UnboundedSender) { + if app.busy { + return; + } + let Some(ref gguf) = app.selected_gguf else { + return; + }; + let hf_repo_id = app.upload_repo_name.clone(); + if hf_repo_id.is_empty() { + app.status = Status::error("No uploaded repo to register."); + return; + } + let gguf_file = gguf.file_name.clone(); + let approx_size_bytes = std::fs::metadata(&gguf.path).ok().map(|m| m.len() as i64); + let base_model_id = app + .current_project + .as_ref() + .map(|p| p.base_model_id.clone()); + let (base_name, family, parameter_class) = derive_base_model_meta(base_model_id.as_deref()); + let display_name = format!("{base_name} (Fine-tuned)"); + let token = token::load().unwrap_or_default(); + + // Make sure the apps list is ready by the time the picker shows up. This + // must run before `app.busy` is set below -- trigger_load_apps early- + // returns if busy is already true, so reordering these two would make + // the apps fetch silently no-op. + if !app.apps_loaded { + trigger_load_apps(app, tx.clone()); + } + + app.busy = true; + app.status = Status::neutral("Registering model…"); + + tokio::spawn(async move { + match crate::gresiq::create_model( + &token, + &hf_repo_id, + &display_name, + &family, + ¶meter_class, + Some(&gguf_file), + approx_size_bytes, + ) + .await + { + Ok(model) => { + let _ = tx.send(AuthEvent::ModelRegistered(model)); + } + Err(e) => { + let _ = tx.send(AuthEvent::ModelRegisterFailed(e.to_string())); + } + } + }); +} + +/// True if `size` (e.g. "3B") appears in `haystack` as its own token, bounded +/// on both sides by a non-alphanumeric character (or the string edge). "3B" +/// is a plain substring of "13B"/"43B" (digit before) and of "3BETA"/"3B2" +/// (alphanumeric after), so a naive `.contains()` would misdetect those. +/// A preceding "." also disqualifies a match: "7B" inside "1.7B" is the tail +/// of a decimal size, not a 7B model. +fn contains_size_token(haystack: &str, size: &str) -> bool { + let mut start = 0; + while let Some(idx) = haystack[start..].find(size) { + let abs_idx = start + idx; + let end_idx = abs_idx + size.len(); + let preceded_by_digit_or_dot = haystack.as_bytes()[..abs_idx] + .last() + .is_some_and(|b| b.is_ascii_digit() || *b == b'.'); + let followed_by_alnum = haystack.as_bytes()[end_idx..] + .first() + .is_some_and(u8::is_ascii_alphanumeric); + if !preceded_by_digit_or_dot && !followed_by_alnum { + return true; + } + start = abs_idx + 1; + } + false +} + +/// Best-effort display name / family / parameter class for a fine-tune, +/// derived from the base model it was trained from. Falls back to generic +/// values when the base model isn't one of the officially supported ones. +fn derive_base_model_meta(base_model_id: Option<&str>) -> (String, String, String) { + let base_model_id = base_model_id.unwrap_or(""); + let info = onde::inference::models::SUPPORTED_MODEL_INFO + .iter() + .find(|i| i.id == base_model_id); + + let base_name = info.map(|i| i.name.to_string()).unwrap_or_else(|| { + base_model_id + .rsplit('/') + .next() + .filter(|s| !s.is_empty()) + .unwrap_or("Custom model") + .to_string() + }); + + let haystack = format!("{base_model_id} {base_name}").to_lowercase(); + // Checked in this order (most-specific first) because "qwen2.5-coder" + // also contains the substring "qwen2.5" — reordering these would make + // coder models misclassified as plain qwen2.5. + let family = if haystack.contains("qwen3") { + "qwen3" + } else if haystack.contains("qwen2.5-coder") || haystack.contains("qwen2_5-coder") { + "qwen2.5-coder" + } else if haystack.contains("qwen2.5") || haystack.contains("qwen2_5") { + "qwen2.5" + } else { + "custom" + } + .to_string(); + + // Covers the Qwen2.5 (0.5B–72B) and Qwen3 (0.6B–235B) lineups plus other + // common parameter counts. `contains_size_token` enforces token boundaries + // (no digit or "." before, no alphanumeric after), so no entry here can + // match inside another — ordering is not load-bearing. + const SIZES: &[&str] = &[ + "0.5B", "0.6B", "1.5B", "1.7B", "1.8B", "2B", "3B", "4B", "7B", "8B", "9B", "13B", "14B", + "30B", "32B", "70B", "72B", "235B", + ]; + let haystack_upper = haystack.to_uppercase(); + let parameter_class = SIZES + .iter() + .find(|s| contains_size_token(&haystack_upper, s)) + .map(|s| s.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + (base_name, family, parameter_class) +} + +fn handle_key_publish_model( + app: &mut App, + key: crossterm::event::KeyEvent, + tx: mpsc::UnboundedSender, +) { + use KeyCode::*; + + match (key.code, key.modifiers) { + (Up, _) | (Char('k'), KeyModifiers::NONE) => { + app.apps_cursor = app.apps_cursor.saturating_sub(1); + clamp_apps_scroll(app, MAX_VISIBLE); + } + (Down, _) | (Char('j'), KeyModifiers::NONE) => { + if app.apps_cursor + 1 < app.apps.len() { + app.apps_cursor += 1; + } + clamp_apps_scroll(app, MAX_VISIBLE); + } + (Enter, _) if !app.apps.is_empty() => { + submit_assign_published_model(app, tx); + } + (Esc, _) => { + app.publish_model_id = None; + app.publish_model_name = None; + app.screen = Screen::GgufDetail; + app.status = Status::neutral("Model registered — assign it later from Apps."); + } + (Char('c'), KeyModifiers::CONTROL) => { + app.should_quit = true; + } + _ => {} + } +} + +/// Assigns the model held in `app.publish_model_id` to the app currently +/// selected in the picker. Reads the target model by id, not by indexing +/// into `app.models` with a cursor, so it can't silently assign the wrong +/// row if the models list is ever reloaded or reordered while this screen +/// is open. +fn submit_assign_published_model(app: &mut App, tx: mpsc::UnboundedSender) { + if app.busy { + return; + } + let Some(model_id) = app.publish_model_id.clone() else { + return; + }; + let Some(onde_app) = app.apps.get(app.apps_cursor) else { + return; + }; + let token = token::load().unwrap_or_default(); + let onde_app_id = onde_app.id.clone(); + let app_index = app.apps_cursor; + let model_name = app + .publish_model_name + .clone() + .unwrap_or_else(|| model_id.clone()); + app.busy = true; + app.publish_model_id = None; + app.publish_model_name = None; + app.status = Status::neutral(format!("Assigning {model_name}…")); + tokio::spawn(async move { + match crate::gresiq::assign_model(&token, &onde_app_id, &model_id).await { + Ok(()) => { + let _ = tx.send(AuthEvent::ModelAssigned { + app_index, + model_id, + }); + } + Err(e) => { + let _ = tx.send(AuthEvent::ModelAssignFailed(e.to_string())); + } + } + }); +} + fn format_adapter_size(bytes: u64) -> String { if bytes >= 1_000_000_000 { format!("{:.1}GB", bytes as f64 / 1e9) @@ -2839,3 +3094,79 @@ fn trigger_inference_download(app: &mut App, tx: mpsc::UnboundedSender &'static str { crate::app::GRESIQ_API_KEY @@ -34,6 +34,40 @@ pub async fn load_models(token: &str) -> anyhow::Result> { .map_err(|e| anyhow::anyhow!("{e}")) } +/// Registers a fine-tuned model (just uploaded to Hugging Face) into the +/// catalog, private to this account, so it can then be assigned to an app. +#[allow(clippy::too_many_arguments)] +pub async fn create_model( + token: &str, + hf_repo_id: &str, + name: &str, + family: &str, + parameter_class: &str, + gguf_file: Option<&str>, + approx_size_bytes: Option, +) -> anyhow::Result { + smbcloud_gresiq_sdk::create_model( + &Environment::Production, + api_key(), + api_secret(), + token, + CreateModelParams { + hf_repo_id, + name, + org: "Custom", + family, + parameter_class, + format: "gguf", + gguf_file, + modality: Some("text"), + description: Some("Fine-tuned with onde-cli."), + approx_size_bytes, + }, + ) + .await + .map_err(|e| anyhow::anyhow!("{e}")) +} + pub async fn assign_model(token: &str, onde_app_id: &str, model_id: &str) -> anyhow::Result<()> { smbcloud_gresiq_sdk::assign_model( &Environment::Production, diff --git a/src/ui.rs b/src/ui.rs index 3c4f0b7..cd0e071 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -89,6 +89,7 @@ fn render_card(frame: &mut Frame, app: &App, area: Rect) { Screen::FineTune => render_finetune(frame, app, inner), Screen::CloneRepo => render_clone_repo(frame, app, inner), Screen::Chat => render_chat(frame, app, inner), + Screen::PublishModel => render_publish_model(frame, app, inner), } } @@ -366,7 +367,7 @@ fn render_apps(frame: &mut Frame, app: &App, area: Rect) { ]) .split(rest); - render_apps_list(frame, app, bottom[0]); + render_apps_list(frame, app, bottom[0], "No apps yet. Press n to create one."); frame.render_widget( Paragraph::new("New app name:").style(Style::new().fg(C_MUTED)), @@ -384,7 +385,7 @@ fn render_apps(frame: &mut Frame, app: &App, area: Rect) { ]) .split(rest); - render_apps_list(frame, app, bottom[0]); + render_apps_list(frame, app, bottom[0], "No apps yet. Press n to create one."); frame.render_widget( Paragraph::new("n · new Enter · open s · sign out").style(Style::new().fg(C_MUTED)), bottom[1], @@ -392,17 +393,20 @@ fn render_apps(frame: &mut Frame, app: &App, area: Rect) { } } -fn render_apps_list(frame: &mut Frame, app: &App, area: Rect) { +fn render_apps_list(frame: &mut Frame, app: &App, area: Rect, empty_hint: &str) { if app.apps.is_empty() { - if app.busy { + // Keyed on apps_loaded rather than busy: busy can flip back to + // false from an unrelated concurrent event (e.g. a model + // registration finishing before the apps fetch does) while the + // apps list is still genuinely in flight. + if !app.apps_loaded { frame.render_widget( Paragraph::new(" Loading…").style(Style::new().fg(C_MUTED)), area, ); - } else if app.apps_loaded { + } else { frame.render_widget( - Paragraph::new(" No apps yet. Press n to create one.") - .style(Style::new().fg(C_MUTED)), + Paragraph::new(format!(" {empty_hint}")).style(Style::new().fg(C_MUTED)), area, ); } @@ -461,6 +465,51 @@ fn resolve_model_name<'a>(app: &'a App, onde_app: &'a OndeApp) -> &'a str { "No model assigned yet" } +// publish (assign a just-registered model) screen + +fn render_publish_model(frame: &mut Frame, app: &App, area: Rect) { + let model_name = app + .publish_model_name + .clone() + .unwrap_or_else(|| "model".to_string()); + let heading = format!("Assign {model_name} to app"); + + let rows = Layout::vertical([ + Constraint::Length(1), // heading + Constraint::Length(1), // spacer + Constraint::Length(1), // column header + Constraint::Length(1), // divider + Constraint::Min(0), // apps list + Constraint::Length(1), // spacer + Constraint::Length(1), // status + ]) + .split(area); + + frame.render_widget( + Paragraph::new(heading).style(Style::new().fg(C_INK).bold()), + rows[0], + ); + + frame.render_widget( + Paragraph::new(" Name Status Model").style(Style::new().fg(C_MUTED)), + rows[2], + ); + + let divider = "─".repeat(area.width as usize); + frame.render_widget( + Paragraph::new(divider).style(Style::new().fg(C_LINE)), + rows[3], + ); + + render_apps_list( + frame, + app, + rows[4], + "No apps yet. Create one from the Apps screen, then come back to assign this model.", + ); + render_status(frame, app, rows[6]); +} + // app detail screen fn render_app_detail(frame: &mut Frame, app: &App, area: Rect) { @@ -1428,7 +1477,7 @@ fn render_gguf_detail(frame: &mut Frame, app: &App, area: Rect) { app.upload_progress, Some(crate::hf_upload::UploadProgress::Done { .. }) ) { - "Esc · back" + "a · assign to app Esc · back" } else if uploadable { "Enter · upload c · chat Esc · back" } else { @@ -2608,11 +2657,22 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { .as_ref() .map(|g| g.is_uploadable()) .unwrap_or(false); + let uploaded = matches!( + app.upload_progress, + Some(crate::hf_upload::UploadProgress::Done { .. }) + ); let mut keys = Vec::new(); - if uploadable { + if uploadable && !uploaded { keys.push(Span::styled("Enter", Style::new().fg(C_NEON))); keys.push(Span::styled(" · upload ", Style::new().fg(C_MUTED))); } + if uploaded { + keys.push(Span::styled("a", Style::new().fg(C_NEON))); + keys.push(Span::styled( + " · assign to app ", + Style::new().fg(C_MUTED), + )); + } keys.push(Span::styled("c", Style::new().fg(C_NEON))); keys.push(Span::styled(" · chat ", Style::new().fg(C_MUTED))); keys.push(Span::styled("Esc", Style::new().fg(C_NEON))); @@ -2664,6 +2724,16 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { Span::styled("Ctrl+C", Style::new().fg(C_NEON)), Span::styled(" · quit", Style::new().fg(C_MUTED)), ], + Screen::PublishModel => vec![ + Span::styled("↑↓", Style::new().fg(C_NEON)), + Span::styled(" · navigate ", Style::new().fg(C_MUTED)), + Span::styled("Enter", Style::new().fg(C_NEON)), + Span::styled(" · assign ", Style::new().fg(C_MUTED)), + Span::styled("Esc", Style::new().fg(C_NEON)), + Span::styled(" · skip ", Style::new().fg(C_MUTED)), + Span::styled("Ctrl+C", Style::new().fg(C_NEON)), + Span::styled(" · quit", Style::new().fg(C_MUTED)), + ], }; frame.render_widget(