diff --git a/src-tauri/migrations/007_add_profiles.sql b/src-tauri/migrations/007_add_profiles.sql new file mode 100644 index 0000000..b0d53c2 --- /dev/null +++ b/src-tauri/migrations/007_add_profiles.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS generation_profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + model_variant TEXT, + duration_seconds REAL, + audio_format TEXT, + thinking INTEGER, + inference_steps INTEGER, + guidance_scale REAL, + bpm INTEGER, + key_scale TEXT, + time_signature TEXT, + vocal_language TEXT, + lm_backend TEXT +); + +CREATE INDEX IF NOT EXISTS idx_generation_profiles_name ON generation_profiles(name); diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index e99fd45..a52e5d0 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -9,6 +9,7 @@ mod files; mod generation; mod list; mod models; +mod profiles; mod project; mod ps; mod pull; @@ -71,6 +72,7 @@ fn run_inner(args: Vec) -> AppResult<()> { } spec::Commands::List(args) => list::execute(&state, json, args), spec::Commands::Project { command } => project::execute(&state, json, command), + spec::Commands::Profiles { command } => profiles::execute(&state, json, command), spec::Commands::Delete(args) => delete::execute(&state, json, args), spec::Commands::Clear(args) => clear::execute(&state, json, args), spec::Commands::Ps => ps::execute(&state, json), diff --git a/src-tauri/src/cli/profiles.rs b/src-tauri/src/cli/profiles.rs new file mode 100644 index 0000000..459a93f --- /dev/null +++ b/src-tauri/src/cli/profiles.rs @@ -0,0 +1,211 @@ +use crate::{ + cli::{cli_error, human_output, json_output, spec::ProfileCommand}, + models::{ + errors::{AppError, AppResult}, + profile::CreateProfileRequest, + }, +}; + +use super::AppState; + +pub fn execute(state: &AppState, json: bool, command: ProfileCommand) -> AppResult<()> { + match command { + ProfileCommand::List => cmd_list(state, json), + ProfileCommand::Create { + name, + model, + duration, + format, + thinking, + steps, + guidance, + bpm, + key, + time_signature, + language, + lm_backend, + } => cmd_create( + state, + json, + &name, + model, + duration, + format, + thinking, + steps, + guidance, + bpm, + key, + time_signature, + language, + lm_backend, + ), + ProfileCommand::Rename { id, name } => cmd_rename(state, json, &id, &name), + ProfileCommand::Delete { id, yes } => cmd_delete(state, json, &id, yes), + } +} + +fn cmd_list(state: &AppState, json: bool) -> AppResult<()> { + let profiles = state.db.list_profiles()?; + + if json { + let output = + serde_json::to_string_pretty(&profiles).map_err(|e| cli_error(e.to_string()))?; + json_output(&output); + } else { + if profiles.is_empty() { + human_output("No profiles."); + return Ok(()); + } + println!( + "{:<12} {:<20} {:<8} {:<8} {:<8}", + "ID", "Name", "Model", "Steps", "Format" + ); + println!("{}", "-".repeat(60)); + for profile in &profiles { + let short_id = &profile.id[..8.min(profile.id.len())]; + let name = if profile.name.len() > 18 { + format!("{}…", &profile.name[..17]) + } else { + profile.name.clone() + }; + println!( + "{:<12} {:<20} {:<8} {:<8} {:<8}", + short_id, + name, + profile.model_variant.as_deref().unwrap_or("-"), + profile + .inference_steps + .map(|s| s.to_string()) + .unwrap_or_else(|| "-".to_string()), + profile.audio_format.as_deref().unwrap_or("-"), + ); + } + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn cmd_create( + state: &AppState, + json: bool, + name: &str, + model: Option, + duration: Option, + format: Option, + thinking: Option, + steps: Option, + guidance: Option, + bpm: Option, + key: Option, + time_signature: Option, + language: Option, + lm_backend: Option, +) -> AppResult<()> { + let thinking_bool = match thinking.as_deref() { + Some("on") | Some("true") => Some(true), + Some("off") | Some("false") => Some(false), + Some(other) => { + return Err(AppError::validation_failed(format!( + "Invalid thinking value '{other}' (expected on/off)" + ))); + } + None => None, + }; + + let request = CreateProfileRequest { + name: name.to_string(), + model_variant: model, + duration_seconds: duration, + audio_format: format, + thinking: thinking_bool, + inference_steps: steps, + guidance_scale: guidance, + bpm, + key_scale: key, + time_signature, + vocal_language: language, + lm_backend, + }; + + let profile = state.db.create_profile(&request)?; + if json { + let output = + serde_json::to_string_pretty(&profile).map_err(|e| cli_error(e.to_string()))?; + json_output(&output); + } else { + human_output(&format!( + "Created profile '{}' (id: {})", + profile.name, profile.id + )); + } + Ok(()) +} + +fn cmd_rename(state: &AppState, json: bool, id: &str, name: &str) -> AppResult<()> { + let resolved = resolve_profile_by_prefix(&state.db, id)?; + let profile = state.db.rename_profile(&resolved, name)?; + if json { + let output = + serde_json::to_string_pretty(&profile).map_err(|e| cli_error(e.to_string()))?; + json_output(&output); + } else { + human_output(&format!("Renamed profile {} to '{}'", resolved, name)); + } + Ok(()) +} + +fn cmd_delete(state: &AppState, json: bool, id: &str, yes: bool) -> AppResult<()> { + let resolved = resolve_profile_by_prefix(&state.db, id)?; + + if !yes && !json { + use std::io::Write; + let profiles = state.db.list_profiles()?; + let display_name = profiles + .iter() + .find(|p| p.id == resolved) + .map(|p| p.name.as_str()) + .unwrap_or(&resolved); + print!("Delete profile '{}'? [y/N] ", display_name); + std::io::stdout().flush().ok(); + let mut input = String::new(); + std::io::stdin() + .read_line(&mut input) + .map_err(|e| cli_error(e.to_string()))?; + let trimmed = input.trim(); + if !["y", "Y", "yes", "Yes"].contains(&trimmed) { + human_output("Cancelled."); + return Ok(()); + } + } + + state.db.delete_profile(&resolved)?; + if json { + json_output(&format!("{{\"deleted\":\"{}\"}}", resolved)); + } else { + human_output(&format!("Deleted profile {}", resolved)); + } + Ok(()) +} + +fn resolve_profile_by_prefix( + db: &crate::services::db::Database, + prefix: &str, +) -> AppResult { + let profiles = db.list_profiles()?; + let matches: Vec<_> = profiles + .iter() + .filter(|p| p.id.starts_with(prefix) || p.name == prefix) + .collect(); + match matches.len() { + 0 => Err(AppError::not_found( + "Profile", + format!("No profile matches '{prefix}'"), + )), + 1 => Ok(matches[0].id.clone()), + _ => Err(AppError::validation_failed(format!( + "Ambiguous profile prefix '{prefix}' matched {} profiles", + matches.len() + ))), + } +} diff --git a/src-tauri/src/cli/spec.rs b/src-tauri/src/cli/spec.rs index 4252793..a18fc00 100644 --- a/src-tauri/src/cli/spec.rs +++ b/src-tauri/src/cli/spec.rs @@ -56,6 +56,11 @@ pub enum Commands { #[command(subcommand)] command: ProjectCommand, }, + /// Manage generation profiles (named presets) + Profiles { + #[command(subcommand)] + command: ProfileCommand, + }, /// Delete a generation record Delete(DeleteArgs), /// Clear all generation history @@ -343,6 +348,66 @@ pub enum ProjectCommand { }, } +#[derive(Subcommand)] +#[allow(clippy::large_enum_variant)] +pub enum ProfileCommand { + /// List all profiles + List, + /// Create a new profile from current form values + Create { + /// Profile name + name: String, + /// Model variant (lite, turbo, pro) + #[arg(long)] + model: Option, + /// Duration in seconds + #[arg(long)] + duration: Option, + /// Audio format (wav, mp3, flac, ogg) + #[arg(long)] + format: Option, + /// Thinking mode (on/off) + #[arg(long)] + thinking: Option, + /// Inference steps + #[arg(long)] + steps: Option, + /// Guidance scale + #[arg(long)] + guidance: Option, + /// BPM + #[arg(long)] + bpm: Option, + /// Key/scale + #[arg(long)] + key: Option, + /// Time signature + #[arg(long)] + time_signature: Option, + /// Vocal language + #[arg(long)] + language: Option, + /// LM backend (pt, vllm, mlx) + #[arg(long)] + lm_backend: Option, + }, + /// Rename an existing profile + Rename { + /// Profile ID prefix + id: String, + /// New profile name + name: String, + }, + /// Delete a profile + Delete { + /// Profile ID prefix + id: String, + /// Skip confirmation + #[arg(long)] + yes: bool, + }, +} + // --------------------------------------------------------------------------- // Leaf commands // --------------------------------------------------------------------------- diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 767757e..9c17fd5 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod history; pub mod logs; pub mod models; pub mod network; +pub mod profiles; pub mod projects; pub mod provisioner; pub mod settings; diff --git a/src-tauri/src/commands/profiles.rs b/src-tauri/src/commands/profiles.rs new file mode 100644 index 0000000..550d0fb --- /dev/null +++ b/src-tauri/src/commands/profiles.rs @@ -0,0 +1,36 @@ +use tauri::State; + +use crate::{ + models::{ + errors::AppResult, + profile::{CreateProfileRequest, GenerationProfile, RenameProfileRequest}, + }, + AppState, +}; + +#[tauri::command] +pub fn list_profiles(state: State<'_, AppState>) -> AppResult> { + state.db.list_profiles() +} + +#[tauri::command] +pub fn create_profile( + state: State<'_, AppState>, + request: CreateProfileRequest, +) -> AppResult { + state.db.create_profile(&request) +} + +#[tauri::command] +pub fn rename_profile( + state: State<'_, AppState>, + id: String, + request: RenameProfileRequest, +) -> AppResult { + state.db.rename_profile(&id, &request.name) +} + +#[tauri::command] +pub fn delete_profile(state: State<'_, AppState>, id: String) -> AppResult<()> { + state.db.delete_profile(&id) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5c69c06..432ba77 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -82,6 +82,10 @@ pub fn run() { commands::projects::rename_project, commands::projects::delete_project, commands::projects::assign_generation_to_project, + commands::profiles::list_profiles, + commands::profiles::create_profile, + commands::profiles::rename_profile, + commands::profiles::delete_profile, commands::generation::insert_generation, commands::generation::generate_music, commands::generation::cancel_generation, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index dbf2fe3..a809da0 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,5 +1,6 @@ pub mod backend; pub mod errors; pub mod generation; +pub mod profile; pub mod project; pub mod settings; diff --git a/src-tauri/src/models/profile.rs b/src-tauri/src/models/profile.rs new file mode 100644 index 0000000..42e9f59 --- /dev/null +++ b/src-tauri/src/models/profile.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +/// A named preset of generation form defaults that can be applied with one click. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GenerationProfile { + pub id: String, + pub name: String, + pub created_at: String, + pub updated_at: String, + pub model_variant: Option, + pub duration_seconds: Option, + pub audio_format: Option, + pub thinking: Option, + pub inference_steps: Option, + pub guidance_scale: Option, + pub bpm: Option, + pub key_scale: Option, + pub time_signature: Option, + pub vocal_language: Option, + pub lm_backend: Option, +} + +/// Payload for creating a profile. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateProfileRequest { + pub name: String, + pub model_variant: Option, + pub duration_seconds: Option, + pub audio_format: Option, + pub thinking: Option, + pub inference_steps: Option, + pub guidance_scale: Option, + pub bpm: Option, + pub key_scale: Option, + pub time_signature: Option, + pub vocal_language: Option, + pub lm_backend: Option, +} + +/// Payload for renaming a profile. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameProfileRequest { + pub name: String, +} diff --git a/src-tauri/src/services/db.rs b/src-tauri/src/services/db.rs index 7c447dd..e12b0ae 100644 --- a/src-tauri/src/services/db.rs +++ b/src-tauri/src/services/db.rs @@ -10,6 +10,7 @@ use serde_json::Value; use crate::models::{ errors::{AppError, AppResult}, generation::{ActiveGenerationTask, FailedRun, GenerationRecord, GenerationRequest}, + profile::{CreateProfileRequest, GenerationProfile}, project::Project, settings::{AppSettings, SettingKey}, }; @@ -51,6 +52,7 @@ impl Database { include_str!("../../migrations/004_add_failed_runs.sql"), include_str!("../../migrations/005_history_indexes.sql"), include_str!("../../migrations/006_add_projects.sql"), + include_str!("../../migrations/007_add_profiles.sql"), ] { let _ = connection.execute_batch(sql); } @@ -572,6 +574,127 @@ impl Database { Ok(()) } + // ----------------------------------------------------------------- + // Generation profiles + // ----------------------------------------------------------------- + + fn map_profile_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(GenerationProfile { + id: row.get(0)?, + name: row.get(1)?, + created_at: row.get(2)?, + updated_at: row.get(3)?, + model_variant: row.get(4)?, + duration_seconds: row.get(5)?, + audio_format: row.get(6)?, + thinking: row.get::<_, Option>(7)?.map(|v| v != 0), + inference_steps: row.get(8)?, + guidance_scale: row.get(9)?, + bpm: row.get(10)?, + key_scale: row.get(11)?, + time_signature: row.get(12)?, + vocal_language: row.get(13)?, + lm_backend: row.get(14)?, + }) + } + + pub fn list_profiles(&self) -> AppResult> { + let connection = self.connection()?; + let mut statement = connection + .prepare("SELECT id, name, created_at, updated_at, model_variant, duration_seconds, audio_format, thinking, inference_steps, guidance_scale, bpm, key_scale, time_signature, vocal_language, lm_backend FROM generation_profiles ORDER BY updated_at DESC") + .map_err(|error| AppError::db_read_failed(error.to_string()))?; + let mapped = statement + .query_map([], Self::map_profile_row) + .map_err(|error| AppError::db_read_failed(error.to_string()))?; + mapped + .into_iter() + .map(|row| row.map_err(|error| AppError::db_read_failed(error.to_string()))) + .collect() + } + + pub fn create_profile(&self, request: &CreateProfileRequest) -> AppResult { + let connection = self.connection()?; + let id = uuid::Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + connection + .execute( + "INSERT INTO generation_profiles (id, name, created_at, updated_at, model_variant, duration_seconds, audio_format, thinking, inference_steps, guidance_scale, bpm, key_scale, time_signature, vocal_language, lm_backend) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", + params![ + id, + request.name, + now, + now, + request.model_variant, + request.duration_seconds, + request.audio_format, + request.thinking.map(|b| b as i32), + request.inference_steps, + request.guidance_scale, + request.bpm, + request.key_scale, + request.time_signature, + request.vocal_language, + request.lm_backend, + ], + ) + .map_err(|error| AppError::db_write_failed(error.to_string()))?; + Ok(GenerationProfile { + id, + name: request.name.clone(), + created_at: now.clone(), + updated_at: now, + model_variant: request.model_variant.clone(), + duration_seconds: request.duration_seconds, + audio_format: request.audio_format.clone(), + thinking: request.thinking, + inference_steps: request.inference_steps, + guidance_scale: request.guidance_scale, + bpm: request.bpm, + key_scale: request.key_scale.clone(), + time_signature: request.time_signature.clone(), + vocal_language: request.vocal_language.clone(), + lm_backend: request.lm_backend.clone(), + }) + } + + pub fn rename_profile(&self, id: &str, name: &str) -> AppResult { + let connection = self.connection()?; + let now = Utc::now().to_rfc3339(); + let updated = connection + .execute( + "UPDATE generation_profiles SET name = ?1, updated_at = ?2 WHERE id = ?3", + params![name, now, id], + ) + .map_err(|error| AppError::db_write_failed(error.to_string()))?; + if updated == 0 { + return Err(AppError::not_found( + "Profile", + format!("No profile exists for id {id}"), + )); + } + connection + .query_row( + "SELECT id, name, created_at, updated_at, model_variant, duration_seconds, audio_format, thinking, inference_steps, guidance_scale, bpm, key_scale, time_signature, vocal_language, lm_backend FROM generation_profiles WHERE id = ?1", + [id], + Self::map_profile_row, + ) + .map_err(|error| AppError::db_read_failed(error.to_string())) + } + + pub fn delete_profile(&self, id: &str) -> AppResult<()> { + let connection = self.connection()?; + let deleted = connection + .execute("DELETE FROM generation_profiles WHERE id = ?1", [id]) + .map_err(|error| AppError::db_write_failed(error.to_string()))?; + if deleted == 0 { + return Err(AppError::not_found( + "Profile", + format!("No profile exists for id {id}"), + )); + } + Ok(()) + } + fn map_generation_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { let output_path: String = row.get(17)?; let seed: Option = row.get(15)?; @@ -652,6 +775,7 @@ mod tests { use super::Database; use crate::models::{ generation::{ActiveGenerationTask, GenerationRecord, GenerationRequest}, + profile::CreateProfileRequest, settings::RecommendedProfile, }; use serde_json::json; @@ -1089,4 +1213,44 @@ mod tests { .expect("prefix search"); assert_eq!(ambiguous.len(), 2); } + + #[test] + fn profile_crud_works_end_to_end() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let database = Database::new(temp_dir.path()).expect("database should init"); + + assert!(database.list_profiles().expect("list").is_empty()); + + let request = CreateProfileRequest { + name: "Fast Draft".to_owned(), + model_variant: Some("lite".to_owned()), + duration_seconds: Some(15.0), + audio_format: Some("wav".to_owned()), + thinking: Some(false), + inference_steps: Some(6), + guidance_scale: Some(6.0), + bpm: None, + key_scale: None, + time_signature: None, + vocal_language: Some("en".to_owned()), + lm_backend: Some("mlx".to_owned()), + }; + + let profile = database.create_profile(&request).expect("create"); + assert_eq!(profile.name, "Fast Draft"); + assert_eq!(profile.model_variant.as_deref(), Some("lite")); + assert_eq!(profile.thinking, Some(false)); + + let profiles = database.list_profiles().expect("list"); + assert_eq!(profiles.len(), 1); + + let renamed = database + .rename_profile(&profile.id, "Quick Draft") + .expect("rename"); + assert_eq!(renamed.name, "Quick Draft"); + assert_eq!(renamed.created_at, profile.created_at); + + database.delete_profile(&profile.id).expect("delete"); + assert!(database.list_profiles().expect("list").is_empty()); + } } diff --git a/src/app/components/settings/SettingsOverlay.tsx b/src/app/components/settings/SettingsOverlay.tsx index 0d9da75..764edd0 100644 --- a/src/app/components/settings/SettingsOverlay.tsx +++ b/src/app/components/settings/SettingsOverlay.tsx @@ -13,6 +13,7 @@ import { BackendSection } from "./sections/BackendSection"; import { DangerZoneSection } from "./sections/DangerZoneSection"; import { NetworkActivitySection } from "./sections/NetworkActivitySection"; import { LogsSection } from "./sections/LogsSection"; +import { ProfilesSection } from "./sections/ProfilesSection"; import { SettingsSaveBar } from "./SettingsSaveBar"; import { SettingsDialogs } from "./SettingsDialogs"; @@ -144,6 +145,7 @@ export function SettingsOverlay() { showModelDirRestartHint={showModelDirRestartHint} onPickDirectory={pickDirectory} /> +
state.profiles); + const createProfile = useGenerationStore((state) => state.createProfile); + const renameProfile = useGenerationStore((state) => state.renameProfile); + const deleteProfile = useGenerationStore((state) => state.deleteProfile); + const applyProfile = useGenerationStore((state) => state.applyProfile); + const form = useGenerationStore((state) => state.form); + + const [newName, setNewName] = useState(""); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(""); + + const handleCreate = async () => { + const name = newName.trim(); + if (!name) return; + try { + await createProfile(name, form); + addToast("success", t("profiles.created", { name })); + setNewName(""); + } catch { + addToast("error", t("profiles.createFailed")); + } + }; + + const handleRename = async (id: string) => { + const name = editName.trim(); + if (!name) return; + try { + await renameProfile(id, name); + addToast("success", t("profiles.renamed")); + setEditingId(null); + } catch { + addToast("error", t("profiles.renameFailed")); + } + }; + + const handleDelete = async (id: string) => { + try { + await deleteProfile(id); + addToast("success", t("profiles.deleted")); + } catch { + addToast("error", t("profiles.deleteFailed")); + } + }; + + return ( + +
+ {/* Create new profile */} +
+ setNewName(e.target.value)} + placeholder={t("profiles.namePlaceholder")} + className="flex-1 rounded-md border border-[var(--color-border-light)] bg-[var(--color-surface)] px-3 py-2 text-[13px] text-[var(--color-text)] placeholder:text-[var(--color-text-dim)] focus:border-[var(--color-accent)] focus:outline-none" + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + }} + /> + +
+ + {/* Profile list */} + {profiles.length === 0 ? ( +

+ {t("profiles.empty")} +

+ ) : ( +
+ {profiles.map((profile) => ( +
+ {editingId === profile.id ? ( + <> + setEditName(e.target.value)} + className="flex-1 rounded border border-[var(--color-accent)] bg-transparent px-2 py-1 text-[13px] text-[var(--color-text)] focus:outline-none" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") handleRename(profile.id); + if (e.key === "Escape") setEditingId(null); + }} + /> + + + + ) : ( + <> + + + {profile.modelVariant ?? "-"} · {profile.inferenceSteps ?? "?"} steps + + + + + )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/app/lib/api.ts b/src/app/lib/api.ts index 2b4e3d7..ada41c3 100644 --- a/src/app/lib/api.ts +++ b/src/app/lib/api.ts @@ -15,6 +15,8 @@ import type { ModelStatusSnapshot, GenerationRecord, GenerationRequest, + GenerationProfile, + CreateProfileRequest, ModelVariant, Project, PromptEnhancementResult, @@ -291,6 +293,22 @@ export function assignGenerationToProject( }); } +export function listProfiles(): Promise { + return invoke("list_profiles"); +} + +export function createProfile(request: CreateProfileRequest): Promise { + return invoke("create_profile", { request }); +} + +export function renameProfile(id: string, name: string): Promise { + return invoke("rename_profile", { id, request: { name } }); +} + +export function deleteProfile(id: string): Promise { + return invoke("delete_profile", { id }); +} + export function exportGenerationsToFolder(ids: string[], destination: string): Promise { return invoke("export_generations_to_folder", { ids, destination }); } diff --git a/src/app/lib/store/index.ts b/src/app/lib/store/index.ts index 0393920..22aa818 100644 --- a/src/app/lib/store/index.ts +++ b/src/app/lib/store/index.ts @@ -6,6 +6,7 @@ import { createGenerationSlice } from "@/app/lib/store/slices/generation"; import { createHistorySlice } from "@/app/lib/store/slices/history"; import { createProjectsSlice } from "@/app/lib/store/slices/projects"; import { createSettingsSlice } from "@/app/lib/store/slices/settings"; +import { createProfilesSlice } from "@/app/lib/store/slices/profiles"; export const useGenerationStore = create((set, get) => ({ ...createUISlice(set, get), @@ -14,4 +15,5 @@ export const useGenerationStore = create((set, get) => ({ ...createHistorySlice(set, get), ...createProjectsSlice(set, get), ...createSettingsSlice(set, get), + ...createProfilesSlice(set, get), })); diff --git a/src/app/lib/store/slices/profiles.ts b/src/app/lib/store/slices/profiles.ts new file mode 100644 index 0000000..8c105c0 --- /dev/null +++ b/src/app/lib/store/slices/profiles.ts @@ -0,0 +1,105 @@ +import type { GenerationStore } from "@/app/lib/store/types"; +import type { StoreApi } from "zustand"; +import type { GenerationFormValues, GenerationProfile } from "@/app/lib/types"; +import * as api from "@/app/lib/api"; +import { computeValidationState } from "@/app/lib/validation-helpers"; + +export interface ProfilesSlice { + profiles: GenerationProfile[]; + refreshProfiles: () => Promise; + createProfile: (name: string, form: GenerationFormValues) => Promise; + renameProfile: (id: string, name: string) => Promise; + deleteProfile: (id: string) => Promise; + applyProfile: (id: string) => void; +} + +export function createProfilesSlice( + set: StoreApi["setState"], + get: StoreApi["getState"], +): ProfilesSlice { + return { + profiles: [], + + refreshProfiles: async () => { + if (!api.isTauriRuntime()) return; + try { + const profiles = await api.listProfiles(); + set({ profiles }); + } catch { + // Non-fatal: profiles are optional + } + }, + + createProfile: async (name: string, form: GenerationFormValues) => { + if (!api.isTauriRuntime()) return; + const durationSeconds = parseFloat(form.durationSeconds); + const inferenceSteps = parseInt(form.inferenceSteps, 10); + const guidanceScale = parseFloat(form.guidanceScale); + const request = { + name, + modelVariant: form.model || null, + durationSeconds: isNaN(durationSeconds) ? null : durationSeconds, + audioFormat: form.audioFormat || null, + thinking: form.thinking, + inferenceSteps: isNaN(inferenceSteps) ? null : inferenceSteps, + guidanceScale: isNaN(guidanceScale) ? null : guidanceScale, + bpm: form.bpm ? parseInt(form.bpm, 10) : null, + keyScale: form.keyScale || null, + timeSignature: form.timeSignature || null, + vocalLanguage: form.vocalLanguage || null, + lmBackend: form.lmBackend || null, + }; + const profile = await api.createProfile(request); + set({ profiles: [profile, ...get().profiles] }); + }, + + renameProfile: async (id: string, name: string) => { + if (!api.isTauriRuntime()) return; + const updated = await api.renameProfile(id, name); + set({ + profiles: get().profiles.map((p) => (p.id === id ? updated : p)), + }); + }, + + deleteProfile: async (id: string) => { + if (!api.isTauriRuntime()) return; + await api.deleteProfile(id); + set({ profiles: get().profiles.filter((p) => p.id !== id) }); + }, + + applyProfile: (id: string) => { + const profile = get().profiles.find((p) => p.id === id); + if (!profile) return; + const currentForm = get().form; + const form: GenerationFormValues = { + ...currentForm, + model: profile.modelVariant ?? currentForm.model, + durationSeconds: + profile.durationSeconds != null + ? String(profile.durationSeconds) + : currentForm.durationSeconds, + audioFormat: + (profile.audioFormat as GenerationFormValues["audioFormat"]) ?? currentForm.audioFormat, + thinking: profile.thinking ?? currentForm.thinking, + inferenceSteps: + profile.inferenceSteps != null + ? String(profile.inferenceSteps) + : currentForm.inferenceSteps, + guidanceScale: + profile.guidanceScale != null ? String(profile.guidanceScale) : currentForm.guidanceScale, + bpm: profile.bpm != null ? String(profile.bpm) : currentForm.bpm, + keyScale: profile.keyScale ?? currentForm.keyScale, + timeSignature: + (profile.timeSignature as GenerationFormValues["timeSignature"]) ?? + currentForm.timeSignature, + vocalLanguage: profile.vocalLanguage ?? currentForm.vocalLanguage, + lmBackend: + (profile.lmBackend as GenerationFormValues["lmBackend"]) ?? currentForm.lmBackend, + }; + set({ + form, + ...computeValidationState(form, { showErrors: false }), + }); + }, + }; +} diff --git a/src/app/lib/store/slices/settings.ts b/src/app/lib/store/slices/settings.ts index 42db2dd..0982e10 100644 --- a/src/app/lib/store/slices/settings.ts +++ b/src/app/lib/store/slices/settings.ts @@ -176,6 +176,7 @@ export function createSettingsSlice( currentGeneration: persistedHistory[0] ?? null, }); await get().refreshBootstrapStatus(); + await get().refreshProfiles(); } catch (error) { set({ hydrated: true, diff --git a/src/app/lib/store/types.ts b/src/app/lib/store/types.ts index 6d8d142..79a495c 100644 --- a/src/app/lib/store/types.ts +++ b/src/app/lib/store/types.ts @@ -5,6 +5,7 @@ import type { DeviceInfo, GenerationEvent, GenerationFormValues, + GenerationProfile, GenerationRecord, GenerationRequest, GenerationState, @@ -35,6 +36,7 @@ export interface GenerationStore { currentGeneration: GenerationRecord | null; history: GenerationRecord[]; historyQuery: string; + profiles: GenerationProfile[]; activeTasks: ActiveGenerationTask[]; playbackToggleRequest: number; settings: AppSettings; @@ -75,6 +77,11 @@ export interface GenerationStore { value: GenerationFormValues[K], ) => void; setHistoryQuery: (query: string) => void; + refreshProfiles: () => Promise; + createProfile: (name: string, form: GenerationFormValues) => Promise; + renameProfile: (id: string, name: string) => Promise; + deleteProfile: (id: string) => Promise; + applyProfile: (id: string) => void; toggleSettings: () => void; toggleSidebar: () => void; toggleLyricsPanel: () => void; diff --git a/src/app/lib/types.ts b/src/app/lib/types.ts index 2527186..7baeb96 100644 --- a/src/app/lib/types.ts +++ b/src/app/lib/types.ts @@ -145,6 +145,39 @@ export type Project = { updatedAt: string; }; +export type GenerationProfile = { + id: string; + name: string; + createdAt: string; + updatedAt: string; + modelVariant?: string | null; + durationSeconds?: number | null; + audioFormat?: string | null; + thinking?: boolean | null; + inferenceSteps?: number | null; + guidanceScale?: number | null; + bpm?: number | null; + keyScale?: string | null; + timeSignature?: string | null; + vocalLanguage?: string | null; + lmBackend?: string | null; +}; + +export type CreateProfileRequest = { + name: string; + modelVariant?: string | null; + durationSeconds?: number | null; + audioFormat?: string | null; + thinking?: boolean | null; + inferenceSteps?: number | null; + guidanceScale?: number | null; + bpm?: number | null; + keyScale?: string | null; + timeSignature?: string | null; + vocalLanguage?: string | null; + lmBackend?: string | null; +}; + export type GenerationRunResult = { records: GenerationRecord[]; }; diff --git a/src/locales/en.json b/src/locales/en.json index 251e77e..86b9fdb 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -519,5 +519,18 @@ "pathCopied": "File path copied to clipboard.", "copyFailed": "Failed to copy to clipboard.", "retryStarted": "Retrying with same settings..." + }, + "profiles": { + "title": "Generation profiles", + "description": "Save and switch between named presets of generation defaults.", + "save": "Save", + "namePlaceholder": "Profile name...", + "empty": "No profiles yet. Create one from your current form values.", + "created": "Profile \"{{name}}\" created.", + "createFailed": "Failed to create profile.", + "renamed": "Profile renamed.", + "renameFailed": "Failed to rename profile.", + "deleted": "Profile deleted.", + "deleteFailed": "Failed to delete profile." } } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 1bab306..1a263fd 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -519,5 +519,18 @@ "pathCopied": "文件路径已复制到剪贴板。", "copyFailed": "复制到剪贴板失败。", "retryStarted": "正在使用相同参数重试..." + }, + "profiles": { + "title": "生成配置档", + "description": "保存并切换命名的生成参数预设。", + "save": "保存", + "namePlaceholder": "配置档名称...", + "empty": "尚无配置档。从当前表单值创建一个。", + "created": "配置档「{{name}}」已创建。", + "createFailed": "创建配置档失败。", + "renamed": "配置档已重命名。", + "renameFailed": "重命名配置档失败。", + "deleted": "配置档已删除。", + "deleteFailed": "删除配置档失败。" } } diff --git a/tests/unit/model-slice.test.ts b/tests/unit/model-slice.test.ts index f4d5fdb..c315e6d 100644 --- a/tests/unit/model-slice.test.ts +++ b/tests/unit/model-slice.test.ts @@ -40,6 +40,7 @@ const { createSettingsSlice } = await import("@/app/lib/store/slices/settings"); const { createHistorySlice } = await import("@/app/lib/store/slices/history"); const { createGenerationSlice } = await import("@/app/lib/store/slices/generation"); const { createProjectsSlice } = await import("@/app/lib/store/slices/projects"); +const { createProfilesSlice } = await import("@/app/lib/store/slices/profiles"); const { create } = await import("zustand"); @@ -55,6 +56,7 @@ function createStore() { ...createHistorySlice(set, get), ...createProjectsSlice(set, get), ...createSettingsSlice(set, get), + ...createProfilesSlice(set, get), })); } diff --git a/tests/unit/settings-components.test.tsx b/tests/unit/settings-components.test.tsx index 1487248..6bd213d 100644 --- a/tests/unit/settings-components.test.tsx +++ b/tests/unit/settings-components.test.tsx @@ -235,6 +235,7 @@ function defaultStoreValues() { modelStatuses: makeModelStatuses(), backendProvisionStatus: makeProvisionReady(), history: [makeGenerationRecord()], + profiles: [], closeSettings, completeSetup, enterDemoMode, diff --git a/tests/unit/settings-slice.test.ts b/tests/unit/settings-slice.test.ts index b170b95..22a45fe 100644 --- a/tests/unit/settings-slice.test.ts +++ b/tests/unit/settings-slice.test.ts @@ -30,6 +30,7 @@ vi.mock("@/app/lib/api", () => ({ getModelStatus: vi.fn(() => Promise.resolve([])), listActiveGenerationTasks: vi.fn(() => Promise.resolve([])), listProjects: vi.fn(() => Promise.resolve([])), + listProfiles: vi.fn(() => Promise.resolve([])), })); vi.mock("@/app/lib/errors", () => ({ @@ -68,6 +69,7 @@ vi.mock("@/app/lib/profile-presets", async (importOriginal) => { /* ------------------------------------------------------------------ */ const { createSettingsSlice } = await import("@/app/lib/store/slices/settings"); +const { createProfilesSlice } = await import("@/app/lib/store/slices/profiles"); const api = await import("@/app/lib/api"); const { PROFILE_FORM_PRESETS } = await import("@/app/lib/profile-presets"); const { computeValidationState } = await import("@/app/lib/validation-helpers"); @@ -116,6 +118,7 @@ function createTestStore(overrides: Partial = {}) { (set, get) => ({ ...createSettingsSlice(set, get), + ...createProfilesSlice(set, get), form: { ...mockForm }, modelStatuses: [], generationState: { diff --git a/tests/unit/store.test.ts b/tests/unit/store.test.ts index e301117..e12e171 100644 --- a/tests/unit/store.test.ts +++ b/tests/unit/store.test.ts @@ -21,6 +21,7 @@ vi.mock("@/app/lib/api", () => ({ listenToModelDownloadEvents: vi.fn(), deleteGenerationFileAndRecord: (id: string) => deleteGenerationFileAndRecord(id), clearGenerationHistory: () => clearGenerationHistory(), + listProfiles: vi.fn(() => Promise.resolve([])), })); const { DEFAULT_GENERATION_FORM_VALUES } = await import("@/app/lib/validation");