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 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(