diff --git a/CONTEXT.md b/CONTEXT.md index cd6b8c4..7833b95 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,6 @@ # OpenLoop Context -OpenLoop is a local-first music generation tool for Apple Silicon, powered by a local ACE-Step backend. It has two interfaces — a desktop GUI and a command-line CLI — that share all state: settings, history, models, and the backend process. +OpenLoop is a local-first music generation tool for Apple Silicon. ACE-Step 1.5 is the bound Engine today; additional Engines and Model Packs are registered in a first-party catalog so they can be downloaded and switched without rewriting Settings. It has two interfaces — a desktop GUI and a command-line CLI — that share all state: settings, history, models, and the backend process. ## Language @@ -27,8 +27,20 @@ _Avoid_: History, Generation Record A configuration option intended for users who deliberately open advanced or diagnostic controls. _Avoid_: Primary workflow, beginner setting +**Engine**: +A generation family with its own Local Backend contract, capability schema, and Model Packs. +_Avoid_: Model, Model Pack, Model Slot + +**Model Pack**: +A downloadable (or announced) weight set owned by one Engine. Users install and delete Model Packs, not Engines. +_Avoid_: Engine, Model Slot, Model Variant + +**Model Slot**: +A selectable run configuration that points at one Model Pack. One Model Pack may back several Model Slots. +_Avoid_: Engine, Model Pack, Model Variant + **Local Backend**: -The ACE-Step HTTP process managed by OpenLoop on the user's machine. +The on-device HTTP process for the active Engine. ACE-Step is the only bound Local Backend in the current release. **Model Bootstrap**: The local readiness path that decides whether generation can run. @@ -37,7 +49,7 @@ The local readiness path that decides whether generation can run. The OpenLoop-managed filesystem layout required by the Local Backend. **Settings**: -Persisted local configuration for model selection, runtime directories, backend startup, output defaults, language, and first-run state. +Persisted local configuration for Engine and Model Slot selection, runtime directories, backend startup, output defaults, language, and first-run state. ## Relationships @@ -60,11 +72,14 @@ Persisted local configuration for model selection, runtime directories, backend - Missing-file items can be cleared by deleting their **Generation Record** when no **Output File** remains. - **Backend Logs** are diagnostic artifacts with automatic retention, not user-managed history. - **Model Bootstrap** represents whether local generation is ready, not only whether a model is downloading. -- **Model Bootstrap** includes the selected model, model files, **Runtime Layout**, and **Local Backend** health. +- **Model Bootstrap** includes the selected **Model Slot**, its **Model Pack**, **Runtime Layout**, and **Local Backend** health. - **Runtime Layout** may repair OpenLoop-managed links, but must not silently reorganize unknown user-owned files. -- **Settings** that affect **Local Backend** startup are `backendPort`, `modelDirectory`, `backendWorkingDirectory`, `logDirectory`, and `modelVariant`. +- **Settings** that affect **Local Backend** startup are `backendPort`, `modelDirectory`, `backendWorkingDirectory`, `logDirectory`, `modelVariant`, and `selectedModelId`. - Backend-impacting **Settings** changes should tell users they affect the next **Local Backend** start; v1 does not automatically restart the backend. - `modelDirectory` means OpenLoop-managed model storage, not an arbitrary ACE-Step project directory. +- The first-party catalog is the only place new **Engines**, **Model Packs**, and **Model Slots** are registered. Commands, the CLI, and the Settings UI must not hard-code family names when listing or switching. +- An **Engine** without a bound **Local Backend** may appear in the catalog so a future pack (for example MiniMax Music 3 Turbo) can attach later. It must not run a **Generation Task**. +- `modelVariant` is the ACE-Step **Model Slot** alias (`lite` / `turbo` / `pro`). `selectedModelId` is the canonical **Model Slot** id (`ace-step/turbo`, `minimax-music3/turbo`). When both are set, `selectedModelId` wins. - OpenLoop uses its bundled `uv` sidecar for the **Local Backend**; legacy external backend command settings are pruned, not migrated. - User-facing screens should present simple **Generation Task**, **History**, and **Settings** language; implementation details belong in **Advanced Settings** or diagnostics. - **Advanced Settings** are opt-in; beginner workflows should work without understanding **Runtime Layout**, sidecars, IPC commands, or backend internals. diff --git a/docs/agents/domain.md b/docs/agents/domain.md index c0b4aba..135ac34 100644 --- a/docs/agents/domain.md +++ b/docs/agents/domain.md @@ -25,6 +25,11 @@ This is a single-context repo: └── src/ ``` +New **Engines**, **Model Packs**, and **Model Slots** are registered in +`src-tauri/src/services/model_catalog/` (Rust source of truth) and mirrored in +`src/app/lib/model-catalog.ts`. Do not hard-code a new family in commands, the +CLI router, or Settings when listing or switching. + ## Use the glossary's vocabulary When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. diff --git a/docs/cli.md b/docs/cli.md index a13f0d7..3ded62f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -80,17 +80,19 @@ openloop pull pro ### `openloop models` -Manage model variants. +List the first-party Engine / Model Pack catalog, and manage installable ACE-Step packs. ```bash -openloop models # list models (default) -openloop models download turbo # download a variant +openloop models # list engines, slots, and announced packs +openloop models download turbo # download an ACE-Step variant openloop models delete turbo # delete a downloaded variant openloop models cancel turbo # cancel an ongoing download openloop models clear-partial turbo # remove partial download artifacts openloop models delete-all # delete all downloaded models ``` +Announced families (for example `minimax-music3/turbo`) appear in the list so a future pack can keep a stable id. They are not downloadable until an Engine adapter is bound. + | Flag | Description | | -------- | ------------------------------ | | `--json` | JSON output | diff --git a/src-tauri/src/cli/models.rs b/src-tauri/src/cli/models.rs index b895087..ba9ae0a 100644 --- a/src-tauri/src/cli/models.rs +++ b/src-tauri/src/cli/models.rs @@ -9,6 +9,7 @@ use crate::{ models::{errors::AppResult, settings::ModelVariant}, services::{ model_bootstrap::{checkpoints_dir_for, descriptor_for}, + model_catalog::{self, PackInstallPolicy}, model_manager::{read_manifest, ModelManager, ACE_MODEL_DESCRIPTORS}, }, }; @@ -63,32 +64,58 @@ fn execute_list(state: &AppState, json: bool) -> AppResult<()> { } } + let active_slot = model_catalog::selected_slot_id(&settings); + if json { let mut items = Vec::new(); for descriptor in ACE_MODEL_DESCRIPTORS { let is_downloaded = settings.downloaded_models.contains(&descriptor.variant); - let is_active = settings.model_variant == Some(descriptor.variant); + let slot = model_catalog::slot_for_ace_variant(descriptor.variant); + let is_active = active_slot.as_deref() == Some(slot.id); items.push(serde_json::json!({ + "id": slot.id, + "engine": slot.engine.as_str(), + "pack": slot.pack_id, "variant": descriptor.variant.as_str(), "size_gb": descriptor.recommended_memory_gb, "status": if is_downloaded { "downloaded" } else { "not_downloaded" }, "active": is_active, + "selectable": slot.selectable, + })); + } + for pack in model_catalog::CATALOG_PACKS { + if pack.ace_pack.is_some() { + continue; + } + items.push(serde_json::json!({ + "id": pack.id, + "engine": pack.engine.as_str(), + "pack": pack.id, + "variant": serde_json::Value::Null, + "size_gb": pack.recommended_memory_gb, + "status": match pack.install_policy { + PackInstallPolicy::Installable => "not_downloaded", + PackInstallPolicy::Announced => "announced", + }, + "active": active_slot.as_deref() == Some(pack.id), + "selectable": false, })); } let output = serde_json::to_string_pretty(&items).map_err(|e| cli_error(e.to_string()))?; super::json_output(&output); } else { println!( - "{:<10} {:<8} {:<12} Description", - r#"Variant"#, r#"Size"#, r#"Status"# + "{:<18} {:<24} {:<12} Description", + r#"Engine"#, r#"Slot"#, r#"Status"# ); - let separator = "-".repeat(70); + let separator = "-".repeat(86); println!("{separator}"); for descriptor in ACE_MODEL_DESCRIPTORS { let is_downloaded = settings.downloaded_models.contains(&descriptor.variant); - let is_active = settings.model_variant == Some(descriptor.variant); + let slot = model_catalog::slot_for_ace_variant(descriptor.variant); + let is_active = active_slot.as_deref() == Some(slot.id); let status = if is_active { "● active" @@ -98,16 +125,26 @@ fn execute_list(state: &AppState, json: bool) -> AppResult<()> { "—" }; - let size = format!("{}GB", descriptor.recommended_memory_gb); - println!( - "{:<10} {:<8} {:<12} {}", - descriptor.variant.label(), - size, + "{:<18} {:<24} {:<12} {}", + slot.engine.as_str(), + slot.id, status, descriptor.description ); } + for pack in model_catalog::CATALOG_PACKS { + if pack.ace_pack.is_some() { + continue; + } + println!( + "{:<18} {:<24} {:<12} {}", + pack.engine.as_str(), + pack.id, + "announced", + pack.description + ); + } } Ok(()) diff --git a/src-tauri/src/commands/models.rs b/src-tauri/src/commands/models.rs index b929216..2298548 100644 --- a/src-tauri/src/commands/models.rs +++ b/src-tauri/src/commands/models.rs @@ -2,7 +2,10 @@ use tauri::{AppHandle, State}; use crate::{ models::{errors::AppResult, settings::ModelVariant}, - services::model_manager::{AceModelDescriptor, ModelStatusSnapshot, ACE_MODEL_DESCRIPTORS}, + services::{ + model_catalog::{self, ModelRegistry}, + model_manager::{AceModelDescriptor, ModelStatusSnapshot, ACE_MODEL_DESCRIPTORS}, + }, AppState, }; @@ -11,6 +14,11 @@ pub fn list_model_catalog() -> Vec { ACE_MODEL_DESCRIPTORS.to_vec() } +#[tauri::command] +pub fn list_model_registry() -> ModelRegistry { + model_catalog::registry() +} + #[tauri::command] pub fn get_model_status(state: State<'_, AppState>) -> AppResult> { let settings = state.db.get_settings()?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1d23df9..6158bdc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -132,6 +132,7 @@ pub fn run() { commands::settings::remove_cli_from_path, commands::settings::is_cli_in_path, commands::models::list_model_catalog, + commands::models::list_model_registry, commands::models::get_model_status, commands::models::download_model, commands::models::delete_model, diff --git a/src-tauri/src/models/settings.rs b/src-tauri/src/models/settings.rs index 5953d4c..4478b6a 100644 --- a/src-tauri/src/models/settings.rs +++ b/src-tauri/src/models/settings.rs @@ -49,6 +49,7 @@ impl std::fmt::Display for ModelVariant { pub struct AppSettings { pub profile: RecommendedProfile, pub model_variant: Option, + pub selected_model_id: Option, pub downloaded_models: Vec, pub output_directory: Option, pub backend_port: u16, @@ -86,6 +87,7 @@ impl Default for AppSettings { Self { profile: RecommendedProfile::Standard, model_variant: None, + selected_model_id: None, downloaded_models: Vec::new(), output_directory: None, backend_port: 8001, @@ -162,6 +164,7 @@ fn mirrors_to_setting_string(mirrors: &[String]) -> Result Ok(Self::Profile), "modelVariant" => Ok(Self::ModelVariant), + "selectedModelId" => Ok(Self::SelectedModelId), "downloadedModels" => Ok(Self::DownloadedModels), "outputDirectory" => Ok(Self::OutputDirectory), "backendPort" => Ok(Self::BackendPort), @@ -207,6 +211,7 @@ impl SettingKey { match self { Self::Profile => "profile", Self::ModelVariant => "modelVariant", + Self::SelectedModelId => "selectedModelId", Self::DownloadedModels => "downloadedModels", Self::OutputDirectory => "outputDirectory", Self::BackendPort => "backendPort", @@ -232,6 +237,7 @@ impl SettingKey { | Self::BackendWorkingDirectory | Self::LogDirectory | Self::ModelVariant + | Self::SelectedModelId | Self::ModelMirror ) } @@ -250,6 +256,11 @@ impl AppSettings { AppError::validation_failed(format!("invalid modelVariant value: {error}")) })?; } + SettingKey::SelectedModelId => { + self.selected_model_id = serde_json::from_value(value).map_err(|error| { + AppError::validation_failed(format!("invalid selectedModelId value: {error}")) + })?; + } SettingKey::DownloadedModels => { self.downloaded_models = serde_json::from_value(value).map_err(|error| { AppError::validation_failed(format!("invalid downloadedModels value: {error}")) @@ -334,6 +345,10 @@ impl AppSettings { let serialized = vec![ ("profile", serde_json::to_string(&self.profile)), ("modelVariant", serde_json::to_string(&self.model_variant)), + ( + "selectedModelId", + serde_json::to_string(&self.selected_model_id), + ), ( "downloadedModels", serde_json::to_string(&self.downloaded_models), @@ -407,6 +422,7 @@ mod tests { let keys = [ "profile", "modelVariant", + "selectedModelId", "downloadedModels", "outputDirectory", "backendPort", @@ -449,6 +465,7 @@ mod tests { assert!(SettingKey::BackendWorkingDirectory.impacts_backend_startup()); assert!(SettingKey::LogDirectory.impacts_backend_startup()); assert!(SettingKey::ModelVariant.impacts_backend_startup()); + assert!(SettingKey::SelectedModelId.impacts_backend_startup()); assert!(SettingKey::ModelMirror.impacts_backend_startup()); } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 1dd5354..566f8c0 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -7,6 +7,7 @@ pub mod file_store; pub mod generation_task; pub mod history; pub mod model_bootstrap; +pub mod model_catalog; pub mod model_manager; pub mod network_log; pub mod observability; diff --git a/src-tauri/src/services/model_bootstrap.rs b/src-tauri/src/services/model_bootstrap.rs index e35e36b..8ee26f6 100644 --- a/src-tauri/src/services/model_bootstrap.rs +++ b/src-tauri/src/services/model_bootstrap.rs @@ -13,7 +13,10 @@ use crate::{ errors::{AppError, AppResult}, settings::{AppSettings, ModelVariant}, }, - services::model_manager::{AceModelDescriptor, ACE_MODEL_DESCRIPTORS}, + services::{ + model_catalog::{self, EngineRuntimeKind}, + model_manager::{AceModelDescriptor, ACE_MODEL_DESCRIPTORS}, + }, }; #[derive(Debug, Clone)] @@ -27,9 +30,7 @@ pub fn prepare_runtime_layout( app_data_dir: &Path, settings: &AppSettings, ) -> AppResult { - let selected_variant = settings.model_variant.ok_or_else(|| { - AppError::model_not_found("select and download a model before starting the backend") - })?; + let selected_variant = selected_ace_variant(settings)?; let descriptor = descriptor_for(selected_variant)?; let working_directory = runtime_dir_for(app_data_dir, settings); let checkpoints_directory = checkpoints_dir_for(app_data_dir, settings); @@ -55,6 +56,32 @@ pub fn prepare_runtime_layout( }) } +pub fn selected_ace_variant(settings: &AppSettings) -> AppResult { + if let Some(slot_id) = model_catalog::selected_slot_id(settings) { + if let Some(slot) = model_catalog::resolve_slot_id(&slot_id) { + let runtime = model_catalog::engine(slot.engine) + .map(|engine| engine.runtime) + .unwrap_or(EngineRuntimeKind::Unbound); + if !runtime.is_bound() { + return Err(AppError::model_not_found(format!( + "engine '{}' is in the catalog but has no Local Backend adapter yet", + slot.engine.as_str() + ))); + } + if let Some(variant) = slot.ace_variant { + return Ok(variant); + } + } else { + return Err(AppError::model_not_found(format!( + "unknown model slot {slot_id}" + ))); + } + } + settings.model_variant.ok_or_else(|| { + AppError::model_not_found("select and download a model before starting the backend") + }) +} + pub fn descriptor_for(variant: ModelVariant) -> AppResult<&'static AceModelDescriptor> { ACE_MODEL_DESCRIPTORS .iter() @@ -180,6 +207,7 @@ pub fn ensure_runtime_checkpoints_link( #[cfg(test)] mod tests { use super::*; + use crate::models::settings::ModelVariant; #[test] #[cfg(unix)] @@ -218,4 +246,17 @@ mod tests { assert_eq!(error.code, "MODEL_NOT_FOUND"); } + + #[test] + fn unbound_catalog_engine_cannot_prepare_runtime() { + let temp = tempfile::tempdir().expect("temp dir"); + let mut settings = AppSettings::default(); + settings.selected_model_id = Some("minimax-music3/turbo".to_owned()); + settings.model_variant = Some(ModelVariant::Turbo); + + let error = prepare_runtime_layout(temp.path(), &settings).expect_err("unbound engine"); + + assert_eq!(error.code, "MODEL_NOT_FOUND"); + assert!(error.details.unwrap_or_default().contains("minimax-music3")); + } } diff --git a/src-tauri/src/services/model_catalog/mod.rs b/src-tauri/src/services/model_catalog/mod.rs new file mode 100644 index 0000000..71d1b0f --- /dev/null +++ b/src-tauri/src/services/model_catalog/mod.rs @@ -0,0 +1,236 @@ +//! First-party Engine / Model Pack / Model Slot registry. +//! +//! This is the Voicebox-style seam: download, list, and switch resolve through +//! these tables. ACE-Step is the only bound Local Backend today. A future +//! MiniMax Music 3 Turbo (or any other pack) is added by: +//! +//! 1. Registering an [`EngineDescriptor`] if the family is new, and a +//! [`EngineRuntimeKind`] adapter when generation should actually run. +//! 2. Adding a [`ModelPackDescriptor`] with a stable `id` (`engine/pack`). +//! 3. Adding [`ModelSlotDescriptor`] rows users can select. +//! 4. When weights exist, flipping `install_policy` to `Installable` and +//! teaching `ModelManager` the file list — UI and CLI pick the pack up +//! without new Settings keys. +//! +//! Do not add per-engine branches in Tauri commands, the CLI router, or the +//! generation form. Read the registry instead. + +pub mod types; + +pub use types::{ + EngineDescriptor, EngineId, EngineRuntimeKind, ModelPackDescriptor, ModelRegistry, + ModelSlotDescriptor, PackCapabilities, PackInstallPolicy, ACE_STYLE_CAPABILITIES, + MUSIC3_CAPABILITIES, +}; + +use crate::models::settings::ModelVariant; +use crate::services::model_manager::{STANDARD_PACK_TOTAL_BYTES, XL_PACK_TOTAL_BYTES}; + +pub const CATALOG_ENGINES: &[EngineDescriptor] = &[ + EngineDescriptor { + id: EngineId::AceStep, + label: "ACE-Step 1.5", + description: "Local MLX music generation. Bound to the OpenLoop-managed ACE-Step HTTP process.", + runtime: EngineRuntimeKind::AceStepHttp, + }, + EngineDescriptor { + id: EngineId::MiniMaxMusic3, + label: "MiniMax Music 3", + description: "Long-form lyric-conditioned generation. Registered so a future Turbo pack can attach without a Settings rewrite. No Local Backend adapter is bound yet.", + runtime: EngineRuntimeKind::Unbound, + }, +]; + +pub const CATALOG_PACKS: &[ModelPackDescriptor] = &[ + ModelPackDescriptor { + id: "ace-step/standard", + engine: EngineId::AceStep, + label: "Standard", + description: "Shared ACE-Step turbo DiT + 0.6B LM pack used by Lite and Turbo slots.", + install_policy: PackInstallPolicy::Installable, + estimated_size_bytes: STANDARD_PACK_TOTAL_BYTES, + recommended_memory_gb: 16, + capabilities: ACE_STYLE_CAPABILITIES, + ace_pack: Some("standard"), + }, + ModelPackDescriptor { + id: "ace-step/xl", + engine: EngineId::AceStep, + label: "XL", + description: "ACE-Step XL turbo DiT + 1.7B LM pack used by the XL Turbo slot.", + install_policy: PackInstallPolicy::Installable, + estimated_size_bytes: XL_PACK_TOTAL_BYTES, + recommended_memory_gb: 24, + capabilities: ACE_STYLE_CAPABILITIES, + ace_pack: Some("xl"), + }, + ModelPackDescriptor { + id: "minimax-music3/mlx-8bit", + engine: EngineId::MiniMaxMusic3, + label: "MLX 8-bit", + description: "Community 8-bit Apple Silicon pack. Reserved in the catalog; install and generation stay gated until a Local Backend adapter exists.", + install_policy: PackInstallPolicy::Announced, + estimated_size_bytes: 14_167_660_156, + recommended_memory_gb: 32, + capabilities: MUSIC3_CAPABILITIES, + ace_pack: None, + }, + ModelPackDescriptor { + id: "minimax-music3/turbo", + engine: EngineId::MiniMaxMusic3, + label: "Turbo", + description: "Placeholder for a future distilled MiniMax Music 3 pack. Same Engine and capability schema as mlx-8bit; swapping weights should not require a new Settings key.", + install_policy: PackInstallPolicy::Announced, + estimated_size_bytes: 0, + recommended_memory_gb: 16, + capabilities: MUSIC3_CAPABILITIES, + ace_pack: None, + }, +]; + +pub const CATALOG_SLOTS: &[ModelSlotDescriptor] = &[ + ModelSlotDescriptor { + id: "ace-step/lite", + pack_id: "ace-step/standard", + engine: EngineId::AceStep, + label: "Lite", + description: "Lower-memory ACE-Step profile. Uses the Standard pack.", + ace_variant: Some(ModelVariant::Lite), + selectable: true, + }, + ModelSlotDescriptor { + id: "ace-step/turbo", + pack_id: "ace-step/standard", + engine: EngineId::AceStep, + label: "Turbo", + description: "Recommended ACE-Step profile for 16 GB Apple Silicon. Uses the Standard pack.", + ace_variant: Some(ModelVariant::Turbo), + selectable: true, + }, + ModelSlotDescriptor { + id: "ace-step/pro", + pack_id: "ace-step/xl", + engine: EngineId::AceStep, + label: "XL Turbo", + description: "Higher-fidelity ACE-Step profile. Uses the XL pack.", + ace_variant: Some(ModelVariant::Pro), + selectable: true, + }, + ModelSlotDescriptor { + id: "minimax-music3/mlx-8bit", + pack_id: "minimax-music3/mlx-8bit", + engine: EngineId::MiniMaxMusic3, + label: "Music 3 MLX 8-bit", + description: "Not selectable until the MiniMax Music 3 Local Backend is bound.", + ace_variant: None, + selectable: false, + }, + ModelSlotDescriptor { + id: "minimax-music3/turbo", + pack_id: "minimax-music3/turbo", + engine: EngineId::MiniMaxMusic3, + label: "Music 3 Turbo", + description: "Reserved slot. A future distilled pack should keep this id so Settings and History stay stable.", + ace_variant: None, + selectable: false, + }, +]; + +pub fn registry() -> ModelRegistry { + ModelRegistry { + engines: CATALOG_ENGINES.to_vec(), + packs: CATALOG_PACKS.to_vec(), + slots: CATALOG_SLOTS.to_vec(), + } +} + +pub fn engine(id: EngineId) -> Option<&'static EngineDescriptor> { + CATALOG_ENGINES.iter().find(|engine| engine.id == id) +} + +pub fn pack(id: &str) -> Option<&'static ModelPackDescriptor> { + CATALOG_PACKS.iter().find(|pack| pack.id == id) +} + +pub fn slot(id: &str) -> Option<&'static ModelSlotDescriptor> { + CATALOG_SLOTS.iter().find(|slot| slot.id == id) +} + +pub fn slot_for_ace_variant(variant: ModelVariant) -> &'static ModelSlotDescriptor { + CATALOG_SLOTS + .iter() + .find(|slot| slot.ace_variant == Some(variant)) + .expect("every ACE-Step variant has a catalog slot") +} + +pub fn pack_for_ace_variant(variant: ModelVariant) -> &'static ModelPackDescriptor { + pack(slot_for_ace_variant(variant).pack_id).expect("ACE-Step slot references a catalog pack") +} + +/// Resolve a user-facing id (`turbo`, `ace-step/turbo`, `minimax-music3/turbo`). +pub fn resolve_slot_id(input: &str) -> Option<&'static ModelSlotDescriptor> { + if let Some(found) = slot(input) { + return Some(found); + } + match input { + "lite" => slot("ace-step/lite"), + "turbo" => slot("ace-step/turbo"), + "pro" => slot("ace-step/pro"), + _ => None, + } +} + +pub fn selected_slot_id(settings: &crate::models::settings::AppSettings) -> Option { + if let Some(id) = settings.selected_model_id.as_deref() { + if !id.is_empty() { + return Some(id.to_owned()); + } + } + settings + .model_variant + .map(|variant| slot_for_ace_variant(variant).id.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_slot_points_at_a_known_pack_and_engine() { + for slot in CATALOG_SLOTS { + let pack = pack(slot.pack_id).expect(slot.pack_id); + assert_eq!(pack.engine, slot.engine); + assert!(engine(slot.engine).is_some()); + } + } + + #[test] + fn ace_aliases_resolve_to_selectable_slots() { + assert_eq!(resolve_slot_id("turbo").unwrap().id, "ace-step/turbo"); + assert_eq!( + resolve_slot_id("ace-step/lite").unwrap().ace_variant, + Some(ModelVariant::Lite) + ); + assert!(resolve_slot_id("turbo").unwrap().selectable); + } + + #[test] + fn music3_slots_are_registered_but_not_selectable() { + let turbo = slot("minimax-music3/turbo").expect("turbo slot"); + assert!(!turbo.selectable); + assert_eq!( + engine(turbo.engine).unwrap().runtime, + EngineRuntimeKind::Unbound + ); + assert_eq!( + pack(turbo.pack_id).unwrap().install_policy, + PackInstallPolicy::Announced + ); + } + + #[test] + fn unknown_ids_do_not_resolve() { + assert!(resolve_slot_id("suno/v4").is_none()); + assert!(pack("ace-step/missing").is_none()); + } +} diff --git a/src-tauri/src/services/model_catalog/types.rs b/src-tauri/src/services/model_catalog/types.rs new file mode 100644 index 0000000..e6bc269 --- /dev/null +++ b/src-tauri/src/services/model_catalog/types.rs @@ -0,0 +1,140 @@ +use serde::Serialize; + +use crate::models::settings::ModelVariant; + +/// First-party engine identifier. +/// +/// Adding a new family (for example a future MiniMax Music 3 Turbo runtime) +/// means adding a variant here and a matching [`EngineDescriptor`] in the +/// registry — UI, CLI, and settings resolve engines only through that table. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum EngineId { + AceStep, + MiniMaxMusic3, +} + +impl EngineId { + pub const fn as_str(self) -> &'static str { + match self { + Self::AceStep => "ace-step", + Self::MiniMaxMusic3 => "minimax-music3", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "ace-step" => Some(Self::AceStep), + "minimax-music3" => Some(Self::MiniMaxMusic3), + _ => None, + } + } +} + +/// How OpenLoop talks to an engine's Local Backend. +/// +/// `Unbound` packs can appear in the catalog and later grow an adapter +/// without changing Settings or History. Do not start generation for them. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum EngineRuntimeKind { + AceStepHttp, + Unbound, +} + +impl EngineRuntimeKind { + pub const fn is_bound(self) -> bool { + matches!(self, Self::AceStepHttp) + } +} + +/// Whether a Model Pack can be downloaded today. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum PackInstallPolicy { + Installable, + Announced, +} + +/// Generation fields this pack understands. +/// +/// The generation form and CLI should read this instead of hard-coding +/// ACE-Step controls when a new engine is bound. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PackCapabilities { + pub supports_bpm: bool, + pub supports_key: bool, + pub supports_time_signature: bool, + pub supports_thinking: bool, + pub supports_lyrics: bool, + pub prompt_role: &'static str, + pub max_duration_seconds: f64, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EngineDescriptor { + pub id: EngineId, + pub label: &'static str, + pub description: &'static str, + pub runtime: EngineRuntimeKind, +} + +/// Downloadable (or announced) weight set owned by one Engine. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelPackDescriptor { + pub id: &'static str, + pub engine: EngineId, + pub label: &'static str, + pub description: &'static str, + pub install_policy: PackInstallPolicy, + pub estimated_size_bytes: u64, + pub recommended_memory_gb: u64, + pub capabilities: PackCapabilities, + /// Legacy ACE-Step pack key (`standard` / `xl`) used by the existing UI. + pub ace_pack: Option<&'static str>, +} + +/// Selectable run configuration. One Model Pack may back several slots +/// (ACE-Step Lite and Turbo share the Standard pack). +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelSlotDescriptor { + pub id: &'static str, + pub pack_id: &'static str, + pub engine: EngineId, + pub label: &'static str, + pub description: &'static str, + pub ace_variant: Option, + pub selectable: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelRegistry { + pub engines: Vec, + pub packs: Vec, + pub slots: Vec, +} + +pub const ACE_STYLE_CAPABILITIES: PackCapabilities = PackCapabilities { + supports_bpm: true, + supports_key: true, + supports_time_signature: true, + supports_thinking: true, + supports_lyrics: true, + prompt_role: "style-and-lyrics", + max_duration_seconds: 600.0, +}; + +pub const MUSIC3_CAPABILITIES: PackCapabilities = PackCapabilities { + supports_bpm: false, + supports_key: false, + supports_time_signature: false, + supports_thinking: false, + supports_lyrics: true, + prompt_role: "caption-and-lyrics", + max_duration_seconds: 360.0, +}; diff --git a/src/app/components/settings/SettingsOverlay/CatalogPackCard.tsx b/src/app/components/settings/SettingsOverlay/CatalogPackCard.tsx new file mode 100644 index 0000000..02c7487 --- /dev/null +++ b/src/app/components/settings/SettingsOverlay/CatalogPackCard.tsx @@ -0,0 +1,50 @@ +import { useTranslation } from "react-i18next"; +import type { ModelPackDescriptor, ModelDownloadState } from "@/app/lib/types"; +import { StateBadge } from "./StateBadge"; + +function bytesToLabel(bytes: number) { + if (!bytes) return "—"; + return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`; +} + +interface CatalogPackCardProps { + pack: ModelPackDescriptor; + state?: ModelDownloadState; + downloadedBytes?: number; + totalBytes?: number; +} + +export function CatalogPackCard({ + pack, + state = "not_installed", + downloadedBytes = 0, + totalBytes, +}: CatalogPackCardProps) { + const { t } = useTranslation(); + const announced = pack.installPolicy === "announced"; + + return ( +
+
+
+
+

