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:
@ -42,7 +42,7 @@ import type {
|
||||
ProjectWorkState,
|
||||
ProjectSystemPermissions,
|
||||
ProfileAvailability,
|
||||
ProfileModelCatalogEntry,
|
||||
ProfileModelCatalog,
|
||||
ResolvedAgentSystemPermissions,
|
||||
SystemPermissionSet,
|
||||
Skill,
|
||||
@ -74,6 +74,7 @@ import type {
|
||||
} from "@/ports";
|
||||
import { normalizeProjectWorkState } from "../workStateNormalization";
|
||||
import { normalizeTurnPage } from "../conversationNormalization";
|
||||
import { normalizeProfileModelCatalog } from "../profileCatalog";
|
||||
import type { HttpInvoker } from "./httpInvoker";
|
||||
|
||||
export class HttpProjectGateway implements ProjectGateway {
|
||||
@ -183,11 +184,11 @@ export class HttpProfileGateway implements ProfileGateway {
|
||||
request: { seedProfileId: input.seedProfileId, name: input.name, model: input.model },
|
||||
});
|
||||
}
|
||||
listClaudeModels(): Promise<ProfileModelCatalogEntry[]> {
|
||||
return this.http.invoke<ProfileModelCatalogEntry[]>("list_claude_models");
|
||||
async listClaudeModels(): Promise<ProfileModelCatalog> {
|
||||
return normalizeProfileModelCatalog(await this.http.invoke<unknown>("list_claude_models"));
|
||||
}
|
||||
listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
|
||||
return this.http.invoke<ProfileModelCatalogEntry[]>("list_codex_models");
|
||||
async listCodexModels(): Promise<ProfileModelCatalog> {
|
||||
return normalizeProfileModelCatalog(await this.http.invoke<unknown>("list_codex_models"));
|
||||
}
|
||||
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
|
||||
return this.http.invoke<AgentProfile[]>("configure_profiles", { request: { profiles } });
|
||||
|
||||
@ -35,6 +35,7 @@ import type {
|
||||
McpToolCatalogue,
|
||||
McpToolPolicy,
|
||||
OpenCodeProviderCatalogEntry,
|
||||
ProfileModelCatalog,
|
||||
ProfileModelCatalogEntry,
|
||||
EffectivePermissions,
|
||||
PairedDevice,
|
||||
@ -1302,6 +1303,8 @@ const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [
|
||||
displayName: "Claude Sonnet 5",
|
||||
aliases: ["sonnet"],
|
||||
recommended: true,
|
||||
compatibility: "compatible",
|
||||
source: "catalogue",
|
||||
},
|
||||
{
|
||||
adapter: "claude",
|
||||
@ -1309,6 +1312,8 @@ const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [
|
||||
displayName: "Claude Opus 4.8",
|
||||
aliases: ["opus"],
|
||||
recommended: false,
|
||||
compatibility: "unknown",
|
||||
source: "catalogue",
|
||||
},
|
||||
{
|
||||
adapter: "claude",
|
||||
@ -1316,6 +1321,8 @@ const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [
|
||||
displayName: "Claude Haiku 4.5",
|
||||
aliases: ["haiku"],
|
||||
recommended: false,
|
||||
compatibility: "likelyTooRecent",
|
||||
source: "provider",
|
||||
},
|
||||
];
|
||||
|
||||
@ -1326,6 +1333,8 @@ const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [
|
||||
displayName: "GPT-5 Codex",
|
||||
aliases: ["codex"],
|
||||
recommended: true,
|
||||
compatibility: "compatible",
|
||||
source: "catalogue",
|
||||
},
|
||||
{
|
||||
adapter: "codex",
|
||||
@ -1333,6 +1342,8 @@ const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [
|
||||
displayName: "GPT-5",
|
||||
aliases: ["general"],
|
||||
recommended: false,
|
||||
compatibility: "unknown",
|
||||
source: "catalogue",
|
||||
},
|
||||
{
|
||||
adapter: "codex",
|
||||
@ -1340,6 +1351,8 @@ const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [
|
||||
displayName: "GPT-5 mini",
|
||||
aliases: ["mini", "fast"],
|
||||
recommended: false,
|
||||
compatibility: "likelyTooRecent",
|
||||
source: "provider",
|
||||
},
|
||||
];
|
||||
|
||||
@ -1420,12 +1433,20 @@ export class MockProfileGateway implements ProfileGateway {
|
||||
return structuredClone(cloned);
|
||||
}
|
||||
|
||||
async listClaudeModels(): Promise<ProfileModelCatalogEntry[]> {
|
||||
return structuredClone(MOCK_CLAUDE_MODELS);
|
||||
async listClaudeModels(): Promise<ProfileModelCatalog> {
|
||||
return {
|
||||
models: structuredClone(MOCK_CLAUDE_MODELS),
|
||||
cliVersion: "2.1.220",
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
async listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
|
||||
return structuredClone(MOCK_CODEX_MODELS);
|
||||
async listCodexModels(): Promise<ProfileModelCatalog> {
|
||||
return {
|
||||
models: structuredClone(MOCK_CODEX_MODELS),
|
||||
cliVersion: "0.145.0",
|
||||
warnings: ["Catalogue provider partiellement estime depuis les donnees locales."],
|
||||
};
|
||||
}
|
||||
|
||||
async cloneOpenCodeProfileFromSeed(
|
||||
|
||||
@ -12,7 +12,7 @@ import type {
|
||||
AgentProfile,
|
||||
FirstRunState,
|
||||
OpenCodeProviderCatalogEntry,
|
||||
ProfileModelCatalogEntry,
|
||||
ProfileModelCatalog,
|
||||
ProfileAvailability,
|
||||
} from "@/domain";
|
||||
import type {
|
||||
@ -21,6 +21,7 @@ import type {
|
||||
ProfileGateway,
|
||||
SaveOpenCodeProviderProfileInput,
|
||||
} from "@/ports";
|
||||
import { normalizeProfileModelCatalog } from "./profileCatalog";
|
||||
|
||||
export class TauriProfileGateway implements ProfileGateway {
|
||||
firstRunState(): Promise<FirstRunState> {
|
||||
@ -59,12 +60,12 @@ export class TauriProfileGateway implements ProfileGateway {
|
||||
});
|
||||
}
|
||||
|
||||
listClaudeModels(): Promise<ProfileModelCatalogEntry[]> {
|
||||
return invoke<ProfileModelCatalogEntry[]>("list_claude_models");
|
||||
async listClaudeModels(): Promise<ProfileModelCatalog> {
|
||||
return normalizeProfileModelCatalog(await invoke<unknown>("list_claude_models"));
|
||||
}
|
||||
|
||||
listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
|
||||
return invoke<ProfileModelCatalogEntry[]>("list_codex_models");
|
||||
async listCodexModels(): Promise<ProfileModelCatalog> {
|
||||
return normalizeProfileModelCatalog(await invoke<unknown>("list_codex_models"));
|
||||
}
|
||||
|
||||
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
|
||||
|
||||
56
frontend/src/adapters/profileCatalog.test.ts
Normal file
56
frontend/src/adapters/profileCatalog.test.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { normalizeProfileModelCatalog } from "./profileCatalog";
|
||||
|
||||
describe("normalizeProfileModelCatalog", () => {
|
||||
it("wraps the legacy bare array response with unknown compatibility", () => {
|
||||
expect(
|
||||
normalizeProfileModelCatalog([
|
||||
{
|
||||
adapter: "codex",
|
||||
modelId: "gpt-5-codex",
|
||||
displayName: "GPT-5 Codex",
|
||||
aliases: ["codex"],
|
||||
recommended: true,
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
models: [
|
||||
{
|
||||
adapter: "codex",
|
||||
modelId: "gpt-5-codex",
|
||||
displayName: "GPT-5 Codex",
|
||||
aliases: ["codex"],
|
||||
recommended: true,
|
||||
compatibility: "unknown",
|
||||
source: "catalogue",
|
||||
},
|
||||
],
|
||||
cliVersion: null,
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults missing enriched fields without dropping warnings", () => {
|
||||
expect(
|
||||
normalizeProfileModelCatalog({
|
||||
models: [{ adapter: "claude", modelId: "claude-sonnet-5" }],
|
||||
warnings: ["version inconnue"],
|
||||
}),
|
||||
).toEqual({
|
||||
models: [
|
||||
{
|
||||
adapter: "claude",
|
||||
modelId: "claude-sonnet-5",
|
||||
displayName: "claude-sonnet-5",
|
||||
aliases: [],
|
||||
recommended: false,
|
||||
compatibility: "unknown",
|
||||
source: "catalogue",
|
||||
},
|
||||
],
|
||||
cliVersion: null,
|
||||
warnings: ["version inconnue"],
|
||||
});
|
||||
});
|
||||
});
|
||||
63
frontend/src/adapters/profileCatalog.ts
Normal file
63
frontend/src/adapters/profileCatalog.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import type {
|
||||
ModelCatalogSource,
|
||||
ModelCompatibility,
|
||||
ProfileModelCatalog,
|
||||
ProfileModelCatalogEntry,
|
||||
} from "@/domain";
|
||||
|
||||
type PartialCatalogEntry = Partial<ProfileModelCatalogEntry> & {
|
||||
adapter?: "claude" | "codex";
|
||||
modelId?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
function compatibilityOf(value: unknown): ModelCompatibility {
|
||||
return value === "compatible" ||
|
||||
value === "unknown" ||
|
||||
value === "likelyTooRecent"
|
||||
? value
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
function sourceOf(value: unknown): ModelCatalogSource {
|
||||
return value === "provider" ? "provider" : "catalogue";
|
||||
}
|
||||
|
||||
function normalizeEntry(raw: PartialCatalogEntry): ProfileModelCatalogEntry {
|
||||
const modelId = raw.modelId ?? "";
|
||||
return {
|
||||
adapter: raw.adapter ?? "codex",
|
||||
modelId,
|
||||
displayName: raw.displayName ?? modelId,
|
||||
aliases: Array.isArray(raw.aliases) ? raw.aliases : [],
|
||||
recommended: Boolean(raw.recommended),
|
||||
compatibility: compatibilityOf(raw.compatibility),
|
||||
source: sourceOf(raw.source),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeProfileModelCatalog(raw: unknown): ProfileModelCatalog {
|
||||
if (Array.isArray(raw)) {
|
||||
return {
|
||||
models: raw.map((entry) => normalizeEntry(entry as PartialCatalogEntry)),
|
||||
cliVersion: null,
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const catalog =
|
||||
raw && typeof raw === "object"
|
||||
? (raw as Partial<ProfileModelCatalog>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
models: Array.isArray(catalog.models)
|
||||
? catalog.models.map((entry) => normalizeEntry(entry as PartialCatalogEntry))
|
||||
: [],
|
||||
cliVersion:
|
||||
typeof catalog.cliVersion === "string" ? catalog.cliVersion : null,
|
||||
warnings: Array.isArray(catalog.warnings)
|
||||
? catalog.warnings.map(String)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
@ -1096,6 +1096,15 @@ export interface OpenCodeProviderCatalogEntry {
|
||||
models: string[];
|
||||
}
|
||||
|
||||
/** Estimated compatibility for a Codex/Claude model against the detected local CLI. */
|
||||
export type ModelCompatibility =
|
||||
| "compatible"
|
||||
| "unknown"
|
||||
| "likelyTooRecent";
|
||||
|
||||
/** Origin of a model catalogue entry. */
|
||||
export type ModelCatalogSource = "catalogue" | "provider";
|
||||
|
||||
/** One searchable model from the Codex/Claude structured-profile catalogues. */
|
||||
export interface ProfileModelCatalogEntry {
|
||||
/** Structured adapter this model belongs to. */
|
||||
@ -1108,6 +1117,19 @@ export interface ProfileModelCatalogEntry {
|
||||
aliases: string[];
|
||||
/** Whether this entry is the conservative default suggestion. */
|
||||
recommended: boolean;
|
||||
/** Best-effort compatibility estimate for the locally detected CLI version. */
|
||||
compatibility: ModelCompatibility;
|
||||
/** Whether the entry comes from IdeA's catalogue or a provider-derived source. */
|
||||
source: ModelCatalogSource;
|
||||
}
|
||||
|
||||
/** Enriched Codex/Claude model catalogue. Manual model entry remains supported. */
|
||||
export interface ProfileModelCatalog {
|
||||
models: ProfileModelCatalogEntry[];
|
||||
/** Detected local CLI version, or null when unavailable. */
|
||||
cliVersion: string | null;
|
||||
/** Non-fatal catalogue/version diagnostics. */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -4,7 +4,7 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { MockProfileGateway } from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import type { ProfileModelCatalogEntry } from "@/domain";
|
||||
import type { ProfileModelCatalog } from "@/domain";
|
||||
import { ProfilesSettings } from "./ProfilesSettings";
|
||||
|
||||
function renderSettings(profile: MockProfileGateway = new MockProfileGateway()) {
|
||||
@ -94,14 +94,14 @@ describe("ProfilesSettings", () => {
|
||||
|
||||
it("keeps manual model entry available when the catalogue fails", async () => {
|
||||
class CatalogueDownProfileGateway extends MockProfileGateway {
|
||||
listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
|
||||
listCodexModels(): Promise<ProfileModelCatalog> {
|
||||
return Promise.reject(new Error("catalogue down"));
|
||||
}
|
||||
}
|
||||
|
||||
renderSettings(new CatalogueDownProfileGateway());
|
||||
await waitReady();
|
||||
expect(await screen.findByText(/saisie manuelle active/)).toBeTruthy();
|
||||
expect(await screen.findByText(/Catalogue provider indisponible/)).toBeTruthy();
|
||||
|
||||
await createProfile();
|
||||
const model = within(screen.getAllByRole("listitem")[0]).getByLabelText(
|
||||
@ -109,5 +109,54 @@ describe("ProfilesSettings", () => {
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(model, { target: { value: "future-codex-model" } });
|
||||
expect(model.value).toBe("future-codex-model");
|
||||
expect(screen.getAllByText(/Catalogue provider indisponible/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows compatibility states in suggestions and contextual help", async () => {
|
||||
renderSettings();
|
||||
await waitReady();
|
||||
await createProfile();
|
||||
|
||||
const row = screen.getAllByRole("listitem")[0];
|
||||
const model = within(row).getByLabelText(/modele du profil/) as HTMLInputElement;
|
||||
fireEvent.focus(model);
|
||||
|
||||
expect(await within(row).findByText("Compatible")).toBeTruthy();
|
||||
expect(
|
||||
within(row).getByText(
|
||||
/Compatible avec Codex CLI 0\.145\.0 d'après le catalogue local IdeA\./,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.change(model, { target: { value: "" } });
|
||||
expect(within(row).getAllByText("Inconnu").length).toBeGreaterThan(0);
|
||||
expect(within(row).getByText("Probablement trop récent")).toBeTruthy();
|
||||
|
||||
fireEvent.change(model, { target: { value: "future-codex-model" } });
|
||||
expect(within(row).getByText("Inconnu")).toBeTruthy();
|
||||
expect(
|
||||
within(row).getByText(
|
||||
/Compatibilité non connue pour Codex CLI 0\.145\.0 ; la saisie reste autorisée\./,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("saves likely-too-recent models and shows a non-blocking warning", async () => {
|
||||
const { profile } = renderSettings();
|
||||
await waitReady();
|
||||
await createProfile();
|
||||
|
||||
const row = screen.getAllByRole("listitem")[0];
|
||||
const model = within(row).getByLabelText(/modele du profil/) as HTMLInputElement;
|
||||
fireEvent.change(model, { target: { value: "gpt-5-mini" } });
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Enregistrer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
expect(saved.some((p) => p.model === "gpt-5-mini")).toBe(true);
|
||||
});
|
||||
expect(
|
||||
await screen.findByText(/Ce modèle semble plus récent que votre Codex CLI 0\.145\.0/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -37,7 +37,7 @@ import type {
|
||||
McpToolPolicy,
|
||||
OpenCodeConfig,
|
||||
OpenCodeProviderCatalogEntry,
|
||||
ProfileModelCatalogEntry,
|
||||
ProfileModelCatalog,
|
||||
EffectivePermissions,
|
||||
PairedDevice,
|
||||
PairingCode,
|
||||
@ -670,10 +670,10 @@ export interface ProfileGateway {
|
||||
* 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[]>;
|
||||
/** Enriched Claude Code model catalogue. Manual model entry remains supported. */
|
||||
listClaudeModels(): Promise<ProfileModelCatalog>;
|
||||
/** Enriched Codex CLI model catalogue. Manual model entry remains supported. */
|
||||
listCodexModels(): Promise<ProfileModelCatalog>;
|
||||
/** Persists the batch of chosen profiles, closing the first run. */
|
||||
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]>;
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user