feat: catalogue dynamique modèles Codex/Claude avec compatibilité CLI locale
Ajout du catalogue enrichi pour les modèles Codex et Claude avec: - Compatibilité estimée avec la version CLI locale détectée - Source d'origine (catalogue/Provider) pour chaque entrée - Support du catalogue Provider API externe - Matrice de compatibilité embarquée dans l'application Frontend: - UI de configuration des modèles avec affichage des états de compatibilité - Suggestions dynamiques avec badges de compatibilité - Messages d'aide contextuels (compatible/unknown/likelyTooRecent) - Alertes non-bloquantes pour les modèles trop récents - Gestion des échecs de catalogue avec saisie manuelle conservée Backend: - Ports CliVersionReader, ProviderModelCatalogue, CompatibilityMatrixSource - Implémentations: ProcessCliVersionReader, HttpProviderModelCatalogue, EmbeddedCompatibilityMatrix - Enrichissement des DTOs avec compatibility, cli_version, warnings - Tests unitaires complets pour le resolver de catalogue
This commit is contained in:
@ -8,12 +8,16 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type {
|
||||
AgentProfile,
|
||||
GatewayError,
|
||||
ModelCompatibility,
|
||||
ProfileModelCatalog,
|
||||
ProfileModelCatalogEntry,
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { Button, Input, Panel, cn } from "@/shared";
|
||||
|
||||
type ProfileTab = "codex" | "claude" | "openCode";
|
||||
type ModelTab = "codex" | "claude";
|
||||
type CatalogState = Record<ModelTab, ProfileModelCatalog & { unavailable: boolean }>;
|
||||
|
||||
const TABS: Array<{ id: ProfileTab; label: string }> = [
|
||||
{ id: "codex", label: "Codex" },
|
||||
@ -21,9 +25,16 @@ const TABS: Array<{ id: ProfileTab; label: string }> = [
|
||||
{ id: "openCode", label: "OpenCode-local" },
|
||||
];
|
||||
|
||||
const EMPTY_CATALOGUE: Record<"codex" | "claude", ProfileModelCatalogEntry[]> = {
|
||||
codex: [],
|
||||
claude: [],
|
||||
const EMPTY_MODEL_CATALOG: ProfileModelCatalog & { unavailable: boolean } = {
|
||||
models: [],
|
||||
cliVersion: null,
|
||||
warnings: [],
|
||||
unavailable: false,
|
||||
};
|
||||
|
||||
const EMPTY_CATALOGUE: CatalogState = {
|
||||
codex: EMPTY_MODEL_CATALOG,
|
||||
claude: EMPTY_MODEL_CATALOG,
|
||||
};
|
||||
|
||||
function describe(e: unknown): string {
|
||||
@ -66,6 +77,185 @@ function optionLabel(entry: ProfileModelCatalogEntry): string {
|
||||
: `${entry.displayName} (${entry.modelId})`;
|
||||
}
|
||||
|
||||
function compatibilityLabel(compatibility: ModelCompatibility): string {
|
||||
if (compatibility === "compatible") return "Compatible";
|
||||
if (compatibility === "likelyTooRecent") return "Probablement trop récent";
|
||||
return "Inconnu";
|
||||
}
|
||||
|
||||
function engineLabel(tab: ModelTab): string {
|
||||
return tab === "codex" ? "Codex CLI" : "Claude CLI";
|
||||
}
|
||||
|
||||
function attentionBadge(
|
||||
compatibility: ModelCompatibility,
|
||||
cliVersion: string | null,
|
||||
): string | null {
|
||||
if (!cliVersion) return "CLI non détecté";
|
||||
if (compatibility === "compatible") return null;
|
||||
return compatibilityLabel(compatibility);
|
||||
}
|
||||
|
||||
function catalogEntryFor(
|
||||
model: string,
|
||||
models: ProfileModelCatalogEntry[],
|
||||
): ProfileModelCatalogEntry | null {
|
||||
const normalized = model.trim().toLowerCase();
|
||||
if (!normalized) return null;
|
||||
return models.find((entry) => entry.modelId.toLowerCase() === normalized) ?? null;
|
||||
}
|
||||
|
||||
function compatibilityFor(
|
||||
model: string,
|
||||
models: ProfileModelCatalogEntry[],
|
||||
): ModelCompatibility {
|
||||
return catalogEntryFor(model, models)?.compatibility ?? "unknown";
|
||||
}
|
||||
|
||||
function modelHelp(
|
||||
tab: ModelTab,
|
||||
model: string,
|
||||
catalog: ProfileModelCatalog & { unavailable: boolean },
|
||||
): string {
|
||||
const cli = engineLabel(tab);
|
||||
if (catalog.unavailable) {
|
||||
return "Catalogue provider indisponible ; vous pouvez saisir le modèle manuellement.";
|
||||
}
|
||||
if (!catalog.cliVersion) {
|
||||
return `Version du ${cli} non détectée ; IdeA ne peut pas estimer la compatibilité.`;
|
||||
}
|
||||
const compatibility = compatibilityFor(model, catalog.models);
|
||||
if (compatibility === "compatible") {
|
||||
return `Compatible avec ${cli} ${catalog.cliVersion} d'après le catalogue local IdeA.`;
|
||||
}
|
||||
if (compatibility === "likelyTooRecent") {
|
||||
return `Probablement trop récent pour ${cli} ${catalog.cliVersion} ; mettez à jour le CLI si le lancement échoue.`;
|
||||
}
|
||||
return `Compatibilité non connue pour ${cli} ${catalog.cliVersion} ; la saisie reste autorisée.`;
|
||||
}
|
||||
|
||||
function saveWarningText(tab: ModelTab, cliVersion: string | null): string {
|
||||
const cli = engineLabel(tab);
|
||||
const version = cliVersion ? ` ${cliVersion}` : "";
|
||||
return `Ce modèle semble plus récent que votre ${cli}${version}. Le profil peut être enregistré, mais l'agent pourrait échouer au lancement tant que le CLI n'est pas mis à jour.`;
|
||||
}
|
||||
|
||||
function matchingSuggestions(
|
||||
model: string,
|
||||
models: ProfileModelCatalogEntry[],
|
||||
): ProfileModelCatalogEntry[] {
|
||||
const q = model.trim().toLowerCase();
|
||||
const filtered = q
|
||||
? models.filter((entry) =>
|
||||
[entry.modelId, entry.displayName, ...entry.aliases]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(q),
|
||||
)
|
||||
: models;
|
||||
return filtered.slice(0, 5);
|
||||
}
|
||||
|
||||
function ModelField({
|
||||
profileId,
|
||||
profileName,
|
||||
tab,
|
||||
model,
|
||||
catalog,
|
||||
onChange,
|
||||
}: {
|
||||
profileId: string;
|
||||
profileName: string;
|
||||
tab: ModelTab;
|
||||
model: string;
|
||||
catalog: ProfileModelCatalog & { unavailable: boolean };
|
||||
onChange: (model: string) => void;
|
||||
}) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const inputId = `profile-model-${tab}-${profileId}`;
|
||||
const compatibility = compatibilityFor(model, catalog.models);
|
||||
const badge = attentionBadge(compatibility, catalog.cliVersion);
|
||||
const suggestions = matchingSuggestions(model, catalog.models);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="flex items-center gap-2 text-xs font-medium text-muted"
|
||||
>
|
||||
Modèle
|
||||
<span
|
||||
className="text-faint"
|
||||
title="IdeA estime la compatibilité à partir du catalogue maintenu dans l'application et de la version du CLI détectée localement. Le provider peut accepter ou refuser le modèle différemment au moment du lancement."
|
||||
>
|
||||
compatibilité estimée
|
||||
</span>
|
||||
{badge && (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[11px] font-medium",
|
||||
compatibility === "likelyTooRecent"
|
||||
? "bg-warning/15 text-warning"
|
||||
: "bg-raised text-muted",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
id={inputId}
|
||||
aria-label={`modele du profil ${profileName}`}
|
||||
placeholder={
|
||||
catalog.models.length > 0
|
||||
? "Choisir ou saisir un modèle"
|
||||
: "Saisir un modèle"
|
||||
}
|
||||
value={model}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => window.setTimeout(() => setFocused(false), 120)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<small className="text-xs text-faint">{modelHelp(tab, model, catalog)}</small>
|
||||
{focused && suggestions.length > 0 && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={`suggestions de modèles ${profileName}`}
|
||||
className="mt-1 max-h-36 overflow-auto rounded-md border border-border bg-raised p-1"
|
||||
>
|
||||
{suggestions.map((entry) => (
|
||||
<button
|
||||
key={entry.modelId}
|
||||
type="button"
|
||||
role="option"
|
||||
className="flex w-full items-center justify-between gap-3 rounded px-2 py-1.5 text-left text-xs hover:bg-surface"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onChange(entry.modelId);
|
||||
setFocused(false);
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 truncate">{optionLabel(entry)}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium",
|
||||
entry.compatibility === "compatible"
|
||||
? "bg-primary/10 text-primary"
|
||||
: entry.compatibility === "likelyTooRecent"
|
||||
? "bg-warning/15 text-warning"
|
||||
: "bg-surface text-muted",
|
||||
)}
|
||||
>
|
||||
{compatibilityLabel(entry.compatibility)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfilesSettings() {
|
||||
const { profile } = useGateways();
|
||||
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
|
||||
@ -75,6 +265,7 @@ export function ProfilesSettings() {
|
||||
const [drafts, setDrafts] = useState<Record<string, AgentProfile>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [catalogueWarning, setCatalogueWarning] = useState<string | null>(null);
|
||||
const [saveWarnings, setSaveWarnings] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@ -106,12 +297,18 @@ export function ProfilesSettings() {
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setCatalogue({
|
||||
codex: codex.status === "fulfilled" ? codex.value : [],
|
||||
claude: claude.status === "fulfilled" ? claude.value : [],
|
||||
codex:
|
||||
codex.status === "fulfilled"
|
||||
? { ...codex.value, unavailable: false }
|
||||
: { ...EMPTY_MODEL_CATALOG, unavailable: true },
|
||||
claude:
|
||||
claude.status === "fulfilled"
|
||||
? { ...claude.value, unavailable: false }
|
||||
: { ...EMPTY_MODEL_CATALOG, unavailable: true },
|
||||
});
|
||||
if (codex.status === "rejected" || claude.status === "rejected") {
|
||||
setCatalogueWarning(
|
||||
"Catalogue de modeles indisponible: saisie manuelle active.",
|
||||
"Catalogue provider indisponible ; vous pouvez saisir le modèle manuellement.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -146,7 +343,7 @@ export function ProfilesSettings() {
|
||||
try {
|
||||
const models =
|
||||
activeTab === "codex" || activeTab === "claude"
|
||||
? catalogue[activeTab]
|
||||
? catalogue[activeTab].models
|
||||
: [];
|
||||
const recommended = models.find((m) => m.recommended)?.modelId;
|
||||
await profile.cloneProfileFromSeed({
|
||||
@ -167,9 +364,24 @@ export function ProfilesSettings() {
|
||||
if (!draft) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setSaveWarnings((prev) => {
|
||||
const { [id]: _ignored, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
try {
|
||||
const tab = tabFor(draft);
|
||||
const warning =
|
||||
tab === "codex" || tab === "claude"
|
||||
? compatibilityFor(modelOf(draft), catalogue[tab].models) ===
|
||||
"likelyTooRecent"
|
||||
? saveWarningText(tab, catalogue[tab].cliVersion)
|
||||
: null
|
||||
: null;
|
||||
await profile.saveProfile(draft);
|
||||
await refresh();
|
||||
if (warning) {
|
||||
setSaveWarnings((prev) => ({ ...prev, [id]: warning }));
|
||||
}
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
@ -212,7 +424,9 @@ export function ProfilesSettings() {
|
||||
}
|
||||
|
||||
const modelOptions =
|
||||
activeTab === "codex" || activeTab === "claude" ? catalogue[activeTab] : [];
|
||||
activeTab === "codex" || activeTab === "claude"
|
||||
? catalogue[activeTab].models
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Panel
|
||||
@ -258,13 +472,12 @@ export function ProfilesSettings() {
|
||||
<p className="text-xs text-muted">{catalogueWarning}</p>
|
||||
)}
|
||||
|
||||
<datalist id={`profile-models-${activeTab}`}>
|
||||
{modelOptions.map((entry) => (
|
||||
<option key={entry.modelId} value={entry.modelId}>
|
||||
{optionLabel(entry)}
|
||||
</option>
|
||||
{(activeTab === "codex" || activeTab === "claude") &&
|
||||
catalogue[activeTab].warnings.map((warning) => (
|
||||
<p key={warning} className="text-xs text-warning">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
{visibleProfiles.length === 0 ? (
|
||||
<p className="text-sm text-muted">
|
||||
@ -296,25 +509,42 @@ export function ProfilesSettings() {
|
||||
/>
|
||||
</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),
|
||||
)
|
||||
{activeTab === "codex" || activeTab === "claude" ? (
|
||||
<ModelField
|
||||
profileId={saved.id}
|
||||
profileName={saved.name}
|
||||
tab={activeTab}
|
||||
model={model}
|
||||
catalog={catalogue[activeTab]}
|
||||
onChange={(value) =>
|
||||
updateDraft(saved.id, (p) => withModel(p, value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted">Modèle</span>
|
||||
<Input
|
||||
aria-label={`modele du profil ${saved.name}`}
|
||||
placeholder={
|
||||
modelOptions.length > 0
|
||||
? "Choisir ou saisir un modèle"
|
||||
: "Saisir un modèle"
|
||||
}
|
||||
value={model}
|
||||
onChange={(e) =>
|
||||
updateDraft(saved.id, (p) =>
|
||||
withModel(p, e.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{saveWarnings[saved.id] && (
|
||||
<p className="mt-2 rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
{saveWarnings[saved.id]}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<code className="min-w-0 truncate text-xs text-muted">
|
||||
|
||||
Reference in New Issue
Block a user