diff --git a/apps/maple-agent/app/src/backend.rs b/apps/maple-agent/app/src/backend.rs index 4d840550b..d9bf87ebd 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -225,6 +225,7 @@ operations! { end_queued_message_edit(user_id: (&str), session_id: (&str), queue_id: (&str)) -> (); cancel_run(user_id: (&str), run_id: (&str)) -> (); available_model_ids(user_id: (&str)) -> Vec; + model_catalog(user_id: (&str)) -> maple_sdk::ModelCatalogResponse; model_catalog_entry(user_id: (&str), model: (&str)) -> Option; audio_capabilities(user_id: (&str)) -> maple_agent::agent::AudioCapabilities; synthesize_speech(user_id: (&str), text: (String), voice: (String), speed: (f32)) -> Vec; @@ -378,13 +379,14 @@ impl AgentBackend { params: impl Serialize, ) -> Result { self.ensure_active()?; - // Speech, account UI and each viewer's remote-sidebar preferences + // Catalog presentation, speech, account UI and remote-sidebar preferences // belong to the controller. // All task operations use the selected host's separate connection. let client = if let Some(controller) = &self.controller && matches!( method, "audio_capabilities" + | "model_catalog" | "synthesize_speech" | "transcribe_audio" | "plan_usage" diff --git a/apps/maple-agent/app/src/backend/local.rs b/apps/maple-agent/app/src/backend/local.rs index f8cf1f2f2..0cf74e6c2 100644 --- a/apps/maple-agent/app/src/backend/local.rs +++ b/apps/maple-agent/app/src/backend/local.rs @@ -2397,6 +2397,17 @@ impl LocalBackend { .await } + pub async fn model_catalog( + &self, + user_id: &str, + ) -> Result { + self.service + .handle_for_user(user_id) + .await? + .model_catalog() + .await + } + /// What the catalog says about a model: its vision and context window. /// None when the catalog cannot be read. pub async fn model_catalog_entry( diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index 4bcc13818..d299764df 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -22,6 +22,44 @@ use crate::ui::theme; use crate::ui::titlebar; use crate::ui::widgets; +/// Research's compact label precedence, projected once when the catalog loads. +/// IDs remain the keys used for selection and persisted tasks. +pub(super) fn catalog_model_labels( + catalog: &maple_sdk::ModelCatalogResponse, +) -> std::collections::HashMap { + let label = |short: Option<&str>, full: Option<&str>, id: &str| { + short + .filter(|value| !value.trim().is_empty()) + .or_else(|| full.filter(|value| !value.trim().is_empty())) + .unwrap_or(id) + .to_string() + }; + catalog + .data + .iter() + .map(|model| { + ( + model.id.clone(), + label( + model.short_name.as_deref(), + model.display_name.as_deref(), + &model.id, + ), + ) + }) + .chain(catalog.aliases.iter().map(|alias| { + ( + alias.id.clone(), + label( + alias.short_name.as_deref(), + alias.label.as_deref(), + &alias.id, + ), + ) + })) + .collect() +} + /// Recent projects for the header menu. The default Maple workspace stays /// listed even when it sits past the recent cap. fn header_project_paths(recent_roots: &[RecentProjectRoot]) -> Vec { @@ -253,7 +291,7 @@ impl ChatScreen { let pick = model.clone(); let item = MenuItem::new( SharedString::from(format!("model-{model}")), - model.clone(), + self.model_label(&model).to_string(), move |this: &mut Self, _: &mut Window, cx: &mut Context| { this.pick_model(pick.clone(), cx); }, @@ -813,8 +851,10 @@ impl ChatScreen { let drafts = &self.draft_images; let model_label = self .selected_model - .clone() - .unwrap_or_else(|| "Model".to_string()); + .as_deref() + .map(|model| self.model_label(model)) + .unwrap_or("Model") + .to_string(); div() .w_full() .flex() @@ -1273,7 +1313,34 @@ fn chip( #[cfg(test)] mod tests { - use super::{RecentProjectRoot, header_project_paths}; + use super::{RecentProjectRoot, catalog_model_labels, header_project_paths}; + + #[test] + fn model_labels_follow_research_catalog_precedence() { + let catalog = serde_json::from_value(serde_json::json!({ + "object": "list", + "data": [ + {"id": "model-a", "short_name": "Compact", "display_name": "Full name"}, + {"id": "model-b", "display_name": "Server supplied name"}, + {"id": "model-c", "short_name": " ", "display_name": "Fallback name"}, + {"id": "model-d"} + ], + "aliases": [ + {"id": "alias-a", "short_name": "Alias", "label": "Full alias", "target_model": "model-a"}, + {"id": "alias-b", "label": "Automatic", "target_model": "model-b"}, + {"id": "alias-c", "short_name": "", "label": " ", "target_model": "model-c"} + ] + })) + .unwrap(); + let labels = catalog_model_labels(&catalog); + assert_eq!(labels["model-a"], "Compact"); + assert_eq!(labels["model-b"], "Server supplied name"); + assert_eq!(labels["model-c"], "Fallback name"); + assert_eq!(labels["model-d"], "model-d"); + assert_eq!(labels["alias-a"], "Alias"); + assert_eq!(labels["alias-b"], "Automatic"); + assert_eq!(labels["alias-c"], "alias-c"); + } #[test] fn the_header_keeps_the_owners_default_workspace_past_the_recent_cap() { diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index d65e0e1aa..0e6f634c9 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -584,6 +584,8 @@ pub struct ChatScreen { /// The catalog's vision and context window per model id, looked up /// when a model is chosen. model_catalog: HashMap, + /// Friendly labels from the advanced catalog; selection always uses IDs. + model_labels: HashMap, /// MCP servers with their enabled state for the selected task. session_mcp: Vec, /// Count of enabled servers in `session_mcp`, for the composer chip. @@ -1283,6 +1285,7 @@ impl ChatScreen { draft_counter: 0, image_picking: false, model_catalog: HashMap::new(), + model_labels: HashMap::new(), session_mcp: Vec::new(), mcp_enabled_count: 0, composer_expanded: false, @@ -1963,7 +1966,25 @@ impl ChatScreen { .unwrap_or_else(|| "Choose folder".to_string()) } + fn refresh_model_labels(&self, cx: &mut Context) { + let backend = self.backend.clone(); + let user_id = self.user_id.clone(); + self.call( + async move { backend.model_catalog(&user_id).await }, + cx, + |this, result, cx| { + if let Ok(catalog) = result { + this.model_labels = composer::catalog_model_labels(&catalog); + cx.notify(); + } + }, + ); + } + fn refresh_models(&self, cx: &mut Context) { + // Presentation metadata loads independently so failure or latency does + // not affect the existing model list or default selection. + self.refresh_model_labels(cx); let backend = self.backend.clone(); let user_id = self.user_id.clone(); let env_model = backend.configured_model(); @@ -5161,6 +5182,10 @@ impl ChatScreen { } } + fn model_label<'a>(&'a self, model: &'a str) -> &'a str { + self.model_labels.get(model).map_or(model, String::as_str) + } + fn model_menu_models(&self) -> Vec { if self.model_locked { self.selected_model.iter().cloned().collect() diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index b9cd7a02f..c75a16d84 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -2742,6 +2742,10 @@ mod state_tests { this.default_model = Some("glm-5-3".into()); this.selected_model = Some("glm-5-3".into()); + this.model_labels.insert("kimi-k3".into(), "Kimi K3".into()); + assert_eq!(this.model_label("kimi-k3"), "Kimi K3"); + assert_eq!(this.model_label("unknown-model"), "unknown-model"); + let mut glm = summary("glm-task", "GLM"); glm.model = Some("glm-5-3".into()); glm.message_count = 2; diff --git a/apps/maple-agent/crates/maple-agent/src/agent/catalog.rs b/apps/maple-agent/crates/maple-agent/src/agent/catalog.rs index c26eff99a..6cc252ae6 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/catalog.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/catalog.rs @@ -119,6 +119,14 @@ impl AgentRuntimeHandle { Ok(Arc::clone(&self.runtime().await?.api)) } + /// Full presentation metadata from the advanced catalog, including labels + /// for concrete models and aliases. Does not select or add any models. + pub async fn model_catalog(&self) -> Result { + let catalog = self.api().await?.model_catalog().await?; + self.verify_generation().await?; + Ok(catalog) + } + /// The models a task may pick, the runtime's default first. pub async fn available_model_ids(&self) -> Result, String> { let runtime = self.runtime().await?; diff --git a/sdk/rust/src/types.rs b/sdk/rust/src/types.rs index db513370b..305a5c744 100644 --- a/sdk/rust/src/types.rs +++ b/sdk/rust/src/types.rs @@ -732,6 +732,10 @@ impl ModelReasoning { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CatalogModel { pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub short_name: Option, #[serde(default = "default_model_object")] pub object: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -771,6 +775,10 @@ pub struct CatalogCapabilities { pub struct CatalogAlias { pub id: String, #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub short_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub target_model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub capabilities: Option, @@ -1392,6 +1400,34 @@ pub enum AgentSseEvent { #[cfg(test)] mod tests { + #[test] + fn catalog_preserves_optional_model_and_alias_labels() { + let catalog: super::ModelCatalogResponse = serde_json::from_value(serde_json::json!({ + "object": "list", + "data": [ + {"id": "model-a", "short_name": "Compact", "display_name": "Full name"}, + {"id": "legacy-model"} + ], + "aliases": [ + {"id": "auto:quick", "short_name": "Quick", "label": "Automatic quick"}, + {"id": "legacy-alias"} + ] + })) + .unwrap(); + assert_eq!(catalog.data[0].short_name.as_deref(), Some("Compact")); + assert_eq!(catalog.data[0].display_name.as_deref(), Some("Full name")); + assert_eq!(catalog.aliases[0].short_name.as_deref(), Some("Quick")); + assert_eq!(catalog.aliases[0].label.as_deref(), Some("Automatic quick")); + assert!(catalog.data[1].short_name.is_none()); + assert!(catalog.data[1].display_name.is_none()); + assert!(catalog.aliases[1].short_name.is_none()); + assert!(catalog.aliases[1].label.is_none()); + let serialized = serde_json::to_value(catalog).unwrap(); + assert_eq!(serialized["data"][0]["short_name"], "Compact"); + assert!(serialized["data"][1].get("short_name").is_none()); + assert!(serialized["aliases"][1].get("label").is_none()); + } + use super::*; use serde_json::json;