diff --git a/crates/rustyclaw-desktop/src/app/dialogs.rs b/crates/rustyclaw-desktop/src/app/dialogs.rs index 4a2cb36c..b0510a54 100644 --- a/crates/rustyclaw-desktop/src/app/dialogs.rs +++ b/crates/rustyclaw-desktop/src/app/dialogs.rs @@ -780,7 +780,18 @@ pub(super) fn Dialogs(sig: AppSignals) -> Element { EnginesDialog { visible: state.read().show_engines_dialog, data: state.read().engines_data.clone(), - on_close: move |_| state.write().show_engines_dialog = false, + on_close: move |_| { + let mut s = state.write(); + s.show_engines_dialog = false; + // The inline action result belongs to the dialog; don't + // let it linger into the next open. + s.engine_action_result = None; + }, + action_pending: state.read().engine_model_action_pending.clone(), + action_result: state.read().engine_action_result.clone(), + on_clear_action_result: move |_| { + state.write().engine_action_result = None; + }, on_engine_action: move |(engine, action): (String, EngineActionKind)| { let gw = gateway.read().clone(); if let Some(client) = gw { @@ -795,6 +806,18 @@ pub(super) fn Dialogs(sig: AppSignals) -> Element { } }, on_model_action: move |(engine, model, action): (String, String, ModelActionKind)| { + state + .write() + .engine_model_action_pending = Some((engine.clone(), model.clone())); + // A Load should honour the context window saved in the + // engine's parameters (the gateway maps it per engine: + // --n-ctx / --ctx-size / --num-ctx). + let saved_ctx = state + .read() + .engines_data + .as_ref() + .and_then(|d| d.engine(&engine)) + .and_then(|e| e.config.context_length); let gw = gateway.read().clone(); if let Some(client) = gw { spawn(async move { @@ -803,7 +826,7 @@ pub(super) fn Dialogs(sig: AppSignals) -> Element { engine, model, action, - context_length: None, + context_length: saved_ctx, extra_args: Vec::new(), }) .await @@ -869,6 +892,20 @@ pub(super) fn Dialogs(sig: AppSignals) -> Element { format!("Switched to {} / {}", engine, model_for_state), ); }, + on_config_save: move |(engine, config): (String, rustyclaw_core::engines::EngineConfig)| { + let gw = gateway.read().clone(); + if let Some(client) = gw { + spawn(async move { + if let Err(e) = client + .send(GatewayCommand::EngineConfigSet { engine, config }) + .await + { + tracing::error!("Failed to save engine config: {}", e); + } + }); + } + }, + configs_received: state.read().engine_configs_received, on_refresh: move |_| { let gw = gateway.read().clone(); let selected = state diff --git a/crates/rustyclaw-desktop/src/app/mod.rs b/crates/rustyclaw-desktop/src/app/mod.rs index 386cbf9a..db890aef 100644 --- a/crates/rustyclaw-desktop/src/app/mod.rs +++ b/crates/rustyclaw-desktop/src/app/mod.rs @@ -409,6 +409,34 @@ pub fn App() -> Element { } }); + // The engines dialog auto-loads the selected engine's models: whenever + // the dialog is open and the engine list (re)arrived, fetch the models + // for the active tab without requiring a click. + use_effect(move || { + if !state.read().engines_models_pending { + return; + } + state.write().engines_models_pending = false; + if !state.read().show_engines_dialog { + return; + } + let selected = state + .read() + .engines_data + .as_ref() + .and_then(|d| d.selected_engine.clone()); + let gw = gateway.read().clone(); + if let (Some(client), Some(engine)) = (gw, selected) { + spawn_reporting("load engine models", async move { + client + .send(GatewayCommand::EngineModelList { engine }) + .await + .context("sending EngineModelList")?; + Ok(()) + }); + } + }); + // Re-fetch panel lists after a mutation result marks them stale, so the // cron/memory/MCP/channels/tool dialogs reflect the change. use_effect(move || { @@ -1736,6 +1764,7 @@ pub fn App() -> Element { agent_name: state.read().agent_name.clone(), pending_prompt: state.read().visible_user_prompt(), provider_models: state.read().provider_models.clone(), + provider_loaded: state.read().provider_loaded_models.clone(), on_submit: on_submit, on_cancel: on_cancel, on_delete_message: on_delete_message, diff --git a/crates/rustyclaw-desktop/src/app_support.rs b/crates/rustyclaw-desktop/src/app_support.rs index a6179573..af1f451d 100644 --- a/crates/rustyclaw-desktop/src/app_support.rs +++ b/crates/rustyclaw-desktop/src/app_support.rs @@ -185,6 +185,14 @@ pub(crate) fn handle_gateway_event( s.pending_tool_approvals.clear(); s.pending_credential_requests.clear(); s.pending_device_flows.clear(); + // An engine model action in flight died with the connection — + // its EngineActionResult can never arrive, so the dialog's + // "Loading…" buttons would stay stuck forever. + s.engine_model_action_pending = None; + s.engine_action_result = None; + // The next connection is a fresh exchange: the EngineConfigList + // snapshot for the engines panel has not arrived yet. + s.engine_configs_received = false; } GatewayEvent::AuthRequired => { state.write().connection = ConnectionStatus::Authenticating; @@ -875,6 +883,10 @@ pub(crate) fn handle_gateway_event( // ── Engines ────────────────────────────────────────────────────── GatewayEvent::EngineListResult { engines } => { let mut s = state.write(); + // A fresh exchange: the EngineConfigList snapshot that patches + // the panel's configs follows this frame, and has not arrived + // yet — until it does, saving parameters must stay disabled. + s.engine_configs_received = false; let (host_ram, host_vram, host_gpu) = host_resources(&s); let panel = s .engines_data @@ -888,9 +900,15 @@ pub(crate) fn handle_gateway_event( if panel.selected_engine.is_none() { panel.selected_engine = panel.engines.first().map(|e| e.id.clone()); } + // The dialog is open: fetch the selected engine's models without + // requiring a tab click. + if s.show_engines_dialog { + s.engines_models_pending = true; + } } GatewayEvent::EngineModelListResult { engine, models } => { let mut s = state.write(); + s.engines_models_pending = false; let panel = s .engines_data .get_or_insert_with(rustyclaw_view::EnginesPanelData::default); @@ -920,10 +938,30 @@ pub(crate) fn handle_gateway_event( state.write().provider_models.insert(provider, models); } } - // The loaded/running markers and full engine configs arrive in their - // own frames (new capabilities, new frames); the desktop surfaces - // them once the enriched-payload handling lands with the UI work. - GatewayEvent::ProviderModelLoadedList { .. } | GatewayEvent::EngineConfigList { .. } => {} + // Loaded/running markers arrive in their own frame (new capability, + // new frame); the picker marks these models as running. + GatewayEvent::ProviderModelLoadedList { provider, loaded } => { + state + .write() + .provider_loaded_models + .insert(provider, loaded); + } + // Full engine configs arrive in their own frame right after the + // engine list; patch them onto the panel's engine entries so the + // parameters editor can round-trip them. + GatewayEvent::EngineConfigList { configs } => { + let mut s = state.write(); + // The real config snapshot is here: the panel entries are no + // longer placeholders, so saving parameters is safe again. + s.engine_configs_received = true; + if let Some(panel) = s.engines_data.as_mut() { + for engine in &mut panel.engines { + if let Some(cfg) = configs.get(&engine.id) { + engine.config = cfg.clone(); + } + } + } + } GatewayEvent::EnginePullProgress { engine, model, @@ -964,6 +1002,17 @@ pub(crate) fn handle_gateway_event( message, } => { let mut s = state.write(); + // A model action finished: clear the in-flight marker and keep + // the outcome for the dialog's inline feedback. + if model.is_some() { + if s.engine_model_action_pending + .as_ref() + .is_some_and(|(e, _)| e == &engine) + { + s.engine_model_action_pending = None; + } + s.engine_action_result = Some((engine.clone(), ok, message.clone())); + } if let Some(ref mut panel) = s.engines_data { // A pull just finished (successfully or not) — clear the bar. if model.is_some() { @@ -1154,6 +1203,9 @@ fn dto_to_engine_data( can_load: dto.capabilities.can_load, can_unload: dto.capabilities.can_unload, }, + // The engine config arrives in the EngineConfigList frame right + // after the list; it is patched onto the panel entry there. + config: Default::default(), } } diff --git a/crates/rustyclaw-desktop/src/components/chat.rs b/crates/rustyclaw-desktop/src/components/chat.rs index 7cd5746f..13deac7c 100644 --- a/crates/rustyclaw-desktop/src/components/chat.rs +++ b/crates/rustyclaw-desktop/src/components/chat.rs @@ -33,6 +33,9 @@ pub struct ChatProps { /// Live model lists fetched from provider APIs, keyed by provider id. /// The model picker prefers these over the static catalogue. pub provider_models: std::collections::HashMap>, + /// Live "loaded/running" model ids per provider (a subset of + /// `provider_models`); the picker marks those models as running. + pub provider_loaded: std::collections::HashMap>, pub on_submit: EventHandler, pub on_cancel: EventHandler<()>, pub on_prompt_respond: EventHandler<(String, PromptResponseValue)>, @@ -127,6 +130,7 @@ pub fn Chat(props: ChatProps) -> Element { current_provider: props.bottom_bar.composer.current_provider.clone(), current_model: props.bottom_bar.composer.current_model.clone(), provider_models: props.provider_models.clone(), + provider_loaded: props.provider_loaded.clone(), directory_selector: props.bottom_bar.directory_selector.clone(), on_model_change: props.on_model_change, on_add_provider: props.on_add_provider, diff --git a/crates/rustyclaw-desktop/src/components/composer_accessory.rs b/crates/rustyclaw-desktop/src/components/composer_accessory.rs index b6153d9e..c77fac69 100644 --- a/crates/rustyclaw-desktop/src/components/composer_accessory.rs +++ b/crates/rustyclaw-desktop/src/components/composer_accessory.rs @@ -21,6 +21,9 @@ pub struct ComposerAccessoryProps { pub current_model: Option, /// Live model lists fetched from provider APIs, keyed by provider id. pub provider_models: HashMap>, + /// Live "loaded/running" model ids per provider (a subset of + /// `provider_models`); the picker marks those models as running. + pub provider_loaded: HashMap>, pub directory_selector: rustyclaw_view::DirectorySelectorState, pub on_model_change: EventHandler, pub on_add_provider: EventHandler<()>, @@ -35,6 +38,7 @@ pub fn ComposerAccessory(props: ComposerAccessoryProps) -> Element { current_provider: props.current_provider.clone(), current_model: props.current_model.clone(), provider_models: props.provider_models.clone(), + provider_loaded: props.provider_loaded.clone(), on_model_change: props.on_model_change, on_add_provider: props.on_add_provider, } @@ -97,6 +101,8 @@ struct ModelBarProps { current_model: Option, /// Live model lists fetched from provider APIs, keyed by provider id. provider_models: HashMap>, + /// Live "loaded/running" model ids per provider. + provider_loaded: HashMap>, on_model_change: EventHandler, on_add_provider: EventHandler<()>, } @@ -148,6 +154,13 @@ fn ModelBar(props: ModelBarProps) -> Element { if !current_model.is_empty() && !model_options.iter().any(|m| m == ¤t_model) { model_options.insert(0, current_model.clone()); } + // Which of the listed models are loaded/running on the local engine, so + // the picker can say so instead of showing a flat list of names. + let loaded_set: std::collections::HashSet<&String> = props + .provider_loaded + .get(&provider_for_models) + .map(|v| v.iter().collect()) + .unwrap_or_default(); rsx! { div { class: "model-bar", @@ -227,7 +240,7 @@ fn ModelBar(props: ModelBarProps) -> Element { option { value: "{mid}", selected: *mid == current_model, - "{mid}" + if loaded_set.contains(mid) { "{mid} ● running" } else { "{mid}" } } } } diff --git a/crates/rustyclaw-desktop/src/components/engines.rs b/crates/rustyclaw-desktop/src/components/engines.rs index 76bdd5a0..0012d8e1 100644 --- a/crates/rustyclaw-desktop/src/components/engines.rs +++ b/crates/rustyclaw-desktop/src/components/engines.rs @@ -2,10 +2,14 @@ //! //! Laid out as one tab per detected engine: the tab strip switches the //! active engine, and the body shows that engine's status, actions, models, -//! live install output, and any pull progress. +//! live install output, and any pull progress. Each engine tab also carries +//! a parameters editor (context window, device, huge pages, …) whose values +//! are persisted through `EngineConfigSet` and applied on the next +//! start/load. use dioxus::prelude::*; use dioxus_bulma::prelude::BulmaColor; +use rustyclaw_core::engines::EngineConfig; use rustyclaw_core::gateway::{EngineActionKind, ModelActionKind}; use super::RcModal; @@ -14,6 +18,14 @@ use super::RcModal; pub struct EnginesDialogProps { pub visible: bool, pub data: Option, + /// (engine, model) whose load/unload action is in flight; the matching + /// row's button shows "Loading…" and is disabled. + pub action_pending: Option<(String, String)>, + /// Outcome of the last engine model action (engine, ok, message), + /// rendered as an inline alert on that engine's tab. + pub action_result: Option<(String, bool, String)>, + /// Dismiss the inline action result (its alert's close button). + pub on_clear_action_result: EventHandler<()>, pub on_close: EventHandler<()>, pub on_engine_action: EventHandler<(String, EngineActionKind)>, pub on_model_action: EventHandler<(String, String, ModelActionKind)>, @@ -23,13 +35,134 @@ pub struct EnginesDialogProps { pub on_select_engine: EventHandler, /// Switch the active chat provider/model to this local (engine, model). pub on_use_model: EventHandler<(String, String)>, + /// Save the full configuration for an engine (parameters, default model, + /// auto-start, extra args) — sent as `EngineConfigSet`. + pub on_config_save: EventHandler<(String, EngineConfig)>, + /// Whether the gateway's `EngineConfigList` snapshot has arrived. Until + /// it does, the engine configs shown here are placeholders and saving + /// would overwrite the real settings with blanks — the Save button is + /// disabled instead. + pub configs_received: bool, /// Re-fetch the engine list (and selected engine's models). pub on_refresh: EventHandler<()>, } +/// Editable parameter form for one engine, seeded from its config. Kept in +/// a map keyed by engine id so switching tabs doesn't lose in-progress edits, +/// and re-seeded whenever the gateway reports a different config (i.e. after +/// a save round-trip or an external config change). +#[derive(Clone, PartialEq)] +struct EngineParamsForm { + /// Config this form was seeded from — when it differs, the form re-seeds. + config_seen: EngineConfig, + context_length: String, + device: String, + huge_pages: String, + mmap: bool, + lazy_weights: bool, + max_output_tokens: String, + max_concurrency: String, + default_model: String, + auto_start: bool, +} + +impl EngineParamsForm { + fn from_config(cfg: &EngineConfig) -> Self { + Self { + config_seen: cfg.clone(), + context_length: cfg + .context_length + .map(|v| v.to_string()) + .unwrap_or_default(), + device: cfg.device.clone().unwrap_or_default(), + huge_pages: cfg.huge_pages.clone().unwrap_or_default(), + mmap: cfg.mmap, + lazy_weights: cfg.lazy_weights, + max_output_tokens: cfg + .max_output_tokens + .map(|v| v.to_string()) + .unwrap_or_default(), + max_concurrency: cfg + .max_concurrency + .map(|v| v.to_string()) + .unwrap_or_default(), + default_model: cfg.default_model.clone().unwrap_or_default(), + auto_start: cfg.auto_start, + } + } + + fn apply_to(&self, cfg: &mut EngineConfig) { + cfg.context_length = parse_opt_u32(&self.context_length); + cfg.device = opt_string(&self.device); + cfg.huge_pages = opt_string(&self.huge_pages); + cfg.mmap = self.mmap; + cfg.lazy_weights = self.lazy_weights; + cfg.max_output_tokens = parse_opt_u32(&self.max_output_tokens); + cfg.max_concurrency = parse_opt_u32(&self.max_concurrency); + cfg.default_model = opt_string(&self.default_model); + cfg.auto_start = self.auto_start; + } +} + +fn parse_opt_u32(raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() { + None + } else { + raw.parse().ok() + } +} + +fn opt_string(raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() { + None + } else { + Some(raw.to_string()) + } +} + +/// The form to display for an engine: the in-progress edit when it was +/// seeded from the config the gateway currently reports, otherwise a fresh +/// form built from that config. A form whose `config_seen` no longer +/// matches is stale (the config changed under it — e.g. after a save) and +/// is ignored in favour of the fresh config. +fn params_form_for( + params_form: Signal>, + eid: &str, + fallback: &EngineConfig, +) -> EngineParamsForm { + params_form + .read() + .get(eid) + .cloned() + .filter(|f| f.config_seen == *fallback) + .unwrap_or_else(|| EngineParamsForm::from_config(fallback)) +} + +/// Apply `edit` to the engine's form, creating it from `fallback` when +/// missing and re-seeding it when the config changed under it (so in-progress +/// edits never apply to a stale base). +fn params_set( + mut params_form: Signal>, + eid: &str, + fallback: &EngineConfig, + edit: impl FnOnce(&mut EngineParamsForm), +) { + let mut map = params_form.write(); + let entry = map + .entry(eid.to_string()) + .or_insert_with(|| EngineParamsForm::from_config(fallback)); + if entry.config_seen != *fallback { + *entry = EngineParamsForm::from_config(fallback); + } + edit(entry); +} + #[component] pub fn EnginesDialog(props: EnginesDialogProps) -> Element { let mut pull_input = use_signal(String::new); + let params_form = use_signal(std::collections::HashMap::::new); if !props.visible { return rsx! {}; @@ -165,6 +298,26 @@ pub fn EnginesDialog(props: EnginesDialogProps) -> Element { } } } + if engine.running && engine.can("stop") && engine.can("start") { + // Restart applies the saved parameters + // without a manual Stop then Start. + div { class: "level-item", + { + let eid = engine.id.clone(); + let on_engine_action = props.on_engine_action; + rsx! { + dioxus_bulma::prelude::Button { + color: BulmaColor::Link, + onclick: move |_| { + on_engine_action.call((eid.clone(), EngineActionKind::Stop)); + on_engine_action.call((eid.clone(), EngineActionKind::Start)); + }, + "Restart" + } + } + } + } + } if engine.running { div { class: "level-item", { @@ -191,6 +344,301 @@ pub fn EnginesDialog(props: EnginesDialogProps) -> Element { } } + // ── Inline action result ────────────────────────── + // The outcome of the last Load/Unload on this + // engine, so clicking a button always answers. + if let Some((result_engine, ok, message)) = &props.action_result { + if result_engine == &engine.id { + div { + class: if *ok { "notification is-success is-light" } + else { "notification is-danger is-light" }, + button { + class: "delete", + onclick: move |_| props.on_clear_action_result.call(()), + } + p { "{message}" } + } + } + } + + // ── Parameters editor ─────────────────────────── + // Shown for engines that can start (startup settings + // apply) or that expose model parameters. + if engine.can("start") + || engine.supports_context_length() + || engine.supports_joshua_parameters() + || engine.supports_default_model() + { + { + let eid = engine.id.clone(); + // Owned copies for 'static closures (the + // dialog's event handlers cannot borrow + // props). + let fallback_config = engine.config.clone(); + let form = params_form_for(params_form, &eid, &fallback_config); + let params_form_handle = params_form; + let on_config_save = props.on_config_save; + let model_names: Vec = + data.models.iter().map(|m| m.name.clone()).collect(); + rsx! { + div { class: "box mb-3", + div { class: "level", + div { class: "level-left", + div { class: "level-item", + h5 { class: "title is-5 mb-0", "Parameters" } + } + } + div { class: "level-right", + div { class: "level-item", + dioxus_bulma::prelude::Button { + color: BulmaColor::Primary, + size: dioxus_bulma::prelude::BulmaSize::Small, + // Until the gateway's config snapshot arrives, the + // base config here is a placeholder: saving would + // blank out enabled/endpoint/port/models_dir/ + // extra_args, so the button stays disabled. + disabled: !props.configs_received, + onclick: { + let eid_save = eid.clone(); + let fallback = fallback_config.clone(); + move |_| { + // Rebuild the full config: start from the + // config the gateway last reported (which + // preserves enabled/endpoint/port/models_dir/ + // extra_args) and overlay the edited fields. + let form = params_form_for( + params_form_handle, + &eid_save, + &fallback, + ); + let mut cfg = fallback.clone(); + form.apply_to(&mut cfg); + on_config_save.call((eid_save.clone(), cfg)); + } + }, + "Save parameters" + } + } + } + } + p { class: "is-size-7 has-text-grey mb-3", + if props.configs_received { + "Applied on the next Start or model Load." + } else { + "Loading engine settings from the gateway… (saving disabled until they arrive)" + } + } + div { class: "columns is-multiline is-variable is-2", + if engine.supports_context_length() { + div { class: "column is-half", + label { class: "label is-size-7", "Context window (tokens)" } + div { class: "control", + input { + class: "input", + r#type: "number", + min: "1", + placeholder: "engine default", + value: "{form.context_length}", + oninput: { + let eid = eid.clone(); + let fallback = fallback_config.clone(); + let pf = params_form; + move |evt: FormEvent| { + let value = evt.value(); + params_set(pf, &eid, &fallback, |f| f.context_length = value); + } + }, + } + } + } + } + if engine.supports_joshua_parameters() { + div { class: "column is-half", + label { class: "label is-size-7", "Compute device (--device)" } + div { class: "control", + dioxus_bulma::prelude::Select { + size: dioxus_bulma::prelude::BulmaSize::Small, + value: "{form.device}", + onchange: { + let eid = eid.clone(); + let fallback = fallback_config.clone(); + let pf = params_form; + move |evt: FormEvent| { + let value = evt.value(); + params_set(pf, &eid, &fallback, |f| f.device = value); + } + }, + option { value: "", "engine default (auto)" } + option { value: "auto", selected: form.device == "auto", "auto" } + option { value: "cpu", selected: form.device == "cpu", "cpu" } + option { value: "metal", selected: form.device == "metal", "metal" } + option { value: "cuda", selected: form.device == "cuda", "cuda" } + } + } + } + div { class: "column is-half", + label { class: "label is-size-7", "Huge pages (--huge-pages)" } + div { class: "control", + dioxus_bulma::prelude::Select { + size: dioxus_bulma::prelude::BulmaSize::Small, + value: "{form.huge_pages}", + onchange: { + let eid = eid.clone(); + let fallback = fallback_config.clone(); + let pf = params_form; + move |evt: FormEvent| { + let value = evt.value(); + params_set(pf, &eid, &fallback, |f| f.huge_pages = value); + } + }, + option { value: "", "off (default)" } + option { value: "transparent", selected: form.huge_pages == "transparent", "transparent" } + option { value: "2mb", selected: form.huge_pages == "2mb", "2mb" } + option { value: "1gb", selected: form.huge_pages == "1gb", "1gb" } + option { value: "huge", selected: form.huge_pages == "huge", "huge" } + } + } + } + div { class: "column is-half", + label { class: "label is-size-7", "Max output tokens (--max-output-tokens)" } + div { class: "control", + input { + class: "input", + r#type: "number", + min: "1", + placeholder: "4096 (joshua default)", + value: "{form.max_output_tokens}", + oninput: { + let eid = eid.clone(); + let fallback = fallback_config.clone(); + let pf = params_form; + move |evt: FormEvent| { + let value = evt.value(); + params_set(pf, &eid, &fallback, |f| f.max_output_tokens = value); + } + }, + } + } + } + div { class: "column is-half", + label { class: "label is-size-7", "Max concurrent requests (--max-concurrency)" } + div { class: "control", + input { + class: "input", + r#type: "number", + min: "1", + placeholder: "CPU count (joshua default)", + value: "{form.max_concurrency}", + oninput: { + let eid = eid.clone(); + let fallback = fallback_config.clone(); + let pf = params_form; + move |evt: FormEvent| { + let value = evt.value(); + params_set(pf, &eid, &fallback, |f| f.max_concurrency = value); + } + }, + } + } + } + div { class: "column is-full", + label { class: "checkbox is-size-7", + input { + r#type: "checkbox", + checked: form.mmap, + onchange: { + let eid = eid.clone(); + let fallback = fallback_config.clone(); + let pf = params_form; + move |evt: FormEvent| { + let checked = evt.checked(); + params_set(pf, &eid, &fallback, |f| f.mmap = checked); + } + }, + } + " Require memory-mappable model (--mmap)" + } + br {} + label { class: "checkbox is-size-7", + input { + r#type: "checkbox", + checked: form.lazy_weights, + onchange: { + let eid = eid.clone(); + let fallback = fallback_config.clone(); + let pf = params_form; + move |evt: FormEvent| { + let checked = evt.checked(); + params_set(pf, &eid, &fallback, |f| f.lazy_weights = checked); + } + }, + } + " Optimise for a model far larger than RAM (--lazy-weights)" + } + } + } + if engine.supports_default_model() { + div { class: "column is-half", + label { class: "label is-size-7", "Default model (startup)" } + div { class: "control", + dioxus_bulma::prelude::Select { + size: dioxus_bulma::prelude::BulmaSize::Small, + value: "{form.default_model}", + onchange: { + let eid = eid.clone(); + let fallback = fallback_config.clone(); + let pf = params_form; + move |evt: FormEvent| { + let value = evt.value(); + params_set(pf, &eid, &fallback, |f| f.default_model = value); + } + }, + option { + value: "", + selected: form.default_model.is_empty(), + if model_names.is_empty() { + "(no local models — refresh)" + } else { + "— none —" + } + } + for mname in model_names.iter() { + option { + value: "{mname}", + selected: form.default_model == *mname, + "{mname}" + } + } + } + } + } + } + if engine.can("start") { + div { class: "column is-half", + label { class: "checkbox is-size-7", + input { + r#type: "checkbox", + checked: form.auto_start, + onchange: { + let eid = eid.clone(); + let fallback = fallback_config.clone(); + let pf = params_form; + move |evt: FormEvent| { + let checked = evt.checked(); + params_set(pf, &eid, &fallback, |f| f.auto_start = checked); + } + }, + } + " Auto-start with the gateway" + } + } + } + } + } + } + } + } + // ── Live install output for this engine ────────── if let Some(output) = data.install_output.get(&engine.id) { div { @@ -252,6 +700,12 @@ pub fn EnginesDialog(props: EnginesDialogProps) -> Element { let can_load = engine_caps.as_ref().is_some_and(|e| e.can("load")); let can_unload = engine_caps.as_ref().is_some_and(|e| e.can("unload")); let can_remove = engine_caps.as_ref().is_some_and(|e| e.can("remove")); + // In-flight feedback: the clicked model's button turns into + // "Loading…" until the gateway answers. + let pending_here = props + .action_pending + .as_ref() + .is_some_and(|(pe, pm)| pe == &eid && pm == &mname); rsx! { dioxus_bulma::prelude::Buttons { { @@ -272,8 +726,9 @@ pub fn EnginesDialog(props: EnginesDialogProps) -> Element { rsx! { dioxus_bulma::prelude::Button { color: BulmaColor::Info, + disabled: pending_here, onclick: move |_| props.on_model_action.call((eid2.clone(), mname2.clone(), ModelActionKind::Load)), - "Load" + if pending_here { "Loading…" } else { "Load" } } } } @@ -285,8 +740,9 @@ pub fn EnginesDialog(props: EnginesDialogProps) -> Element { rsx! { dioxus_bulma::prelude::Button { color: BulmaColor::Warning, + disabled: pending_here, onclick: move |_| props.on_model_action.call((eid2.clone(), mname2.clone(), ModelActionKind::Unload)), - "Unload" + if pending_here { "Loading…" } else { "Unload" } } } } @@ -299,6 +755,7 @@ pub fn EnginesDialog(props: EnginesDialogProps) -> Element { dioxus_bulma::prelude::Button { color: BulmaColor::Danger, outlined: true, + disabled: pending_here, onclick: move |_| props.on_model_action.call((eid2.clone(), mname2.clone(), ModelActionKind::Remove)), "Remove" } diff --git a/crates/rustyclaw-desktop/src/state.rs b/crates/rustyclaw-desktop/src/state.rs index 7f8b5100..4c172af1 100644 --- a/crates/rustyclaw-desktop/src/state.rs +++ b/crates/rustyclaw-desktop/src/state.rs @@ -244,6 +244,10 @@ pub struct AppState { /// Set when an engine action completed and the engine/model lists /// should be re-fetched from the gateway. pub engines_stale: bool, + /// Set when the engines dialog is open and the selected engine's model + /// list should be (re)fetched — the dialog auto-loads contents instead + /// of waiting for a tab click. + pub engines_models_pending: bool, /// Whether the scheduled-jobs dialog is visible. pub show_cron_dialog: bool, @@ -303,9 +307,24 @@ pub struct AppState { /// keyed by provider id. The model picker prefers these over the /// static catalogue fallback. pub provider_models: HashMap>, + /// Live "loaded/running" model ids per provider (a subset of + /// `provider_models`), used by the picker to mark running models. + pub provider_loaded_models: HashMap>, /// Providers whose live model list has already been requested this /// session (guards against duplicate in-flight requests). pub provider_models_requested: HashSet, + + /// (engine, model) whose load/unload action is currently in flight, so + /// the engines dialog can show "Loading…" on the right button. + pub engine_model_action_pending: Option<(String, String)>, + /// Outcome of the last engine model action (engine, ok, message), shown + /// inline in the engines dialog until the next action or dialog close. + pub engine_action_result: Option<(String, bool, String)>, + /// Whether the `EngineConfigList` snapshot has arrived for the current + /// connection. Until it does, the panel's engine configs are placeholders + /// (`Default::default()`), so saving parameters would overwrite the real + /// enabled/endpoint/port/models_dir/extra_args with blanks. + pub engine_configs_received: bool, } impl Default for AppState { @@ -395,6 +414,7 @@ impl Default for AppState { show_engines_dialog: false, engines_data: None, engines_stale: false, + engines_models_pending: false, show_cron_dialog: false, cron_data: None, cron_stale: false, @@ -418,7 +438,11 @@ impl Default for AppState { show_logs_dialog: false, logs_data: None, provider_models: HashMap::new(), + provider_loaded_models: HashMap::new(), provider_models_requested: HashSet::new(), + engine_model_action_pending: None, + engine_action_result: None, + engine_configs_received: false, } } } diff --git a/crates/rustyclaw-tui/src/gateway_client.rs b/crates/rustyclaw-tui/src/gateway_client.rs index b8e37488..3b147e2e 100644 --- a/crates/rustyclaw-tui/src/gateway_client.rs +++ b/crates/rustyclaw-tui/src/gateway_client.rs @@ -434,6 +434,9 @@ pub(crate) fn gateway_event_to_gw_event( can_load: e.capabilities.can_load, can_unload: e.capabilities.can_unload, }, + // The full engine config arrives in the EngineConfigList + // frame right after the list (patched in the TUI PR). + config: Default::default(), }) .collect(), }, diff --git a/crates/rustyclaw-view/src/engines.rs b/crates/rustyclaw-view/src/engines.rs index 68628b24..0c326b0a 100644 --- a/crates/rustyclaw-view/src/engines.rs +++ b/crates/rustyclaw-view/src/engines.rs @@ -154,9 +154,30 @@ pub struct LocalEngineData { pub available_models: u32, pub loaded_models: u32, pub caps: EngineCapsData, + /// The engine's full configuration (parameters, default model, extra + /// args), as persisted by the gateway. The parameters panel edits + /// this and sends it back via `EngineConfigSet`. + pub config: rustyclaw_core::engines::EngineConfig, } impl LocalEngineData { + /// Whether the engine's config exposes a context-window parameter. + pub fn supports_context_length(&self) -> bool { + matches!(self.id.as_str(), "joshua" | "llamacpp" | "ollama") + } + + /// Whether the engine's config exposes Joshua-style serve parameters + /// (device, huge pages, mmap, …). + pub fn supports_joshua_parameters(&self) -> bool { + self.id == "joshua" + } + + /// Whether the engine uses `default_model` to pick a single model at + /// startup (one-model-per-process engines). + pub fn supports_default_model(&self) -> bool { + self.id == "joshua" + } + /// Status badge string for display. pub fn status_badge(&self) -> &'static str { if !self.installed { @@ -237,9 +258,10 @@ impl LocalModelData { } } - /// Load status badge. + /// Load status badge: "running" when the model is loaded/served (the + /// wording the UI uses for the engines panel), "on disk" otherwise. pub fn load_badge(&self) -> &'static str { - if self.loaded { "loaded" } else { "on disk" } + if self.loaded { "running" } else { "on disk" } } /// Warning message if model doesn't fit (returns the detailed message @@ -309,6 +331,7 @@ mod tests { available_models: 0, loaded_models: 0, caps: EngineCapsData::default(), + config: rustyclaw_core::engines::EngineConfig::default(), } }