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:
@ -70,3 +70,4 @@
|
|||||||
- [ticket101-cross-talk-multi-project-rootcause](ticket101-cross-talk-multi-project-rootcause.md) — memory note ticket101-cross-talk-multi-project-rootcause
|
- [ticket101-cross-talk-multi-project-rootcause](ticket101-cross-talk-multi-project-rootcause.md) — memory note ticket101-cross-talk-multi-project-rootcause
|
||||||
- [ticket103-network-permission-ux-surface](ticket103-network-permission-ux-surface.md) — Stable UX convention for agent network permissions in IdeA.
|
- [ticket103-network-permission-ux-surface](ticket103-network-permission-ux-surface.md) — Stable UX convention for agent network permissions in IdeA.
|
||||||
- [codex-network-access-config-fix](codex-network-access-config-fix.md) — memory note codex-network-access-config-fix
|
- [codex-network-access-config-fix](codex-network-access-config-fix.md) — memory note codex-network-access-config-fix
|
||||||
|
- [multi-profile-codex-claude-model-catalogue-scoping](multi-profile-codex-claude-model-catalogue-scoping.md) — memory note multi-profile-codex-claude-model-catalogue-scoping
|
||||||
|
|||||||
@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
name: multi-profile-codex-claude-model-catalogue-scoping
|
||||||
|
description: memory note multi-profile-codex-claude-model-catalogue-scoping
|
||||||
|
metadata:
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
# Cadrage : profils multiples Codex/Claude + catalogue de modèles
|
||||||
|
|
||||||
|
Demande utilisateur : plusieurs profils Codex et Claude, chacun avec son modèle, assignables aux
|
||||||
|
agents ; lister les modèles plutôt que saisie manuelle quand possible.
|
||||||
|
|
||||||
|
## État vérifié de l'existant (2026-07-26)
|
||||||
|
|
||||||
|
Le backend est déjà générique multi-profils, contrairement à ce qu'on pourrait croire à la lecture
|
||||||
|
seule des mémoires F35/F36 (qui documentaient le cas OpenCode) :
|
||||||
|
|
||||||
|
- `AgentProfile` (crates/domain/src/profile.rs) porte déjà `model: Option<String>` (ticket #99,
|
||||||
|
explicitement prévu pour Codex/Claude), et `profiles.json` (FsProfileStore) est une **liste**
|
||||||
|
indexée par `id`, pas un slot singleton par provider.
|
||||||
|
- Commandes déjà câblées : `list_profiles`, `save_profile` (upsert générique par id),
|
||||||
|
`delete_profile`, `reference_profiles`, `detect_profiles`.
|
||||||
|
- Ce qui existe **seulement pour OpenCode** : `clone_opencode_profile_from_seed` (alloue un id
|
||||||
|
frais via IdGenerator) et `save_opencode_provider_profile`, plus un vrai catalogue de modèles
|
||||||
|
(`crates/application/src/agent/provider_catalogue.rs`, lit le cache models.dev d'OpenCode avec
|
||||||
|
repli statique).
|
||||||
|
- `catalogue.rs` : un seul profil de référence Claude et un seul Codex, aucun `.with_model(...)`.
|
||||||
|
- Frontend `ProfilesSettings.tsx` : simple list+delete, pas de create/duplicate/edit inline ;
|
||||||
|
toute édition rouvre `FirstRunWizard`.
|
||||||
|
|
||||||
|
## Gaps identifiés (pas de migration de schéma nécessaire)
|
||||||
|
|
||||||
|
1. Backend : généraliser le pattern `CloneOpenCodeProfileFromSeed` (fresh_profile_id via
|
||||||
|
IdGenerator) en un use case `CloneProfileFromSeed` non spécifique à OpenCode, pour dupliquer un
|
||||||
|
profil Claude/Codex avec un nom + `model` en override. Ne PAS laisser le frontend miner l'id
|
||||||
|
(romprait la discipline IdGenerator déjà en place).
|
||||||
|
2. Backend : catalogue de modèles Claude/Codex — aucune API fiable côté CLI, donc liste statique
|
||||||
|
curée (même esprit que `static_fallback_catalogue()` d'OpenCode), exposée par commande Tauri
|
||||||
|
infaillible (`list_claude_models`/`list_codex_models` ou générique par `structuredAdapter`).
|
||||||
|
Le frontend garde toujours un champ de saisie manuelle en repli (liste jamais garantie
|
||||||
|
exhaustive).
|
||||||
|
3. Frontend : refonte `ProfilesSettings.tsx` en onglets Codex/Claude/OpenCode avec
|
||||||
|
create/duplicate/edit/delete par onglet + `ModelSelect` searchable partagé ; simplifier
|
||||||
|
`FirstRunWizard` pour ne créer qu'un profil par défaut par provider détecté, avec renvoi vers
|
||||||
|
Settings pour en ajouter d'autres.
|
||||||
|
|
||||||
|
## Découpage de livraison
|
||||||
|
DevBackend (use case + catalogues + commandes, petit lot, zéro migration) → DevFrontend (refonte
|
||||||
|
Settings + first-run simplifié) → QA (créer 2 profils Claude modèles différents + 2 Codex, assigner
|
||||||
|
à des agents distincts, vérifier le bon modèle atteint la CLI au lancement, non-régression
|
||||||
|
OpenCode) → Git (branche feature unique, lot petit et couplé).
|
||||||
@ -41,22 +41,23 @@ use crate::dto::{
|
|||||||
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachLiveAgentRequestDto,
|
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachLiveAgentRequestDto,
|
||||||
AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto,
|
AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto,
|
||||||
ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
||||||
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||||
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
|
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
||||||
CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
||||||
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
|
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto,
|
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
|
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
|
||||||
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
|
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||||
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
|
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||||
HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
|
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
||||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
|
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
||||||
ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
|
ModelServerConfigDto, ModelServerConfigListDto, OpenCodeProviderListDto,
|
||||||
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
|
OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto,
|
||||||
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
|
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
||||||
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
|
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
||||||
ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
|
||||||
|
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||||
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto,
|
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto,
|
||||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
|
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
|
||||||
@ -1188,6 +1189,22 @@ pub async fn list_opencode_providers(
|
|||||||
Ok(state.list_opencode_providers.execute().into())
|
Ok(state.list_opencode_providers.execute().into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `list_claude_models` — static curated Claude model catalogue.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_claude_models(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<ProfileModelCatalogDto, ErrorDto> {
|
||||||
|
Ok(state.list_claude_models.execute().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `list_codex_models` — static curated Codex model catalogue.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_codex_models(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<ProfileModelCatalogDto, ErrorDto> {
|
||||||
|
Ok(state.list_codex_models.execute().into())
|
||||||
|
}
|
||||||
|
|
||||||
/// `save_opencode_provider_profile` — create or replace an OpenCode profile
|
/// `save_opencode_provider_profile` — create or replace an OpenCode profile
|
||||||
/// backed by a cloud provider (ticket #92, lot B3). The literal API key is
|
/// backed by a cloud provider (ticket #92, lot B3). The literal API key is
|
||||||
/// sealed into the `SecretStore`, never persisted in `profiles.json`.
|
/// sealed into the `SecretStore`, never persisted in `profiles.json`.
|
||||||
@ -1227,6 +1244,25 @@ pub async fn clone_opencode_profile_from_seed(
|
|||||||
.map_err(ErrorDto::from)
|
.map_err(ErrorDto::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `clone_profile_from_seed` — create a new profile instance from a
|
||||||
|
/// persisted/reference seed, with optional name/model overrides.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] (`NOT_FOUND` for an unknown seed, `STORE` on profiles
|
||||||
|
/// I/O failure, `INVALID` for a blank requested name/model).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clone_profile_from_seed(
|
||||||
|
request: CloneProfileFromSeedRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<ProfileDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.clone_profile_from_seed
|
||||||
|
.execute(request.into())
|
||||||
|
.await
|
||||||
|
.map(ProfileDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
/// `delete_profile` — delete a profile by id.
|
/// `delete_profile` — delete a profile by id.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|||||||
@ -255,6 +255,9 @@ pub fn run() {
|
|||||||
commands::save_profile,
|
commands::save_profile,
|
||||||
commands::save_opencode_provider_profile,
|
commands::save_opencode_provider_profile,
|
||||||
commands::list_opencode_providers,
|
commands::list_opencode_providers,
|
||||||
|
commands::list_claude_models,
|
||||||
|
commands::list_codex_models,
|
||||||
|
commands::clone_profile_from_seed,
|
||||||
commands::clone_opencode_profile_from_seed,
|
commands::clone_opencode_profile_from_seed,
|
||||||
commands::delete_profile,
|
commands::delete_profile,
|
||||||
commands::configure_profiles,
|
commands::configure_profiles,
|
||||||
|
|||||||
@ -4,15 +4,17 @@
|
|||||||
|
|
||||||
use app_tauri_lib::dto::{
|
use app_tauri_lib::dto::{
|
||||||
parse_delete_profile, parse_profile_id, CloneOpenCodeProfileFromSeedRequestDto,
|
parse_delete_profile, parse_profile_id, CloneOpenCodeProfileFromSeedRequestDto,
|
||||||
ConfigureProfilesRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, DetectProfilesRequestDto,
|
||||||
FirstRunStateDto, ProfileListDto, SaveProfileRequestDto,
|
DetectProfilesResponseDto, FirstRunStateDto, ProfileListDto, ProfileModelCatalogDto,
|
||||||
|
SaveProfileRequestDto,
|
||||||
};
|
};
|
||||||
use application::{
|
use application::{
|
||||||
CloneOpenCodeProfileFromSeedInput, ConfigureProfilesInput, DetectProfilesInput,
|
CloneOpenCodeProfileFromSeedInput, CloneProfileFromSeedInput, ConfigureProfilesInput,
|
||||||
DetectProfilesOutput, FirstRunStateOutput, ProfileAvailability, SaveProfileInput,
|
DetectProfilesInput, DetectProfilesOutput, FirstRunStateOutput, ProfileAvailability,
|
||||||
|
SaveProfileInput,
|
||||||
};
|
};
|
||||||
use domain::ids::{LocalModelServerId, ProfileId};
|
use domain::ids::{LocalModelServerId, ProfileId};
|
||||||
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig};
|
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -121,6 +123,41 @@ fn clone_opencode_profile_from_seed_request_deserialises_camelcase_config() {
|
|||||||
assert_eq!(opencode.local_model_server_id, Some(server_id));
|
assert_eq!(opencode.local_model_server_id, Some(server_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clone_profile_from_seed_request_deserialises_camelcase_overrides() {
|
||||||
|
let seed_id = Uuid::from_u128(42);
|
||||||
|
let raw = json!({
|
||||||
|
"seedProfileId": seed_id.to_string(),
|
||||||
|
"name": "Codex GPT-5",
|
||||||
|
"model": "gpt-5-codex"
|
||||||
|
});
|
||||||
|
|
||||||
|
let dto: CloneProfileFromSeedRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
let input: CloneProfileFromSeedInput = dto.into();
|
||||||
|
assert_eq!(input.seed_profile_id, ProfileId::from_uuid(seed_id));
|
||||||
|
assert_eq!(input.name.as_deref(), Some("Codex GPT-5"));
|
||||||
|
assert_eq!(input.model.as_deref(), Some("gpt-5-codex"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_model_catalogue_dto_serialises_searchable_camelcase_entries() {
|
||||||
|
let dto = ProfileModelCatalogDto(vec![app_tauri_lib::dto::ProfileModelCatalogEntryDto {
|
||||||
|
adapter: StructuredAdapter::Codex,
|
||||||
|
model_id: "gpt-5-codex".to_owned(),
|
||||||
|
display_name: "GPT-5 Codex".to_owned(),
|
||||||
|
aliases: vec!["codex".to_owned()],
|
||||||
|
recommended: true,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
let value = serde_json::to_value(&dto).unwrap();
|
||||||
|
let arr = value.as_array().expect("transparent array");
|
||||||
|
assert_eq!(arr[0]["adapter"], "codex");
|
||||||
|
assert_eq!(arr[0]["modelId"], "gpt-5-codex");
|
||||||
|
assert_eq!(arr[0]["displayName"], "GPT-5 Codex");
|
||||||
|
assert_eq!(arr[0]["aliases"], json!(["codex"]));
|
||||||
|
assert_eq!(arr[0]["recommended"], true);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn opencode_config_dto_omits_local_model_server_id_when_none() {
|
fn opencode_config_dto_omits_local_model_server_id_when_none() {
|
||||||
let config = OpenCodeConfig::new(
|
let config = OpenCodeConfig::new(
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
mod catalogue;
|
mod catalogue;
|
||||||
mod inspect;
|
mod inspect;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
|
mod model_catalogue;
|
||||||
mod provider_catalogue;
|
mod provider_catalogue;
|
||||||
mod resume;
|
mod resume;
|
||||||
mod session_limit;
|
mod session_limit;
|
||||||
@ -39,6 +40,10 @@ pub use lifecycle::{
|
|||||||
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
|
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
|
||||||
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
|
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::{
|
pub use provider_catalogue::{
|
||||||
opencode_models_cache_path, opencode_provider_catalogue, ListOpenCodeProviders,
|
opencode_models_cache_path, opencode_provider_catalogue, ListOpenCodeProviders,
|
||||||
ListOpenCodeProvidersOutput, OpenCodeProviderCatalogEntry,
|
ListOpenCodeProvidersOutput, OpenCodeProviderCatalogEntry,
|
||||||
@ -48,10 +53,11 @@ pub use resume::{
|
|||||||
};
|
};
|
||||||
pub use usecases::{
|
pub use usecases::{
|
||||||
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
||||||
CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
|
CloneOpenCodeProfileFromSeedOutput, CloneProfileFromSeed, CloneProfileFromSeedInput,
|
||||||
ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
CloneProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput,
|
||||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles,
|
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput,
|
||||||
ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput,
|
FirstRunState, FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability,
|
||||||
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
ReferenceProfiles, ReferenceProfilesOutput, SaveOpenCodeProviderProfile,
|
||||||
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
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,
|
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
|
// CloneOpenCodeProfileFromSeed
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -45,16 +45,18 @@ pub use agent::{
|
|||||||
reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer,
|
reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer,
|
||||||
AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
|
AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
|
||||||
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
||||||
CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
|
CloneOpenCodeProfileFromSeedOutput, CloneProfileFromSeed, CloneProfileFromSeedInput,
|
||||||
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
CloneProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput,
|
||||||
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput,
|
||||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
|
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput,
|
||||||
InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput,
|
FirstRunState, FirstRunStateOutput, HandoffProvider, InjectedLiveRow, InspectConversation,
|
||||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
InspectConversationInput, InspectConversationOutput, LaunchAgent, LaunchAgentInput,
|
||||||
ListAgentsOutput, ListOpenCodeProviders, ListOpenCodeProvidersOutput, ListProfiles,
|
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListClaudeModels,
|
||||||
ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput,
|
ListClaudeModelsOutput, ListCodexModels, ListCodexModelsOutput, ListOpenCodeProviders,
|
||||||
LiveStateLeanProvider, McpRuntime, OpenCodeProviderCatalogEntry, PermissionProjectorRegistry,
|
ListOpenCodeProvidersOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
|
||||||
ProfileAvailability, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput,
|
ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime,
|
||||||
|
OpenCodeProviderCatalogEntry, PermissionProjectorRegistry, ProfileAvailability,
|
||||||
|
ProfileModelCatalogEntry, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput,
|
||||||
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent,
|
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent,
|
||||||
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
||||||
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||||
|
|||||||
@ -24,9 +24,10 @@ use domain::profile::{
|
|||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
reference_profile_id, reference_profiles, CloneOpenCodeProfileFromSeed,
|
reference_profile_id, reference_profiles, AppError, CloneOpenCodeProfileFromSeed,
|
||||||
CloneOpenCodeProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile,
|
CloneOpenCodeProfileFromSeedInput, CloneProfileFromSeed, CloneProfileFromSeedInput,
|
||||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, ListProfiles,
|
ConfigureProfiles, ConfigureProfilesInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||||
|
DetectProfilesInput, FirstRunState, ListClaudeModels, ListCodexModels, ListProfiles,
|
||||||
ReferenceProfiles, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, SaveProfile,
|
ReferenceProfiles, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, SaveProfile,
|
||||||
SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
|
SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
|
||||||
};
|
};
|
||||||
@ -869,6 +870,114 @@ async fn clone_opencode_profile_falls_back_to_catalogue_when_persisted_seed_is_n
|
|||||||
assert_eq!(out.profile.name, "OpenCode + llama.cpp copy");
|
assert_eq!(out.profile.name, "OpenCode + llama.cpp copy");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn clone_profile_from_seed_creates_codex_profile_with_fresh_id_and_model_override() {
|
||||||
|
let store = FakeProfileStore::default();
|
||||||
|
let clone = CloneProfileFromSeed::new(
|
||||||
|
Arc::new(store.clone()),
|
||||||
|
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3801)])),
|
||||||
|
);
|
||||||
|
|
||||||
|
let out = clone
|
||||||
|
.execute(CloneProfileFromSeedInput {
|
||||||
|
seed_profile_id: reference_profile_id("codex"),
|
||||||
|
name: Some("Codex GPT-5".to_owned()),
|
||||||
|
model: Some("gpt-5-codex".to_owned()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
out.profile.id,
|
||||||
|
ProfileId::from_uuid(uuid::Uuid::from_u128(3801))
|
||||||
|
);
|
||||||
|
assert_eq!(out.profile.name, "Codex GPT-5");
|
||||||
|
assert_eq!(out.profile.model.as_deref(), Some("gpt-5-codex"));
|
||||||
|
assert_eq!(
|
||||||
|
out.profile.structured_adapter,
|
||||||
|
Some(StructuredAdapter::Codex)
|
||||||
|
);
|
||||||
|
assert_eq!(store.0.lock().unwrap().profiles, vec![out.profile]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn clone_profile_from_seed_prefers_persisted_seed_and_preserves_model_by_default() {
|
||||||
|
let store = FakeProfileStore::default();
|
||||||
|
let persisted = reference_profiles()
|
||||||
|
.into_iter()
|
||||||
|
.find(|profile| profile.id == reference_profile_id("claude"))
|
||||||
|
.expect("seed exists")
|
||||||
|
.with_model("claude-opus-4-8");
|
||||||
|
SaveProfile::new(Arc::new(store.clone()))
|
||||||
|
.execute(SaveProfileInput {
|
||||||
|
profile: persisted.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let clone = CloneProfileFromSeed::new(
|
||||||
|
Arc::new(store.clone()),
|
||||||
|
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3802)])),
|
||||||
|
);
|
||||||
|
let out = clone
|
||||||
|
.execute(CloneProfileFromSeedInput {
|
||||||
|
seed_profile_id: persisted.id,
|
||||||
|
name: None,
|
||||||
|
model: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out.profile.name, "Claude Code copy");
|
||||||
|
assert_eq!(out.profile.model.as_deref(), Some("claude-opus-4-8"));
|
||||||
|
assert_eq!(
|
||||||
|
out.profile.structured_adapter,
|
||||||
|
Some(StructuredAdapter::Claude)
|
||||||
|
);
|
||||||
|
assert_ne!(out.profile.id, persisted.id);
|
||||||
|
assert_eq!(store.0.lock().unwrap().profiles.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn clone_profile_from_seed_rejects_blank_model_override() {
|
||||||
|
let store = FakeProfileStore::default();
|
||||||
|
let clone = CloneProfileFromSeed::new(
|
||||||
|
Arc::new(store),
|
||||||
|
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3803)])),
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = clone
|
||||||
|
.execute(CloneProfileFromSeedInput {
|
||||||
|
seed_profile_id: reference_profile_id("claude"),
|
||||||
|
name: Some("Claude blank".to_owned()),
|
||||||
|
model: Some(" ".to_owned()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(err, AppError::Invalid(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn clone_profile_from_seed_rejects_blank_name_override() {
|
||||||
|
let store = FakeProfileStore::default();
|
||||||
|
let clone = CloneProfileFromSeed::new(
|
||||||
|
Arc::new(store),
|
||||||
|
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3804)])),
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = clone
|
||||||
|
.execute(CloneProfileFromSeedInput {
|
||||||
|
seed_profile_id: reference_profile_id("codex"),
|
||||||
|
name: Some(" ".to_owned()),
|
||||||
|
model: Some("gpt-5-codex".to_owned()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(err, AppError::Invalid(_)));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// ReferenceProfiles / catalogue
|
// ReferenceProfiles / catalogue
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -1049,3 +1158,22 @@ fn catalogue_gemini_and_aider_stay_pty_without_adapter() {
|
|||||||
assert_eq!(by_command["gemini"].structured_adapter, None);
|
assert_eq!(by_command["gemini"].structured_adapter, None);
|
||||||
assert_eq!(by_command["aider"].structured_adapter, None);
|
assert_eq!(by_command["aider"].structured_adapter, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claude_and_codex_model_catalogues_are_static_and_searchable() {
|
||||||
|
let claude = ListClaudeModels::new().execute().models;
|
||||||
|
let codex = ListCodexModels::new().execute().models;
|
||||||
|
|
||||||
|
assert!(claude
|
||||||
|
.iter()
|
||||||
|
.any(|model| model.model_id == "claude-sonnet-5" && model.recommended));
|
||||||
|
assert!(codex
|
||||||
|
.iter()
|
||||||
|
.any(|model| model.model_id == "gpt-5-codex" && model.recommended));
|
||||||
|
assert!(claude
|
||||||
|
.iter()
|
||||||
|
.all(|model| model.adapter == StructuredAdapter::Claude && !model.display_name.is_empty()));
|
||||||
|
assert!(codex
|
||||||
|
.iter()
|
||||||
|
.all(|model| model.adapter == StructuredAdapter::Codex && !model.display_name.is_empty()));
|
||||||
|
}
|
||||||
|
|||||||
@ -1047,6 +1047,57 @@ impl From<CloneOpenCodeProfileFromSeedOutput> for ProfileDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<application::CloneProfileFromSeedOutput> for ProfileDto {
|
||||||
|
fn from(out: application::CloneProfileFromSeedOutput) -> Self {
|
||||||
|
Self(out.profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry of a curated structured-profile model catalogue.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProfileModelCatalogEntryDto {
|
||||||
|
/// Structured adapter this model belongs to.
|
||||||
|
pub adapter: domain::profile::StructuredAdapter,
|
||||||
|
/// Exact model identifier to persist on `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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<application::ProfileModelCatalogEntry> for ProfileModelCatalogEntryDto {
|
||||||
|
fn from(entry: application::ProfileModelCatalogEntry) -> Self {
|
||||||
|
Self {
|
||||||
|
adapter: entry.adapter,
|
||||||
|
model_id: entry.model_id,
|
||||||
|
display_name: entry.display_name,
|
||||||
|
aliases: entry.aliases,
|
||||||
|
recommended: entry.recommended,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A list of curated structured-profile models.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct ProfileModelCatalogDto(pub Vec<ProfileModelCatalogEntryDto>);
|
||||||
|
|
||||||
|
impl From<application::ListClaudeModelsOutput> for ProfileModelCatalogDto {
|
||||||
|
fn from(out: application::ListClaudeModelsOutput) -> Self {
|
||||||
|
Self(out.models.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<application::ListCodexModelsOutput> for ProfileModelCatalogDto {
|
||||||
|
fn from(out: application::ListCodexModelsOutput) -> Self {
|
||||||
|
Self(out.models.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One entry of the static OpenCode cloud-provider catalogue (ticket #92, lot B3).
|
/// One entry of the static OpenCode cloud-provider catalogue (ticket #92, lot B3).
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -1202,6 +1253,30 @@ impl From<CloneOpenCodeProfileFromSeedRequestDto> for CloneOpenCodeProfileFromSe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `clone_profile_from_seed`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CloneProfileFromSeedRequestDto {
|
||||||
|
/// Id of the persisted or reference profile to clone.
|
||||||
|
pub seed_profile_id: domain::ids::ProfileId,
|
||||||
|
/// Optional display name for the new profile.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Optional model override. When omitted, the seed model is copied.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CloneProfileFromSeedRequestDto> for application::CloneProfileFromSeedInput {
|
||||||
|
fn from(dto: CloneProfileFromSeedRequestDto) -> Self {
|
||||||
|
Self {
|
||||||
|
seed_profile_id: dto.seed_profile_id,
|
||||||
|
name: dto.name,
|
||||||
|
model: dto.model,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Request DTO for `configure_profiles` (closes the first run).
|
/// Request DTO for `configure_profiles` (closes the first run).
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@ -15,24 +15,24 @@ use application::{
|
|||||||
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
||||||
AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive,
|
AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive,
|
||||||
CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion,
|
CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion,
|
||||||
CloneOpenCodeProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant,
|
CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal,
|
||||||
ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate,
|
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
|
||||||
CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint,
|
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
|
||||||
CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory,
|
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
|
||||||
DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate,
|
DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate,
|
||||||
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
||||||
EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory,
|
EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory,
|
||||||
GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState, GitBranches,
|
GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState, GitBranches,
|
||||||
GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
|
GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
|
||||||
HarvestMemoryFromTurn, HealthUseCase, InspectConversation, InstallPluginFromArchive,
|
HarvestMemoryFromTurn, HealthUseCase, InspectConversation, InstallPluginFromArchive,
|
||||||
InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput,
|
InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput,
|
||||||
LinkIssues, ListAgents, ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues,
|
LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices,
|
||||||
ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders,
|
ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
||||||
ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents,
|
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
|
||||||
ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider,
|
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
||||||
LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue,
|
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
||||||
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
||||||
OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
||||||
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
|
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
|
||||||
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
|
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
|
||||||
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
|
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
|
||||||
@ -940,6 +940,12 @@ pub struct BackendCore {
|
|||||||
pub save_opencode_provider_profile: Arc<SaveOpenCodeProviderProfile>,
|
pub save_opencode_provider_profile: Arc<SaveOpenCodeProviderProfile>,
|
||||||
/// Static catalogue of OpenCode cloud providers (ticket #92, lot B3).
|
/// Static catalogue of OpenCode cloud providers (ticket #92, lot B3).
|
||||||
pub list_opencode_providers: Arc<ListOpenCodeProviders>,
|
pub list_opencode_providers: Arc<ListOpenCodeProviders>,
|
||||||
|
/// Static curated Claude model catalogue.
|
||||||
|
pub list_claude_models: Arc<ListClaudeModels>,
|
||||||
|
/// Static curated Codex model catalogue.
|
||||||
|
pub list_codex_models: Arc<ListCodexModels>,
|
||||||
|
/// Create a new profile instance from a persisted/reference seed.
|
||||||
|
pub clone_profile_from_seed: Arc<CloneProfileFromSeed>,
|
||||||
/// Create a new OpenCode profile instance from the canonical seed.
|
/// Create a new OpenCode profile instance from the canonical seed.
|
||||||
pub clone_opencode_profile_from_seed: Arc<CloneOpenCodeProfileFromSeed>,
|
pub clone_opencode_profile_from_seed: Arc<CloneOpenCodeProfileFromSeed>,
|
||||||
/// Delete a profile.
|
/// Delete a profile.
|
||||||
@ -1468,6 +1474,12 @@ impl BackendCore {
|
|||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
));
|
));
|
||||||
let list_opencode_providers = Arc::new(ListOpenCodeProviders::new());
|
let list_opencode_providers = Arc::new(ListOpenCodeProviders::new());
|
||||||
|
let list_claude_models = Arc::new(ListClaudeModels::new());
|
||||||
|
let list_codex_models = Arc::new(ListCodexModels::new());
|
||||||
|
let clone_profile_from_seed = Arc::new(CloneProfileFromSeed::new(
|
||||||
|
Arc::clone(&profile_store_port),
|
||||||
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
|
));
|
||||||
let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new(
|
let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new(
|
||||||
Arc::clone(&profile_store_port),
|
Arc::clone(&profile_store_port),
|
||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
@ -2661,6 +2673,9 @@ impl BackendCore {
|
|||||||
save_profile,
|
save_profile,
|
||||||
save_opencode_provider_profile,
|
save_opencode_provider_profile,
|
||||||
list_opencode_providers,
|
list_opencode_providers,
|
||||||
|
list_claude_models,
|
||||||
|
list_codex_models,
|
||||||
|
clone_profile_from_seed,
|
||||||
clone_opencode_profile_from_seed,
|
clone_opencode_profile_from_seed,
|
||||||
delete_profile,
|
delete_profile,
|
||||||
configure_profiles,
|
configure_profiles,
|
||||||
|
|||||||
@ -42,6 +42,7 @@ import type {
|
|||||||
ProjectWorkState,
|
ProjectWorkState,
|
||||||
ProjectSystemPermissions,
|
ProjectSystemPermissions,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
|
ProfileModelCatalogEntry,
|
||||||
ResolvedAgentSystemPermissions,
|
ResolvedAgentSystemPermissions,
|
||||||
SystemPermissionSet,
|
SystemPermissionSet,
|
||||||
Skill,
|
Skill,
|
||||||
@ -50,6 +51,7 @@ import type {
|
|||||||
TurnPage,
|
TurnPage,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import type {
|
import type {
|
||||||
|
CloneProfileFromSeedInput,
|
||||||
CloneOpenCodeProfileFromSeedInput,
|
CloneOpenCodeProfileFromSeedInput,
|
||||||
ConversationGateway,
|
ConversationGateway,
|
||||||
ConversationPageRequest,
|
ConversationPageRequest,
|
||||||
@ -176,6 +178,17 @@ export class HttpProfileGateway implements ProfileGateway {
|
|||||||
async deleteProfile(profileId: string): Promise<void> {
|
async deleteProfile(profileId: string): Promise<void> {
|
||||||
await this.http.invoke("delete_profile", { profileId });
|
await this.http.invoke("delete_profile", { profileId });
|
||||||
}
|
}
|
||||||
|
cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise<AgentProfile> {
|
||||||
|
return this.http.invoke<AgentProfile>("clone_profile_from_seed", {
|
||||||
|
request: { seedProfileId: input.seedProfileId, name: input.name, model: input.model },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
listClaudeModels(): Promise<ProfileModelCatalogEntry[]> {
|
||||||
|
return this.http.invoke<ProfileModelCatalogEntry[]>("list_claude_models");
|
||||||
|
}
|
||||||
|
listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
|
||||||
|
return this.http.invoke<ProfileModelCatalogEntry[]>("list_codex_models");
|
||||||
|
}
|
||||||
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
|
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
|
||||||
return this.http.invoke<AgentProfile[]>("configure_profiles", { request: { profiles } });
|
return this.http.invoke<AgentProfile[]>("configure_profiles", { request: { profiles } });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import type {
|
|||||||
McpToolCatalogue,
|
McpToolCatalogue,
|
||||||
McpToolPolicy,
|
McpToolPolicy,
|
||||||
OpenCodeProviderCatalogEntry,
|
OpenCodeProviderCatalogEntry,
|
||||||
|
ProfileModelCatalogEntry,
|
||||||
EffectivePermissions,
|
EffectivePermissions,
|
||||||
PairedDevice,
|
PairedDevice,
|
||||||
PairingCode,
|
PairingCode,
|
||||||
@ -80,6 +81,7 @@ import type {
|
|||||||
ConversationGateway,
|
ConversationGateway,
|
||||||
ConversationPageRequest,
|
ConversationPageRequest,
|
||||||
ConversationDetails,
|
ConversationDetails,
|
||||||
|
CloneProfileFromSeedInput,
|
||||||
CloneOpenCodeProfileFromSeedInput,
|
CloneOpenCodeProfileFromSeedInput,
|
||||||
CreateAgentInput,
|
CreateAgentInput,
|
||||||
CreateMemoryInput,
|
CreateMemoryInput,
|
||||||
@ -1293,6 +1295,54 @@ const MOCK_OPENCODE_PROVIDERS: OpenCodeProviderCatalogEntry[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [
|
||||||
|
{
|
||||||
|
adapter: "claude",
|
||||||
|
modelId: "claude-sonnet-5",
|
||||||
|
displayName: "Claude Sonnet 5",
|
||||||
|
aliases: ["sonnet"],
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
adapter: "claude",
|
||||||
|
modelId: "claude-opus-4-8",
|
||||||
|
displayName: "Claude Opus 4.8",
|
||||||
|
aliases: ["opus"],
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
adapter: "claude",
|
||||||
|
modelId: "claude-haiku-4-5-20251001",
|
||||||
|
displayName: "Claude Haiku 4.5",
|
||||||
|
aliases: ["haiku"],
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [
|
||||||
|
{
|
||||||
|
adapter: "codex",
|
||||||
|
modelId: "gpt-5-codex",
|
||||||
|
displayName: "GPT-5 Codex",
|
||||||
|
aliases: ["codex"],
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
adapter: "codex",
|
||||||
|
modelId: "gpt-5",
|
||||||
|
displayName: "GPT-5",
|
||||||
|
aliases: ["general"],
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
adapter: "codex",
|
||||||
|
modelId: "gpt-5-mini",
|
||||||
|
displayName: "GPT-5 mini",
|
||||||
|
aliases: ["mini", "fast"],
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory profiles gateway. Tracks configured profiles and a first-run flag so
|
* In-memory profiles gateway. Tracks configured profiles and a first-run flag so
|
||||||
* the wizard can be driven and tested fully offline. By default it reports the
|
* the wizard can be driven and tested fully offline. By default it reports the
|
||||||
@ -1348,6 +1398,36 @@ export class MockProfileGateway implements ProfileGateway {
|
|||||||
return structuredClone(profiles);
|
return structuredClone(profiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cloneProfileFromSeed(
|
||||||
|
input: CloneProfileFromSeedInput,
|
||||||
|
): Promise<AgentProfile> {
|
||||||
|
const seed = [...this.profiles, ...MOCK_REFERENCE_PROFILES].find(
|
||||||
|
(p) => p.id === input.seedProfileId,
|
||||||
|
);
|
||||||
|
if (!seed) throw new Error(`unknown profile seed: ${input.seedProfileId}`);
|
||||||
|
this.cloneCounter += 1;
|
||||||
|
const cloned: AgentProfile = {
|
||||||
|
...structuredClone(seed),
|
||||||
|
id: `mock-profile-clone-${this.cloneCounter}`,
|
||||||
|
name: input.name ?? `${seed.name} copy`,
|
||||||
|
model:
|
||||||
|
input.model !== undefined && input.model.trim() !== ""
|
||||||
|
? input.model
|
||||||
|
: seed.model,
|
||||||
|
};
|
||||||
|
this.profiles.push(cloned);
|
||||||
|
this.configured = true;
|
||||||
|
return structuredClone(cloned);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listClaudeModels(): Promise<ProfileModelCatalogEntry[]> {
|
||||||
|
return structuredClone(MOCK_CLAUDE_MODELS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
|
||||||
|
return structuredClone(MOCK_CODEX_MODELS);
|
||||||
|
}
|
||||||
|
|
||||||
async cloneOpenCodeProfileFromSeed(
|
async cloneOpenCodeProfileFromSeed(
|
||||||
input: CloneOpenCodeProfileFromSeedInput = {},
|
input: CloneOpenCodeProfileFromSeedInput = {},
|
||||||
): Promise<AgentProfile> {
|
): Promise<AgentProfile> {
|
||||||
|
|||||||
@ -12,9 +12,11 @@ import type {
|
|||||||
AgentProfile,
|
AgentProfile,
|
||||||
FirstRunState,
|
FirstRunState,
|
||||||
OpenCodeProviderCatalogEntry,
|
OpenCodeProviderCatalogEntry,
|
||||||
|
ProfileModelCatalogEntry,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import type {
|
import type {
|
||||||
|
CloneProfileFromSeedInput,
|
||||||
CloneOpenCodeProfileFromSeedInput,
|
CloneOpenCodeProfileFromSeedInput,
|
||||||
ProfileGateway,
|
ProfileGateway,
|
||||||
SaveOpenCodeProviderProfileInput,
|
SaveOpenCodeProviderProfileInput,
|
||||||
@ -47,6 +49,24 @@ export class TauriProfileGateway implements ProfileGateway {
|
|||||||
await invoke("delete_profile", { profileId });
|
await invoke("delete_profile", { profileId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise<AgentProfile> {
|
||||||
|
return invoke<AgentProfile>("clone_profile_from_seed", {
|
||||||
|
request: {
|
||||||
|
seedProfileId: input.seedProfileId,
|
||||||
|
name: input.name,
|
||||||
|
model: input.model,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listClaudeModels(): Promise<ProfileModelCatalogEntry[]> {
|
||||||
|
return invoke<ProfileModelCatalogEntry[]>("list_claude_models");
|
||||||
|
}
|
||||||
|
|
||||||
|
listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
|
||||||
|
return invoke<ProfileModelCatalogEntry[]>("list_codex_models");
|
||||||
|
}
|
||||||
|
|
||||||
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
|
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
|
||||||
return invoke<AgentProfile[]>("configure_profiles", {
|
return invoke<AgentProfile[]>("configure_profiles", {
|
||||||
request: { profiles },
|
request: { profiles },
|
||||||
|
|||||||
@ -1096,6 +1096,20 @@ export interface OpenCodeProviderCatalogEntry {
|
|||||||
models: string[];
|
models: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One searchable model from the Codex/Claude structured-profile catalogues. */
|
||||||
|
export interface ProfileModelCatalogEntry {
|
||||||
|
/** Structured adapter this model belongs to. */
|
||||||
|
adapter: "claude" | "codex";
|
||||||
|
/** Exact model identifier to persist on `AgentProfile.model`. */
|
||||||
|
modelId: string;
|
||||||
|
/** Human-readable label for picker display. */
|
||||||
|
displayName: string;
|
||||||
|
/** Extra search tokens useful to the frontend. */
|
||||||
|
aliases: string[];
|
||||||
|
/** Whether this entry is the conservative default suggestion. */
|
||||||
|
recommended: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a
|
* A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a
|
||||||
* UUID string; `detect` is the optional detection command line.
|
* UUID string; `detect` is the optional detection command line.
|
||||||
|
|||||||
@ -237,6 +237,15 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
// Determine if a template is chosen → profile selector is hidden (template imposes it).
|
// Determine if a template is chosen → profile selector is hidden (template imposes it).
|
||||||
const hasTemplate = newTemplateId !== "";
|
const hasTemplate = newTemplateId !== "";
|
||||||
|
|
||||||
|
const profileLabel = (profile: import("@/domain").AgentProfile): string => {
|
||||||
|
const model =
|
||||||
|
profile.model ??
|
||||||
|
profile.opencode?.model ??
|
||||||
|
profile.opencodeProvider?.model ??
|
||||||
|
profile.chatHttp?.model;
|
||||||
|
return model ? `${profile.name} · ${model}` : profile.name;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel title="Agents" className="flex flex-col gap-0">
|
<Panel title="Agents" className="flex flex-col gap-0">
|
||||||
{vm.error && (
|
{vm.error && (
|
||||||
@ -326,7 +335,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
<option value="">— select profile —</option>
|
<option value="">— select profile —</option>
|
||||||
{vm.profiles.map((p) => (
|
{vm.profiles.map((p) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
{p.name}
|
{profileLabel(p)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -366,7 +375,10 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
const isRunning = a.id === activeAgentId;
|
const isRunning = a.id === activeAgentId;
|
||||||
const live = vm.liveAgents.find((candidate) => candidate.agentId === a.id);
|
const live = vm.liveAgents.find((candidate) => candidate.agentId === a.id);
|
||||||
const profileName =
|
const profileName =
|
||||||
vm.profiles.find((p) => p.id === a.profileId)?.name ??
|
(() => {
|
||||||
|
const p = vm.profiles.find((p) => p.id === a.profileId);
|
||||||
|
return p ? profileLabel(p) : null;
|
||||||
|
})() ??
|
||||||
a.profileId;
|
a.profileId;
|
||||||
const agentDrift = drift.driftByAgentId.get(a.id);
|
const agentDrift = drift.driftByAgentId.get(a.id);
|
||||||
// Source of this agent's last orchestration delegation (mcp vs
|
// Source of this agent's last orchestration delegation (mcp vs
|
||||||
@ -478,7 +490,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
)}
|
)}
|
||||||
{vm.profiles.map((p) => (
|
{vm.profiles.map((p) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
{p.name}
|
{profileLabel(p)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@ -141,6 +141,42 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
|||||||
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows profile names with their model in the assignment selector", async () => {
|
||||||
|
const profile = new MockProfileGateway();
|
||||||
|
await profile.saveProfile({
|
||||||
|
id: "codex-fast",
|
||||||
|
name: "Codex fast",
|
||||||
|
command: "codex",
|
||||||
|
args: [],
|
||||||
|
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
||||||
|
detect: "codex --version",
|
||||||
|
cwdTemplate: "{projectRoot}",
|
||||||
|
structuredAdapter: "codex",
|
||||||
|
model: "gpt-5-mini",
|
||||||
|
});
|
||||||
|
await profile.saveProfile({
|
||||||
|
id: "claude-opus",
|
||||||
|
name: "Claude deep",
|
||||||
|
command: "claude",
|
||||||
|
args: [],
|
||||||
|
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
|
||||||
|
detect: "claude --version",
|
||||||
|
cwdTemplate: "{projectRoot}",
|
||||||
|
structuredAdapter: "claude",
|
||||||
|
model: "claude-opus-4-8",
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPanel(new MockAgentGateway(), profile);
|
||||||
|
await waitForIdle();
|
||||||
|
|
||||||
|
const labels = Array.from(
|
||||||
|
screen.getByLabelText("agent profile").querySelectorAll("option"),
|
||||||
|
).map((option) => option.textContent);
|
||||||
|
|
||||||
|
expect(labels).toContain("Codex fast · gpt-5-mini");
|
||||||
|
expect(labels).toContain("Claude deep · claude-opus-4-8");
|
||||||
|
});
|
||||||
|
|
||||||
it("selecting an agent displays its context", async () => {
|
it("selecting an agent displays its context", async () => {
|
||||||
const agent = new MockAgentGateway();
|
const agent = new MockAgentGateway();
|
||||||
// Pre-seed an agent with initial content.
|
// Pre-seed an agent with initial content.
|
||||||
|
|||||||
113
frontend/src/features/first-run/ProfilesSettings.test.tsx
Normal file
113
frontend/src/features/first-run/ProfilesSettings.test.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { DIProvider } from "@/app/di";
|
||||||
|
import { MockProfileGateway } from "@/adapters/mock";
|
||||||
|
import type { Gateways } from "@/ports";
|
||||||
|
import type { ProfileModelCatalogEntry } from "@/domain";
|
||||||
|
import { ProfilesSettings } from "./ProfilesSettings";
|
||||||
|
|
||||||
|
function renderSettings(profile: MockProfileGateway = new MockProfileGateway()) {
|
||||||
|
return {
|
||||||
|
profile,
|
||||||
|
...render(
|
||||||
|
<DIProvider gateways={{ profile } as unknown as Gateways}>
|
||||||
|
<ProfilesSettings />
|
||||||
|
</DIProvider>,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitReady() {
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", { name: "Creer un profil" }) as HTMLButtonElement)
|
||||||
|
.disabled,
|
||||||
|
).toBe(false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProfile() {
|
||||||
|
const before = screen.queryAllByRole("listitem").length;
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Creer un profil" }));
|
||||||
|
await waitFor(() => expect(screen.getAllByRole("listitem")).toHaveLength(before + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ProfilesSettings", () => {
|
||||||
|
it("creates multiple named Codex and Claude profiles with different models", async () => {
|
||||||
|
const { profile } = renderSettings();
|
||||||
|
await waitReady();
|
||||||
|
|
||||||
|
await createProfile();
|
||||||
|
await createProfile();
|
||||||
|
let rows = screen.getAllByRole("listitem");
|
||||||
|
fireEvent.change(within(rows[1]).getByLabelText(/nom du profil/), {
|
||||||
|
target: { value: "Codex mini" },
|
||||||
|
});
|
||||||
|
fireEvent.change(within(rows[1]).getByLabelText(/modele du profil/), {
|
||||||
|
target: { value: "gpt-5-mini" },
|
||||||
|
});
|
||||||
|
fireEvent.click(within(rows[1]).getByRole("button", { name: "Enregistrer" }));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Claude" }));
|
||||||
|
await waitReady();
|
||||||
|
await createProfile();
|
||||||
|
await createProfile();
|
||||||
|
rows = screen.getAllByRole("listitem");
|
||||||
|
fireEvent.change(within(rows[1]).getByLabelText(/nom du profil/), {
|
||||||
|
target: { value: "Claude Opus" },
|
||||||
|
});
|
||||||
|
fireEvent.change(within(rows[1]).getByLabelText(/modele du profil/), {
|
||||||
|
target: { value: "claude-opus-4-8" },
|
||||||
|
});
|
||||||
|
fireEvent.click(within(rows[1]).getByRole("button", { name: "Enregistrer" }));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const saved = await profile.listProfiles();
|
||||||
|
expect(saved.filter((p) => p.structuredAdapter === "codex")).toHaveLength(2);
|
||||||
|
expect(saved.filter((p) => p.structuredAdapter === "claude")).toHaveLength(2);
|
||||||
|
expect(saved.map((p) => p.model)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"gpt-5-codex",
|
||||||
|
"gpt-5-mini",
|
||||||
|
"claude-sonnet-5",
|
||||||
|
"claude-opus-4-8",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplicates from an existing profile with '<name> copy' and preserves the model", async () => {
|
||||||
|
const { profile } = renderSettings();
|
||||||
|
await waitReady();
|
||||||
|
await createProfile();
|
||||||
|
|
||||||
|
const row = screen.getAllByRole("listitem")[0];
|
||||||
|
fireEvent.click(within(row).getByRole("button", { name: "Dupliquer" }));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const saved = await profile.listProfiles();
|
||||||
|
expect(saved.some((p) => p.name === "OpenAI Codex CLI copy copy")).toBe(true);
|
||||||
|
expect(saved.filter((p) => p.model === "gpt-5-codex")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps manual model entry available when the catalogue fails", async () => {
|
||||||
|
class CatalogueDownProfileGateway extends MockProfileGateway {
|
||||||
|
listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
|
||||||
|
return Promise.reject(new Error("catalogue down"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSettings(new CatalogueDownProfileGateway());
|
||||||
|
await waitReady();
|
||||||
|
expect(await screen.findByText(/saisie manuelle active/)).toBeTruthy();
|
||||||
|
|
||||||
|
await createProfile();
|
||||||
|
const model = within(screen.getAllByRole("listitem")[0]).getByLabelText(
|
||||||
|
/modele du profil/,
|
||||||
|
) as HTMLInputElement;
|
||||||
|
fireEvent.change(model, { target: { value: "future-codex-model" } });
|
||||||
|
expect(model.value).toBe("future-codex-model");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,34 +1,94 @@
|
|||||||
/**
|
/**
|
||||||
* Minimal "Settings → AI Profiles" panel (L5). An always-available entry point
|
* Settings -> AI Profiles. This is the durable CRUD surface for named runtime
|
||||||
* to review the configured profiles and re-run the setup wizard after the first
|
* profiles; first-run stays a small default-profile bootstrap.
|
||||||
* run. Kept intentionally small; richer per-profile editing reuses the wizard.
|
|
||||||
*
|
|
||||||
* Pure presentation over the {@link ProfileGateway} port (no `invoke()`).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import type { AgentProfile, GatewayError } from "@/domain";
|
import type {
|
||||||
|
AgentProfile,
|
||||||
|
GatewayError,
|
||||||
|
ProfileModelCatalogEntry,
|
||||||
|
} from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { Button, Panel } from "@/shared";
|
import { Button, Input, Panel, cn } from "@/shared";
|
||||||
import { FirstRunWizard } from "./FirstRunWizard";
|
|
||||||
|
type ProfileTab = "codex" | "claude" | "openCode";
|
||||||
|
|
||||||
|
const TABS: Array<{ id: ProfileTab; label: string }> = [
|
||||||
|
{ id: "codex", label: "Codex" },
|
||||||
|
{ id: "claude", label: "Claude" },
|
||||||
|
{ id: "openCode", label: "OpenCode-local" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_CATALOGUE: Record<"codex" | "claude", ProfileModelCatalogEntry[]> = {
|
||||||
|
codex: [],
|
||||||
|
claude: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function describe(e: unknown): string {
|
||||||
|
if (e && typeof e === "object" && "message" in e) {
|
||||||
|
return String((e as GatewayError).message);
|
||||||
|
}
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabFor(profile: AgentProfile): ProfileTab | null {
|
||||||
|
if (profile.structuredAdapter === "codex") return "codex";
|
||||||
|
if (profile.structuredAdapter === "claude") return "claude";
|
||||||
|
if (profile.structuredAdapter === "openCode" && profile.opencode) {
|
||||||
|
return "openCode";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelOf(profile: AgentProfile): string {
|
||||||
|
if (profile.structuredAdapter === "openCode") {
|
||||||
|
return profile.opencode?.model ?? profile.opencodeProvider?.model ?? "";
|
||||||
|
}
|
||||||
|
return profile.model ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function withModel(profile: AgentProfile, model: string): AgentProfile {
|
||||||
|
const nextModel = model.trim() || undefined;
|
||||||
|
if (profile.structuredAdapter === "openCode" && profile.opencode) {
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
opencode: { ...profile.opencode, model: model.trim() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...profile, model: nextModel };
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionLabel(entry: ProfileModelCatalogEntry): string {
|
||||||
|
return entry.recommended
|
||||||
|
? `${entry.displayName} (${entry.modelId}, recommande)`
|
||||||
|
: `${entry.displayName} (${entry.modelId})`;
|
||||||
|
}
|
||||||
|
|
||||||
export function ProfilesSettings() {
|
export function ProfilesSettings() {
|
||||||
const { profile } = useGateways();
|
const { profile } = useGateways();
|
||||||
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
|
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
|
||||||
|
const [references, setReferences] = useState<AgentProfile[]>([]);
|
||||||
|
const [catalogue, setCatalogue] = useState(EMPTY_CATALOGUE);
|
||||||
|
const [activeTab, setActiveTab] = useState<ProfileTab>("codex");
|
||||||
|
const [drafts, setDrafts] = useState<Record<string, AgentProfile>>({});
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [editing, setEditing] = useState(false);
|
const [catalogueWarning, setCatalogueWarning] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
setProfiles(await profile.listProfiles());
|
const [saved, refs] = await Promise.all([
|
||||||
|
profile.listProfiles(),
|
||||||
|
profile.referenceProfiles(),
|
||||||
|
]);
|
||||||
|
setProfiles(saved);
|
||||||
|
setReferences(refs);
|
||||||
|
setDrafts(Object.fromEntries(saved.map((p) => [p.id, p])));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(
|
setError(describe(e));
|
||||||
e && typeof e === "object" && "message" in e
|
|
||||||
? String((e as GatewayError).message)
|
|
||||||
: String(e),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [profile]);
|
}, [profile]);
|
||||||
|
|
||||||
@ -36,64 +96,263 @@ export function ProfilesSettings() {
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
async function del(id: string) {
|
useEffect(() => {
|
||||||
await profile.deleteProfile(id);
|
let cancelled = false;
|
||||||
await refresh();
|
async function loadCatalogue() {
|
||||||
|
setCatalogueWarning(null);
|
||||||
|
const [codex, claude] = await Promise.allSettled([
|
||||||
|
profile.listCodexModels(),
|
||||||
|
profile.listClaudeModels(),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setCatalogue({
|
||||||
|
codex: codex.status === "fulfilled" ? codex.value : [],
|
||||||
|
claude: claude.status === "fulfilled" ? claude.value : [],
|
||||||
|
});
|
||||||
|
if (codex.status === "rejected" || claude.status === "rejected") {
|
||||||
|
setCatalogueWarning(
|
||||||
|
"Catalogue de modeles indisponible: saisie manuelle active.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadCatalogue();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
const visibleProfiles = useMemo(
|
||||||
|
() => profiles.filter((p) => tabFor(p) === activeTab),
|
||||||
|
[profiles, activeTab],
|
||||||
|
);
|
||||||
|
|
||||||
|
const seed = useMemo(
|
||||||
|
() => references.find((p) => tabFor(p) === activeTab) ?? null,
|
||||||
|
[references, activeTab],
|
||||||
|
);
|
||||||
|
|
||||||
|
function updateDraft(id: string, updater: (profile: AgentProfile) => AgentProfile) {
|
||||||
|
setDrafts((prev) => {
|
||||||
|
const current = prev[id] ?? profiles.find((p) => p.id === id);
|
||||||
|
if (!current) return prev;
|
||||||
|
return { ...prev, [id]: updater(current) };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editing) {
|
async function createFromSeed() {
|
||||||
// Reopened after the first run, so force the wizard to render.
|
if (!seed) return;
|
||||||
return (
|
setBusy(true);
|
||||||
<FirstRunWizard
|
setError(null);
|
||||||
forceOpen
|
try {
|
||||||
onDone={() => {
|
const models =
|
||||||
setEditing(false);
|
activeTab === "codex" || activeTab === "claude"
|
||||||
void refresh();
|
? catalogue[activeTab]
|
||||||
}}
|
: [];
|
||||||
/>
|
const recommended = models.find((m) => m.recommended)?.modelId;
|
||||||
);
|
await profile.cloneProfileFromSeed({
|
||||||
|
seedProfileId: seed.id,
|
||||||
|
name: `${seed.name} copy`,
|
||||||
|
model: recommended,
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function save(id: string) {
|
||||||
|
const draft = drafts[id];
|
||||||
|
if (!draft) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await profile.saveProfile(draft);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function duplicate(source: AgentProfile) {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await profile.cloneProfileFromSeed({
|
||||||
|
seedProfileId: source.id,
|
||||||
|
name: `${source.name} copy`,
|
||||||
|
model:
|
||||||
|
source.structuredAdapter === "codex" ||
|
||||||
|
source.structuredAdapter === "claude"
|
||||||
|
? modelOf(source) || undefined
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(source: AgentProfile) {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await profile.deleteProfile(source.id);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelOptions =
|
||||||
|
activeTab === "codex" || activeTab === "claude" ? catalogue[activeTab] : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
aria-label="ai profiles settings"
|
aria-label="ai profiles settings"
|
||||||
title="Profils IA"
|
title="Profils IA"
|
||||||
actions={
|
actions={
|
||||||
<Button size="sm" onClick={() => setEditing(true)}>
|
<Button size="sm" onClick={() => void createFromSeed()} disabled={!seed || busy}>
|
||||||
Configurer les profils
|
Creer un profil
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="types de profils IA"
|
||||||
|
className="inline-flex w-fit rounded-md border border-border bg-raised p-0.5"
|
||||||
|
>
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={cn(
|
||||||
|
"h-7 rounded px-3 text-xs font-medium transition-colors",
|
||||||
|
activeTab === tab.id
|
||||||
|
? "bg-surface text-content shadow-sm"
|
||||||
|
: "text-muted hover:text-content",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-sm text-danger">
|
<p role="alert" className="text-sm text-danger">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{catalogueWarning && (
|
||||||
|
<p className="text-xs text-muted">{catalogueWarning}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{profiles.length === 0 ? (
|
<datalist id={`profile-models-${activeTab}`}>
|
||||||
<p className="text-sm text-muted">Aucun profil configuré.</p>
|
{modelOptions.map((entry) => (
|
||||||
|
<option key={entry.modelId} value={entry.modelId}>
|
||||||
|
{optionLabel(entry)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
|
||||||
|
{visibleProfiles.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Aucun profil {TABS.find((tab) => tab.id === activeTab)?.label} configure.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex flex-col divide-y divide-border">
|
<ul className="flex flex-col gap-3">
|
||||||
{profiles.map((p) => (
|
{visibleProfiles.map((saved) => {
|
||||||
<li
|
const draft = drafts[saved.id] ?? saved;
|
||||||
key={p.id}
|
const model = modelOf(draft);
|
||||||
className="flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0"
|
const dirty = JSON.stringify(draft) !== JSON.stringify(saved);
|
||||||
>
|
return (
|
||||||
<span className="flex items-baseline gap-2">
|
<li
|
||||||
<strong className="text-sm text-content">{p.name}</strong>
|
key={saved.id}
|
||||||
<code className="text-xs text-muted">{p.command}</code>
|
className="rounded-md border border-border bg-surface p-3"
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
aria-label={`supprimer ${p.name}`}
|
|
||||||
onClick={() => void del(p.id)}
|
|
||||||
>
|
>
|
||||||
Supprimer
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||||
</Button>
|
<label className="flex min-w-0 flex-col gap-1">
|
||||||
</li>
|
<span className="text-xs font-medium text-muted">Nom</span>
|
||||||
))}
|
<Input
|
||||||
|
aria-label={`nom du profil ${saved.name}`}
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateDraft(saved.id, (p) => ({
|
||||||
|
...p,
|
||||||
|
name: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex min-w-0 flex-col gap-1">
|
||||||
|
<span className="text-xs font-medium text-muted">Modele</span>
|
||||||
|
<Input
|
||||||
|
aria-label={`modele du profil ${saved.name}`}
|
||||||
|
list={`profile-models-${activeTab}`}
|
||||||
|
placeholder={
|
||||||
|
modelOptions.length > 0
|
||||||
|
? "Choisir ou saisir un modele"
|
||||||
|
: "Saisir un modele"
|
||||||
|
}
|
||||||
|
value={model}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateDraft(saved.id, (p) =>
|
||||||
|
withModel(p, e.target.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<code className="min-w-0 truncate text-xs text-muted">
|
||||||
|
{draft.command}
|
||||||
|
{model ? ` · ${model}` : ""}
|
||||||
|
</code>
|
||||||
|
<span className="flex flex-wrap gap-1.5">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
disabled={!dirty || busy || draft.name.trim() === ""}
|
||||||
|
onClick={() => void save(saved.id)}
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void duplicate(saved)}
|
||||||
|
>
|
||||||
|
Dupliquer
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy}
|
||||||
|
className="text-danger hover:text-danger"
|
||||||
|
aria-label={`supprimer ${saved.name}`}
|
||||||
|
onClick={() => void del(saved)}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -37,6 +37,7 @@ import type {
|
|||||||
McpToolPolicy,
|
McpToolPolicy,
|
||||||
OpenCodeConfig,
|
OpenCodeConfig,
|
||||||
OpenCodeProviderCatalogEntry,
|
OpenCodeProviderCatalogEntry,
|
||||||
|
ProfileModelCatalogEntry,
|
||||||
EffectivePermissions,
|
EffectivePermissions,
|
||||||
PairedDevice,
|
PairedDevice,
|
||||||
PairingCode,
|
PairingCode,
|
||||||
@ -664,6 +665,15 @@ export interface ProfileGateway {
|
|||||||
saveProfile(profile: AgentProfile): Promise<AgentProfile>;
|
saveProfile(profile: AgentProfile): Promise<AgentProfile>;
|
||||||
/** Deletes a profile by id. */
|
/** Deletes a profile by id. */
|
||||||
deleteProfile(profileId: string): Promise<void>;
|
deleteProfile(profileId: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Clones a persisted or reference profile seed and saves the fresh profile.
|
||||||
|
* Used by Settings duplication for Codex/Claude/OpenCode identity copies.
|
||||||
|
*/
|
||||||
|
cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise<AgentProfile>;
|
||||||
|
/** Curated Claude Code model catalogue. Manual model entry remains supported. */
|
||||||
|
listClaudeModels(): Promise<ProfileModelCatalogEntry[]>;
|
||||||
|
/** Curated Codex CLI model catalogue. Manual model entry remains supported. */
|
||||||
|
listCodexModels(): Promise<ProfileModelCatalogEntry[]>;
|
||||||
/** Persists the batch of chosen profiles, closing the first run. */
|
/** Persists the batch of chosen profiles, closing the first run. */
|
||||||
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]>;
|
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]>;
|
||||||
/**
|
/**
|
||||||
@ -701,6 +711,16 @@ export interface CloneOpenCodeProfileFromSeedInput {
|
|||||||
opencode?: OpenCodeConfig;
|
opencode?: OpenCodeConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Input for {@link ProfileGateway.cloneProfileFromSeed}. */
|
||||||
|
export interface CloneProfileFromSeedInput {
|
||||||
|
/** Id of the persisted or reference profile to clone. */
|
||||||
|
seedProfileId: string;
|
||||||
|
/** Optional display name for the new profile. */
|
||||||
|
name?: string;
|
||||||
|
/** Optional model override. When omitted, the seed model is copied. */
|
||||||
|
model?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Input for {@link ProfileGateway.saveOpenCodeProviderProfile}. */
|
/** Input for {@link ProfileGateway.saveOpenCodeProviderProfile}. */
|
||||||
export interface SaveOpenCodeProviderProfileInput {
|
export interface SaveOpenCodeProviderProfileInput {
|
||||||
/** The profile to create or replace (by id). */
|
/** The profile to create or replace (by id). */
|
||||||
|
|||||||
Reference in New Issue
Block a user