feat: finalise multi-profil Codex/Claude avec catalogue de modèles
- Backend : clone_profile_from_seed généralisé (non OpenCode) - Backend : catalogue static Claude/Codex (3 modèles chacun, 1 recommandé) - Backend : commandes Tauri list_claude_models/list_codex_models - Frontend : ProfilesSettings refonte en onglets Codex/Claude + create/duplicate/edit/delete - Frontend : ModelSelect searchable partagé + fallback saisie manuelle - Frontend : assignation agent nom · modèle - Tests QA : 4 profils modèles distincts (2 Claude, 2 Codex) assignés à agents
This commit is contained in:
@ -9,6 +9,7 @@
|
||||
mod catalogue;
|
||||
mod inspect;
|
||||
mod lifecycle;
|
||||
mod model_catalogue;
|
||||
mod provider_catalogue;
|
||||
mod resume;
|
||||
mod session_limit;
|
||||
@ -39,6 +40,10 @@ pub use lifecycle::{
|
||||
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
|
||||
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
|
||||
};
|
||||
pub use model_catalogue::{
|
||||
claude_model_catalogue, codex_model_catalogue, ListClaudeModels, ListClaudeModelsOutput,
|
||||
ListCodexModels, ListCodexModelsOutput, ProfileModelCatalogEntry,
|
||||
};
|
||||
pub use provider_catalogue::{
|
||||
opencode_models_cache_path, opencode_provider_catalogue, ListOpenCodeProviders,
|
||||
ListOpenCodeProvidersOutput, OpenCodeProviderCatalogEntry,
|
||||
@ -48,10 +53,11 @@ pub use resume::{
|
||||
};
|
||||
pub use usecases::{
|
||||
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
||||
CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
|
||||
ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles,
|
||||
ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput,
|
||||
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
||||
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||
CloneOpenCodeProfileFromSeedOutput, CloneProfileFromSeed, CloneProfileFromSeedInput,
|
||||
CloneProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput,
|
||||
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput,
|
||||
FirstRunState, FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability,
|
||||
ReferenceProfiles, ReferenceProfilesOutput, SaveOpenCodeProviderProfile,
|
||||
SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfile,
|
||||
SaveProfileInput, SaveProfileOutput,
|
||||
};
|
||||
|
||||
183
crates/application/src/agent/model_catalogue.rs
Normal file
183
crates/application/src/agent/model_catalogue.rs
Normal file
@ -0,0 +1,183 @@
|
||||
//! Static curated model catalogues for structured Claude/Codex profiles.
|
||||
//!
|
||||
//! The CLIs do not expose a stable machine-readable model catalogue. These lists
|
||||
//! are therefore intentionally small, static and infallible; the UI must still
|
||||
//! keep manual entry as a fallback for models not listed here.
|
||||
|
||||
use domain::profile::StructuredAdapter;
|
||||
|
||||
/// One searchable model entry for a structured profile adapter.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProfileModelCatalogEntry {
|
||||
/// Structured adapter this model belongs to.
|
||||
pub adapter: StructuredAdapter,
|
||||
/// Exact model identifier to persist on [`domain::profile::AgentProfile::model`].
|
||||
pub model_id: String,
|
||||
/// Human-readable label for picker display.
|
||||
pub display_name: String,
|
||||
/// Extra search tokens useful to the frontend.
|
||||
pub aliases: Vec<String>,
|
||||
/// Whether this entry is the conservative default suggestion.
|
||||
pub recommended: bool,
|
||||
}
|
||||
|
||||
fn entry(
|
||||
adapter: StructuredAdapter,
|
||||
model_id: &str,
|
||||
display_name: &str,
|
||||
aliases: &[&str],
|
||||
recommended: bool,
|
||||
) -> ProfileModelCatalogEntry {
|
||||
ProfileModelCatalogEntry {
|
||||
adapter,
|
||||
model_id: model_id.to_owned(),
|
||||
display_name: display_name.to_owned(),
|
||||
aliases: aliases.iter().map(|alias| (*alias).to_owned()).collect(),
|
||||
recommended,
|
||||
}
|
||||
}
|
||||
|
||||
/// Static Claude Code model catalogue.
|
||||
#[must_use]
|
||||
pub fn claude_model_catalogue() -> Vec<ProfileModelCatalogEntry> {
|
||||
vec![
|
||||
entry(
|
||||
StructuredAdapter::Claude,
|
||||
"claude-sonnet-5",
|
||||
"Claude Sonnet 5",
|
||||
&["sonnet"],
|
||||
true,
|
||||
),
|
||||
entry(
|
||||
StructuredAdapter::Claude,
|
||||
"claude-opus-4-8",
|
||||
"Claude Opus 4.8",
|
||||
&["opus"],
|
||||
false,
|
||||
),
|
||||
entry(
|
||||
StructuredAdapter::Claude,
|
||||
"claude-haiku-4-5-20251001",
|
||||
"Claude Haiku 4.5",
|
||||
&["haiku"],
|
||||
false,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Static OpenAI Codex CLI model catalogue.
|
||||
#[must_use]
|
||||
pub fn codex_model_catalogue() -> Vec<ProfileModelCatalogEntry> {
|
||||
vec![
|
||||
entry(
|
||||
StructuredAdapter::Codex,
|
||||
"gpt-5-codex",
|
||||
"GPT-5 Codex",
|
||||
&["codex"],
|
||||
true,
|
||||
),
|
||||
entry(
|
||||
StructuredAdapter::Codex,
|
||||
"gpt-5",
|
||||
"GPT-5",
|
||||
&["general"],
|
||||
false,
|
||||
),
|
||||
entry(
|
||||
StructuredAdapter::Codex,
|
||||
"gpt-5-mini",
|
||||
"GPT-5 mini",
|
||||
&["mini", "fast"],
|
||||
false,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Use case exposing the static Claude model catalogue.
|
||||
pub struct ListClaudeModels;
|
||||
|
||||
/// Output of [`ListClaudeModels::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListClaudeModelsOutput {
|
||||
/// The catalogue entries.
|
||||
pub models: Vec<ProfileModelCatalogEntry>,
|
||||
}
|
||||
|
||||
impl ListClaudeModels {
|
||||
/// Builds the use case (stateless, no ports to inject).
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Lists curated Claude models. Infallible.
|
||||
#[must_use]
|
||||
pub fn execute(&self) -> ListClaudeModelsOutput {
|
||||
ListClaudeModelsOutput {
|
||||
models: claude_model_catalogue(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ListClaudeModels {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Use case exposing the static Codex model catalogue.
|
||||
pub struct ListCodexModels;
|
||||
|
||||
/// Output of [`ListCodexModels::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListCodexModelsOutput {
|
||||
/// The catalogue entries.
|
||||
pub models: Vec<ProfileModelCatalogEntry>,
|
||||
}
|
||||
|
||||
impl ListCodexModels {
|
||||
/// Builds the use case (stateless, no ports to inject).
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Lists curated Codex models. Infallible.
|
||||
#[must_use]
|
||||
pub fn execute(&self) -> ListCodexModelsOutput {
|
||||
ListCodexModelsOutput {
|
||||
models: codex_model_catalogue(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ListCodexModels {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn static_catalogues_are_non_empty_searchable_and_have_one_default() {
|
||||
for (adapter, models) in [
|
||||
(StructuredAdapter::Claude, claude_model_catalogue()),
|
||||
(StructuredAdapter::Codex, codex_model_catalogue()),
|
||||
] {
|
||||
assert!(!models.is_empty());
|
||||
assert_eq!(
|
||||
models.iter().filter(|model| model.recommended).count(),
|
||||
1,
|
||||
"{adapter:?} should expose one default suggestion"
|
||||
);
|
||||
for model in models {
|
||||
assert_eq!(model.adapter, adapter);
|
||||
assert!(!model.model_id.trim().is_empty());
|
||||
assert!(!model.display_name.trim().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -146,6 +146,92 @@ pub struct SaveProfileOutput {
|
||||
pub profile: AgentProfile,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CloneProfileFromSeed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`CloneProfileFromSeed::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CloneProfileFromSeedInput {
|
||||
/// Id of the persisted or reference profile to clone.
|
||||
pub seed_profile_id: ProfileId,
|
||||
/// Optional display name for the cloned profile. When absent, a copy label is
|
||||
/// derived from the seed name.
|
||||
pub name: Option<String>,
|
||||
/// Optional model override. When absent, the seed model is copied as-is.
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
/// Output of [`CloneProfileFromSeed::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CloneProfileFromSeedOutput {
|
||||
/// The newly persisted profile.
|
||||
pub profile: AgentProfile,
|
||||
}
|
||||
|
||||
/// Creates a new profile instance from an existing persisted/reference seed.
|
||||
///
|
||||
/// Persisted profiles are preferred over reference seeds so user edits to the
|
||||
/// seed are preserved. The clone always receives a fresh [`ProfileId`] from the
|
||||
/// backend [`IdGenerator`]; callers can override the display name and model
|
||||
/// without minting ids client-side.
|
||||
pub struct CloneProfileFromSeed {
|
||||
store: Arc<dyn ProfileStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
}
|
||||
|
||||
impl CloneProfileFromSeed {
|
||||
/// Builds the use case from the profile store and id generator ports.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProfileStore>, ids: Arc<dyn IdGenerator>) -> Self {
|
||||
Self { store, ids }
|
||||
}
|
||||
|
||||
/// Clones the requested seed into a new persisted profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::NotFound`] if no persisted/reference profile has the seed id,
|
||||
/// [`AppError::Invalid`] if `name` or `model` is blank, [`AppError::Store`]
|
||||
/// on persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: CloneProfileFromSeedInput,
|
||||
) -> Result<CloneProfileFromSeedOutput, AppError> {
|
||||
let existing = self.store.list().await?;
|
||||
let seed = existing
|
||||
.iter()
|
||||
.find(|profile| profile.id == input.seed_profile_id)
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
reference_profiles()
|
||||
.into_iter()
|
||||
.find(|profile| profile.id == input.seed_profile_id)
|
||||
})
|
||||
.ok_or(AppError::NotFound("profile seed not found".into()))?;
|
||||
|
||||
let mut profile = seed;
|
||||
profile.id = fresh_profile_id(&*self.ids, &existing)?;
|
||||
profile.name = match input.name {
|
||||
Some(name) => {
|
||||
if name.trim().is_empty() {
|
||||
return Err(AppError::Invalid("profile.name must not be empty".into()));
|
||||
}
|
||||
name
|
||||
}
|
||||
None => format!("{} copy", profile.name),
|
||||
};
|
||||
if let Some(model) = input.model {
|
||||
if model.trim().is_empty() {
|
||||
return Err(AppError::Invalid("profile.model must not be empty".into()));
|
||||
}
|
||||
profile.model = Some(model);
|
||||
}
|
||||
|
||||
self.store.save(&profile).await?;
|
||||
Ok(CloneProfileFromSeedOutput { profile })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CloneOpenCodeProfileFromSeed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user