{pack.label}

+ {announced ? ( + + {t("model.announced")} + + ) : ( + + )} +
+

{pack.description}

+

+ {announced + ? t("model.recommendedMemory", { gb: pack.recommendedMemoryGb }) + : `${bytesToLabel(downloadedBytes)} / ${bytesToLabel(totalBytes ?? pack.estimatedSizeBytes)}`} +

+
+
+
+ ); +} diff --git a/src/app/components/settings/sections/ModelsSection.tsx b/src/app/components/settings/sections/ModelsSection.tsx index b562fc0..c490707 100644 --- a/src/app/components/settings/sections/ModelsSection.tsx +++ b/src/app/components/settings/sections/ModelsSection.tsx @@ -1,8 +1,10 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { SettingsSectionCard } from "@/app/components/settings/SettingsSectionCard"; +import { CatalogPackCard } from "@/app/components/settings/SettingsOverlay/CatalogPackCard"; import { ModelPackCard } from "@/app/components/settings/SettingsOverlay/ModelPackCard"; import { ModelVariantCard } from "@/app/components/settings/SettingsOverlay/ModelVariantCard"; +import { DEFAULT_MODEL_REGISTRY, packsForEngine } from "@/app/lib/model-catalog"; import { MODEL_PACKS, aggregatePackStatus, @@ -16,6 +18,8 @@ import type { ModelVariant } from "@/app/lib/types"; export function ModelsSection() { const { t } = useTranslation(); const modelStatuses = useGenerationStore((state) => state.modelStatuses); + const modelRegistry = + useGenerationStore((state) => state.modelRegistry) ?? DEFAULT_MODEL_REGISTRY; const settings = useGenerationStore((state) => state.settings); const selectModelVariant = useGenerationStore((state) => state.selectModelVariant); const downloadModelVariant = useGenerationStore((state) => state.downloadModelVariant); @@ -26,6 +30,8 @@ export function ModelsSection() { ); const [busyVariant, setBusyVariant] = useState(null); + const acePacks = packsForEngine(modelRegistry, "ace-step"); + const otherEngines = modelRegistry.engines.filter((engine) => engine.id !== "ace-step"); return ( } > -
+
+

ACE-Step 1.5

+

+ {t("settings.aceEngineDescription")} +

+
+ +

{t("settings.modelPacks")}

- {(Object.keys(MODEL_PACKS) as ModelPackId[]).map((packId) => { + {acePacks.map((pack) => { + const packId = (pack.acePack ?? "standard") as ModelPackId; const packStatus = aggregatePackStatus(modelStatuses, packId); const primary = primaryVariantForPack(packId); return ( + + {otherEngines.map((engine) => ( +
+
+

{engine.label}

+

+ {engine.description} +

+
+ {packsForEngine(modelRegistry, engine.id).map((pack) => ( + + ))} +
+ ))} ); } diff --git a/src/app/lib/api.ts b/src/app/lib/api.ts index 831cb95..4907548 100644 --- a/src/app/lib/api.ts +++ b/src/app/lib/api.ts @@ -13,6 +13,7 @@ import type { GenerationRunResult, GenerationWaveform, ModelCatalogItem, + ModelRegistry, ModelStatusSnapshot, GenerationRecord, GenerationRequest, @@ -177,6 +178,10 @@ export function listModelCatalog(): Promise { return invoke("list_model_catalog"); } +export function listModelRegistry(): Promise { + return invoke("list_model_registry"); +} + export function getModelStatus(): Promise { return invoke("get_model_status"); } diff --git a/src/app/lib/model-bootstrap.ts b/src/app/lib/model-bootstrap.ts index 2f6db42..42511c7 100644 --- a/src/app/lib/model-bootstrap.ts +++ b/src/app/lib/model-bootstrap.ts @@ -18,6 +18,7 @@ import type { export const DEFAULT_APP_SETTINGS: AppSettings = { profile: "standard", modelVariant: null, + selectedModelId: null, downloadedModels: [], outputDirectory: null, backendPort: 8001, diff --git a/src/app/lib/model-catalog.ts b/src/app/lib/model-catalog.ts new file mode 100644 index 0000000..798a191 --- /dev/null +++ b/src/app/lib/model-catalog.ts @@ -0,0 +1,167 @@ +import type { + CatalogPackId, + CatalogSlotId, + EngineDescriptor, + EngineId, + ModelPackDescriptor, + ModelRegistry, + ModelSlotDescriptor, + ModelVariant, + PackCapabilities, +} from "@/app/lib/types"; + +const ACE_STYLE_CAPABILITIES: PackCapabilities = { + supportsBpm: true, + supportsKey: true, + supportsTimeSignature: true, + supportsThinking: true, + supportsLyrics: true, + promptRole: "style-and-lyrics", + maxDurationSeconds: 600, +}; + +const MUSIC3_CAPABILITIES: PackCapabilities = { + supportsBpm: false, + supportsKey: false, + supportsTimeSignature: false, + supportsThinking: false, + supportsLyrics: true, + promptRole: "caption-and-lyrics", + maxDurationSeconds: 360, +}; + +export const CATALOG_ENGINES: EngineDescriptor[] = [ + { + id: "ace-step", + label: "ACE-Step 1.5", + description: "Local MLX music generation. Bound to the OpenLoop-managed ACE-Step HTTP process.", + runtime: "ace-step-http", + }, + { + id: "minimax-music3", + label: "MiniMax Music 3", + description: + "Long-form lyric-conditioned generation. Registered so a future Turbo pack can attach without a Settings rewrite. No Local Backend adapter is bound yet.", + runtime: "unbound", + }, +]; + +export const CATALOG_PACKS: ModelPackDescriptor[] = [ + { + id: "ace-step/standard", + engine: "ace-step", + label: "Standard", + description: "Shared ACE-Step turbo DiT + 0.6B LM pack used by Lite and Turbo slots.", + installPolicy: "installable", + estimatedSizeBytes: 8 * 1024 * 1024 * 1024, + recommendedMemoryGb: 16, + capabilities: ACE_STYLE_CAPABILITIES, + acePack: "standard", + }, + { + id: "ace-step/xl", + engine: "ace-step", + label: "XL", + description: "ACE-Step XL turbo DiT + 1.7B LM pack used by the XL Turbo slot.", + installPolicy: "installable", + estimatedSizeBytes: 22 * 1024 * 1024 * 1024, + recommendedMemoryGb: 24, + capabilities: ACE_STYLE_CAPABILITIES, + acePack: "xl", + }, + { + id: "minimax-music3/mlx-8bit", + engine: "minimax-music3", + label: "MLX 8-bit", + description: + "Community 8-bit Apple Silicon pack. Reserved until a Local Backend adapter exists.", + installPolicy: "announced", + estimatedSizeBytes: 14_167_660_156, + recommendedMemoryGb: 32, + capabilities: MUSIC3_CAPABILITIES, + acePack: null, + }, + { + id: "minimax-music3/turbo", + engine: "minimax-music3", + label: "Turbo", + description: + "Placeholder for a future distilled MiniMax Music 3 pack. Same Engine and capability schema as mlx-8bit.", + installPolicy: "announced", + estimatedSizeBytes: 0, + recommendedMemoryGb: 16, + capabilities: MUSIC3_CAPABILITIES, + acePack: null, + }, +]; + +export const CATALOG_SLOTS: ModelSlotDescriptor[] = [ + { + id: "ace-step/lite", + packId: "ace-step/standard", + engine: "ace-step", + label: "Lite", + description: "Lower-memory ACE-Step profile. Uses the Standard pack.", + aceVariant: "lite", + selectable: true, + }, + { + id: "ace-step/turbo", + packId: "ace-step/standard", + engine: "ace-step", + label: "Turbo", + description: "Recommended ACE-Step profile for 16 GB Apple Silicon. Uses the Standard pack.", + aceVariant: "turbo", + selectable: true, + }, + { + id: "ace-step/pro", + packId: "ace-step/xl", + engine: "ace-step", + label: "XL Turbo", + description: "Higher-fidelity ACE-Step profile. Uses the XL pack.", + aceVariant: "pro", + selectable: true, + }, + { + id: "minimax-music3/mlx-8bit", + packId: "minimax-music3/mlx-8bit", + engine: "minimax-music3", + label: "Music 3 MLX 8-bit", + description: "Not selectable until the MiniMax Music 3 Local Backend is bound.", + aceVariant: null, + selectable: false, + }, + { + id: "minimax-music3/turbo", + packId: "minimax-music3/turbo", + engine: "minimax-music3", + label: "Music 3 Turbo", + description: "Reserved slot for a future distilled pack.", + aceVariant: null, + selectable: false, + }, +]; + +export const DEFAULT_MODEL_REGISTRY: ModelRegistry = { + engines: CATALOG_ENGINES, + packs: CATALOG_PACKS, + slots: CATALOG_SLOTS, +}; + +export function slotIdForVariant(variant: ModelVariant): CatalogSlotId { + if (variant === "lite") return "ace-step/lite"; + if (variant === "pro") return "ace-step/pro"; + return "ace-step/turbo"; +} + +export function packsForEngine(registry: ModelRegistry, engineId: EngineId): ModelPackDescriptor[] { + return registry.packs.filter((pack) => pack.engine === engineId); +} + +export function slotsForPack( + registry: ModelRegistry, + packId: CatalogPackId, +): ModelSlotDescriptor[] { + return registry.slots.filter((slot) => slot.packId === packId); +} diff --git a/src/app/lib/store/slices/model-sync-actions.ts b/src/app/lib/store/slices/model-sync-actions.ts index 8227e7f..9ba19c2 100644 --- a/src/app/lib/store/slices/model-sync-actions.ts +++ b/src/app/lib/store/slices/model-sync-actions.ts @@ -3,6 +3,7 @@ import type { StoreApi } from "zustand"; import type { AppSettings, BackendProvisionStatus } from "@/app/lib/types"; import * as api from "@/app/lib/api"; import { expandDownloadedVariantsFromStatuses } from "@/app/lib/model-packs"; +import { DEFAULT_MODEL_REGISTRY } from "@/app/lib/model-catalog"; import { localizeModelStatuses } from "@/app/lib/errors"; import { resolveModelBootstrapStatus } from "@/app/lib/model-bootstrap"; @@ -48,8 +49,9 @@ export function createModelSyncActions( refreshModelStatuses: async () => { if (!api.isTauriRuntime()) return; - const [modelCatalog, rawModelStatuses, backendProvision] = await Promise.all([ + const [modelCatalog, modelRegistry, rawModelStatuses, backendProvision] = await Promise.all([ api.listModelCatalog(), + api.listModelRegistry().catch(() => DEFAULT_MODEL_REGISTRY), api.getModelStatus(), api .getBackendProvisionStatus() @@ -59,6 +61,7 @@ export function createModelSyncActions( const downloadedModels = expandDownloadedVariantsFromStatuses(modelStatuses); set((state) => ({ modelCatalog, + modelRegistry, modelStatuses, backendProvisionStatus: backendProvision, settings: { diff --git a/src/app/lib/store/slices/model.ts b/src/app/lib/store/slices/model.ts index c52f647..ea2d812 100644 --- a/src/app/lib/store/slices/model.ts +++ b/src/app/lib/store/slices/model.ts @@ -18,6 +18,7 @@ import { computeValidationState } from "@/app/lib/validation-helpers"; import { resolveModelBootstrapStatus } from "@/app/lib/model-bootstrap"; import { tr } from "@/app/lib/i18n"; import { MODEL_CATALOG } from "./model-catalog"; +import { DEFAULT_MODEL_REGISTRY, slotIdForVariant } from "@/app/lib/model-catalog"; import { createBackendProvisionActions } from "./backend-provision-actions"; import { computeModelStatusPatch } from "./model-status-apply"; import { createModelSyncActions } from "./model-sync-actions"; @@ -32,6 +33,7 @@ export function createModelSlice( message: tr("status.chooseAndDownload"), } as const, modelCatalog: MODEL_CATALOG, + modelRegistry: DEFAULT_MODEL_REGISTRY, modelStatuses: [], backendProvisionStatus: { state: "not_installed", @@ -57,6 +59,7 @@ export function createModelSlice( get().applyModelStatus(initialStatus); await Promise.all([ api.setSetting("modelVariant", variant), + api.setSetting("selectedModelId", slotIdForVariant(variant)), api.setSetting("profile", profileForVariant(variant)), api.setSetting( "defaultThinking", @@ -70,6 +73,7 @@ export function createModelSlice( ...state.settings, profile, modelVariant: variant, + selectedModelId: slotIdForVariant(variant), defaultThinking: PROFILE_FORM_PRESETS[profile].thinking, }, form: nextForm, @@ -85,6 +89,7 @@ export function createModelSlice( ...get().settings, profile: profileForVariant(variant), modelVariant: variant, + selectedModelId: slotIdForVariant(variant), downloadedModels: nextDownloadedModels, }; const nextForm = applyModelVariantToForm( @@ -162,6 +167,7 @@ export function createModelSlice( if (api.isTauriRuntime()) { await Promise.all([ api.setSetting("modelVariant", variant), + api.setSetting("selectedModelId", slotIdForVariant(variant)), api.setSetting("profile", profile), api.setSetting("defaultThinking", PROFILE_FORM_PRESETS[profile].thinking), ]); @@ -174,6 +180,7 @@ export function createModelSlice( profile, defaultThinking: PROFILE_FORM_PRESETS[profile].thinking, modelVariant: variant, + selectedModelId: slotIdForVariant(variant), }; const nextForm = applyModelVariantToForm(applyProfilePreset(get().form, profile), variant); set({ diff --git a/src/app/lib/store/slices/settings.ts b/src/app/lib/store/slices/settings.ts index 2770543..623d407 100644 --- a/src/app/lib/store/slices/settings.ts +++ b/src/app/lib/store/slices/settings.ts @@ -11,6 +11,7 @@ import { import { computeValidationState } from "@/app/lib/validation-helpers"; import { DEFAULT_APP_SETTINGS, resolveModelBootstrapStatus } from "@/app/lib/model-bootstrap"; import { expandDownloadedVariantsFromStatuses } from "@/app/lib/model-packs"; +import { DEFAULT_MODEL_REGISTRY } from "@/app/lib/model-catalog"; import i18next, { detectSystemLanguage, tr } from "@/app/lib/i18n"; import { @@ -127,6 +128,7 @@ export function createSettingsSlice( persistedHistory, deviceInfo, modelCatalog, + modelRegistry, rawModelStatuses, activeTasks, projects, @@ -135,6 +137,7 @@ export function createSettingsSlice( api.listGenerations(), api.getDeviceInfo(), api.listModelCatalog(), + api.listModelRegistry().catch(() => DEFAULT_MODEL_REGISTRY), api.getModelStatus(), api.listActiveGenerationTasks(), api.listProjects(), @@ -164,6 +167,7 @@ export function createSettingsSlice( hydrated: true, deviceInfo, modelCatalog, + modelRegistry, modelStatuses, settings: mergedSettings, form: nextForm, diff --git a/src/app/lib/store/types.ts b/src/app/lib/store/types.ts index 0e33033..712c28b 100644 --- a/src/app/lib/store/types.ts +++ b/src/app/lib/store/types.ts @@ -10,6 +10,7 @@ import type { GenerationRequest, GenerationState, ModelCatalogItem, + ModelRegistry, ModelStatusSnapshot, ModelVariant, ModelBootstrapStatus, @@ -22,6 +23,7 @@ export interface GenerationStore { deviceInfo: DeviceInfo | null; bootstrapStatus: ModelBootstrapStatus; modelCatalog: ModelCatalogItem[]; + modelRegistry: ModelRegistry; modelStatuses: ModelStatusSnapshot[]; backendProvisionStatus: BackendProvisionStatus; isSettingsOpen: boolean; diff --git a/src/app/lib/types.ts b/src/app/lib/types.ts index 1aba5c3..df7835a 100644 --- a/src/app/lib/types.ts +++ b/src/app/lib/types.ts @@ -6,6 +6,20 @@ export type BpmMode = "auto" | "manual"; export type RecommendedProfile = "low-memory" | "standard" | "quality" | "unsupported"; export type ModelVariant = "lite" | "turbo" | "pro"; +export type EngineId = "ace-step" | "minimax-music3"; +export type EngineRuntimeKind = "ace-step-http" | "unbound"; +export type PackInstallPolicy = "installable" | "announced"; +export type CatalogPackId = + | "ace-step/standard" + | "ace-step/xl" + | "minimax-music3/mlx-8bit" + | "minimax-music3/turbo"; +export type CatalogSlotId = + | "ace-step/lite" + | "ace-step/turbo" + | "ace-step/pro" + | "minimax-music3/mlx-8bit" + | "minimax-music3/turbo"; export type TaskType = "text2music" | "cover" | "repaint" | "lego" | "extract" | "complete"; export type LmBackend = "pt" | "vllm" | "mlx"; @@ -214,9 +228,55 @@ export type GenerationState = { progressPercent?: number; }; +export type PackCapabilities = { + supportsBpm: boolean; + supportsKey: boolean; + supportsTimeSignature: boolean; + supportsThinking: boolean; + supportsLyrics: boolean; + promptRole: "style-and-lyrics" | "caption-and-lyrics"; + maxDurationSeconds: number; +}; + +export type EngineDescriptor = { + id: EngineId; + label: string; + description: string; + runtime: EngineRuntimeKind; +}; + +export type ModelPackDescriptor = { + id: CatalogPackId; + engine: EngineId; + label: string; + description: string; + installPolicy: PackInstallPolicy; + estimatedSizeBytes: number; + recommendedMemoryGb: number; + capabilities: PackCapabilities; + acePack: "standard" | "xl" | null; +}; + +export type ModelSlotDescriptor = { + id: CatalogSlotId; + packId: CatalogPackId; + engine: EngineId; + label: string; + description: string; + aceVariant: ModelVariant | null; + selectable: boolean; +}; + +export type ModelRegistry = { + engines: EngineDescriptor[]; + packs: ModelPackDescriptor[]; + slots: ModelSlotDescriptor[]; +}; + export type AppSettings = { profile: RecommendedProfile; modelVariant: ModelVariant | null; + selectedModelId?: CatalogSlotId | string | null; downloadedModels: ModelVariant[]; outputDirectory: string | null; backendPort: number; diff --git a/src/locales/en.json b/src/locales/en.json index 73863bb..6d2fc7f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -57,7 +57,8 @@ "title": "Settings", "description": "Manage language, models, and default generation preferences.", "models": "Models", - "modelsDescription": "Download official ACE-Step 1.5 weight packs, watch progress, and remove the ones you don't need.", + "modelsDescription": "Download, manage, and switch Engines and Model Packs. ACE-Step 1.5 is bound today; announced families appear so a future pack can attach without a Settings rewrite.", + "aceEngineDescription": "Local MLX generation. Lite, Turbo, and XL Turbo are Model Slots on the Standard and XL packs.", "modelPacks": "Model packs", "runProfiles": "Runtime profiles", "defaults": "Defaults", @@ -191,7 +192,9 @@ "chooseModel": "Choose model", "openSettings": "Open settings", "active": "Active", - "ready_short": "Ready" + "ready_short": "Ready", + "announced": "Announced", + "recommendedMemory": "{{gb}} GB recommended" }, "modelPacks": { "standard": { diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index ff4484f..2776ecf 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -57,7 +57,8 @@ "title": "设置", "description": "管理语言、模型和默认生成参数。", "models": "模型", - "modelsDescription": "下载官方 ACE-Step 1.5 权重包,跟踪进度,并删除不再需要的副本。", + "modelsDescription": "下载、管理和切换引擎与模型包。当前已接入 ACE-Step 1.5;已登记的引擎会先出现在目录里,方便以后的 Turbo 包直接挂上。", + "aceEngineDescription": "本机 MLX 生成。Lite、Turbo、XL Turbo 是 Standard / XL 权重包上的运行槽位。", "modelPacks": "模型权重包", "runProfiles": "运行配置档", "defaults": "默认值", @@ -191,7 +192,9 @@ "chooseModel": "选择模型", "openSettings": "打开设置", "active": "正在使用", - "ready_short": "可用" + "ready_short": "可用", + "announced": "已登记", + "recommendedMemory": "建议 {{gb}} GB 内存" }, "modelPacks": { "standard": { diff --git a/tests/unit/api.test.ts b/tests/unit/api.test.ts index 309bc84..d30354a 100644 --- a/tests/unit/api.test.ts +++ b/tests/unit/api.test.ts @@ -510,6 +510,18 @@ describe("listModelCatalog", () => { }); }); +describe("listModelRegistry", () => { + it("calls 'list_model_registry' with no args", async () => { + const registry = { engines: [], packs: [], slots: [] }; + mockInvoke.mockResolvedValue(registry); + + const result = await api.listModelRegistry(); + + expect(mockInvoke).toHaveBeenCalledWith("list_model_registry"); + expect(result).toBe(registry); + }); +}); + describe("getModelStatus", () => { it("calls 'get_model_status' with no args", async () => { const statuses = [{ variant: "turbo", state: "ready" }] as any; diff --git a/tests/unit/model-catalog.test.ts b/tests/unit/model-catalog.test.ts new file mode 100644 index 0000000..5259bd6 --- /dev/null +++ b/tests/unit/model-catalog.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + CATALOG_ENGINES, + CATALOG_PACKS, + CATALOG_SLOTS, + packsForEngine, + slotIdForVariant, + slotsForPack, +} from "@/app/lib/model-catalog"; +import { DEFAULT_MODEL_REGISTRY } from "@/app/lib/model-catalog"; + +describe("model catalog", () => { + it("registers ACE-Step as the only bound engine", () => { + const ace = CATALOG_ENGINES.find((engine) => engine.id === "ace-step"); + const music3 = CATALOG_ENGINES.find((engine) => engine.id === "minimax-music3"); + expect(ace?.runtime).toBe("ace-step-http"); + expect(music3?.runtime).toBe("unbound"); + }); + + it("keeps a stable turbo slot for a future Music 3 pack", () => { + const turbo = CATALOG_SLOTS.find((slot) => slot.id === "minimax-music3/turbo"); + expect(turbo).toBeDefined(); + expect(turbo?.selectable).toBe(false); + expect(turbo?.packId).toBe("minimax-music3/turbo"); + }); + + it("maps ACE-Step variants onto catalog slots", () => { + expect(slotIdForVariant("lite")).toBe("ace-step/lite"); + expect(slotIdForVariant("turbo")).toBe("ace-step/turbo"); + expect(slotIdForVariant("pro")).toBe("ace-step/pro"); + }); + + it("groups packs by engine", () => { + const acePacks = packsForEngine(DEFAULT_MODEL_REGISTRY, "ace-step"); + const music3Packs = packsForEngine(DEFAULT_MODEL_REGISTRY, "minimax-music3"); + expect(acePacks.map((pack) => pack.id)).toEqual(["ace-step/standard", "ace-step/xl"]); + expect(music3Packs.every((pack) => pack.installPolicy === "announced")).toBe(true); + }); + + it("shares the Standard pack across Lite and Turbo slots", () => { + const slots = slotsForPack(DEFAULT_MODEL_REGISTRY, "ace-step/standard"); + expect(slots.map((slot) => slot.aceVariant)).toEqual(["lite", "turbo"]); + }); + + it("has a pack for every slot", () => { + const packIds = new Set(CATALOG_PACKS.map((pack) => pack.id)); + for (const slot of CATALOG_SLOTS) { + expect(packIds.has(slot.packId)).toBe(true); + } + }); +}); diff --git a/tests/unit/model-slice.test.ts b/tests/unit/model-slice.test.ts index c315e6d..8d9ff5b 100644 --- a/tests/unit/model-slice.test.ts +++ b/tests/unit/model-slice.test.ts @@ -20,6 +20,7 @@ const mockApi = { clearPartialDownloads: vi.fn(), deleteAllModels: vi.fn(), listModelCatalog: vi.fn(), + listModelRegistry: vi.fn(() => Promise.resolve({ engines: [], packs: [], slots: [] })), getModelStatus: vi.fn(), getBackendProvisionStatus: vi.fn(), provisionBackend: vi.fn(), @@ -349,6 +350,7 @@ describe("downloadModelVariant", () => { await store.getState().downloadModelVariant("turbo"); expect(mockApi.setSetting).toHaveBeenCalledWith("modelVariant", "turbo"); + expect(mockApi.setSetting).toHaveBeenCalledWith("selectedModelId", "ace-step/turbo"); expect(mockApi.setSetting).toHaveBeenCalledWith("profile", "standard"); expect(mockApi.setSetting).toHaveBeenCalledWith("defaultThinking", expect.any(Boolean)); }); @@ -658,6 +660,7 @@ describe("selectModelVariant", () => { await store.getState().selectModelVariant("pro"); expect(mockApi.setSetting).toHaveBeenCalledWith("modelVariant", "pro"); + expect(mockApi.setSetting).toHaveBeenCalledWith("selectedModelId", "ace-step/pro"); expect(mockApi.setSetting).toHaveBeenCalledWith("profile", "quality"); expect(mockApi.setSetting).toHaveBeenCalledWith("defaultThinking", expect.any(Boolean)); }); diff --git a/tests/unit/settings-overlay-subcomponents.test.tsx b/tests/unit/settings-overlay-subcomponents.test.tsx index d9843cf..48f8659 100644 --- a/tests/unit/settings-overlay-subcomponents.test.tsx +++ b/tests/unit/settings-overlay-subcomponents.test.tsx @@ -16,7 +16,9 @@ vi.mock("react-i18next", () => ({ import { StateBadge } from "@/app/components/settings/SettingsOverlay/StateBadge"; import { ModelVariantCard } from "@/app/components/settings/SettingsOverlay/ModelVariantCard"; import { ModelPackCard } from "@/app/components/settings/SettingsOverlay/ModelPackCard"; +import { CatalogPackCard } from "@/app/components/settings/SettingsOverlay/CatalogPackCard"; import { DirectoryPickerRow } from "@/app/components/settings/SettingsOverlay/DirectoryPickerRow"; +import type { ModelPackDescriptor } from "@/app/lib/types"; // --------------------------------------------------------------------------- // StateBadge @@ -331,3 +333,68 @@ describe("ModelPackCard", () => { expect(screen.getByText(/50%/)).toBeTruthy(); }); }); + +// --------------------------------------------------------------------------- +// CatalogPackCard +// --------------------------------------------------------------------------- + +const announcedPack: ModelPackDescriptor = { + id: "minimax-music3/turbo", + engine: "minimax-music3", + label: "Turbo", + description: "Reserved distilled pack.", + installPolicy: "announced", + estimatedSizeBytes: 0, + recommendedMemoryGb: 16, + capabilities: { + supportsBpm: false, + supportsKey: false, + supportsTimeSignature: false, + supportsThinking: false, + supportsLyrics: true, + promptRole: "caption-and-lyrics", + maxDurationSeconds: 360, + }, + acePack: null, +}; + +const installablePack: ModelPackDescriptor = { + ...announcedPack, + id: "ace-step/standard", + engine: "ace-step", + label: "Standard", + description: "Installable catalog pack.", + installPolicy: "installable", + estimatedSizeBytes: 8 * 1024 * 1024 * 1024, + recommendedMemoryGb: 16, + acePack: "standard", +}; + +describe("CatalogPackCard", () => { + it("shows an announced badge and recommended memory", () => { + render(); + expect(screen.getByText("Turbo")).toBeTruthy(); + expect(screen.getByText("Reserved distilled pack.")).toBeTruthy(); + expect(screen.getByText("model.announced")).toBeTruthy(); + expect(screen.getByText("model.recommendedMemory")).toBeTruthy(); + }); + + it("shows download progress for an installable pack", () => { + render( + , + ); + expect(screen.getByText("model.downloading")).toBeTruthy(); + expect(screen.getByText(/2\.0 GB/)).toBeTruthy(); + expect(screen.getByText(/8\.0 GB/)).toBeTruthy(); + }); + + it("renders an em dash when no bytes have been downloaded", () => { + render(); + expect(screen.getByText("— / —")).toBeTruthy(); + }); +}); diff --git a/tests/unit/settings-sections.test.tsx b/tests/unit/settings-sections.test.tsx index 91618b9..fd5aa3f 100644 --- a/tests/unit/settings-sections.test.tsx +++ b/tests/unit/settings-sections.test.tsx @@ -623,10 +623,16 @@ describe("ModelsSection", () => { it("renders variant cards for lite, turbo, and pro", () => { render(); expect(screen.getByText("Lite")).toBeTruthy(); - expect(screen.getByText("Turbo")).toBeTruthy(); + expect(screen.getAllByText("Turbo").length).toBeGreaterThan(0); expect(screen.getByText("XL Turbo")).toBeTruthy(); }); + it("renders announced MiniMax Music 3 packs from the catalog", () => { + render(); + expect(screen.getByText("MiniMax Music 3")).toBeTruthy(); + expect(screen.getAllByText("model.announced").length).toBeGreaterThan(0); + }); + it("renders pack cards for standard and xl", () => { render(); // Model pack labels diff --git a/tests/unit/settings-slice.test.ts b/tests/unit/settings-slice.test.ts index 22a45fe..22403da 100644 --- a/tests/unit/settings-slice.test.ts +++ b/tests/unit/settings-slice.test.ts @@ -27,6 +27,7 @@ vi.mock("@/app/lib/api", () => ({ getDeviceInfo: vi.fn(() => Promise.resolve(null)), listGenerations: vi.fn(() => Promise.resolve([])), listModelCatalog: vi.fn(() => Promise.resolve([])), + listModelRegistry: vi.fn(() => Promise.resolve({ engines: [], packs: [], slots: [] })), getModelStatus: vi.fn(() => Promise.resolve([])), listActiveGenerationTasks: vi.fn(() => Promise.resolve([])), listProjects: vi.fn(() => Promise.resolve([])), diff --git a/tests/unit/store.test.ts b/tests/unit/store.test.ts index e12e171..1b0f7a8 100644 --- a/tests/unit/store.test.ts +++ b/tests/unit/store.test.ts @@ -14,6 +14,7 @@ vi.mock("@/app/lib/api", () => ({ listGenerations: vi.fn(), getDeviceInfo: vi.fn(), listModelCatalog: vi.fn(), + listModelRegistry: vi.fn(() => Promise.resolve({ engines: [], packs: [], slots: [] })), getModelStatus: vi.fn(), listActiveGenerationTasks: vi.fn(), listProjects: vi.fn(() => Promise.resolve([])),