/** * Settings -> AI Profiles. This is the durable CRUD surface for named runtime * profiles; first-run stays a small default-profile bootstrap. */ 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"; import { ModelServersPanel, useModelServers, } from "@/features/model-servers"; import { OpenCodeModeFields, useOpenCodeProviderCatalog, } from "./OpenCodeModeFields"; import { validateProfile } from "./profile"; type ProfileTab = "codex" | "claude" | "openCode"; type ModelTab = "codex" | "claude"; type CatalogState = Record; const TABS: Array<{ id: ProfileTab; label: string }> = [ { id: "codex", label: "Codex" }, { id: "claude", label: "Claude" }, { id: "openCode", label: "OpenCode-local" }, ]; 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 { 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 || profile.opencodeProvider) ) { 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})`; } 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 (
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)} /> {modelHelp(tab, model, catalog)} {focused && suggestions.length > 0 && (
{suggestions.map((entry) => ( ))}
)}
); } export function ProfilesSettings() { const { profile } = useGateways(); const modelServers = useModelServers(); const providerCatalog = useOpenCodeProviderCatalog(); const [profiles, setProfiles] = useState([]); const [references, setReferences] = useState([]); const [catalogue, setCatalogue] = useState(EMPTY_CATALOGUE); const [activeTab, setActiveTab] = useState("codex"); const [drafts, setDrafts] = useState>({}); const [error, setError] = useState(null); const [catalogueWarning, setCatalogueWarning] = useState(null); const [saveWarnings, setSaveWarnings] = useState>({}); const [busy, setBusy] = useState(false); const refresh = useCallback(async () => { setError(null); try { 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) { setError(describe(e)); } }, [profile]); useEffect(() => { void refresh(); }, [refresh]); useEffect(() => { let cancelled = false; 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, 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 provider indisponible ; vous pouvez saisir le modèle manuellement.", ); } } 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) }; }); } async function createFromSeed() { if (!seed) return; setBusy(true); setError(null); try { if (activeTab === "openCode") { const created = await profile.cloneOpenCodeProfileFromSeed({ name: `${seed.name} copy`, opencode: seed.opencode, }); await profile.saveProfile(created); await refresh(); return; } const models = activeTab === "codex" || activeTab === "claude" ? catalogue[activeTab].models : []; 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); 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 { setBusy(false); } } async function duplicate(source: AgentProfile) { setBusy(true); setError(null); try { if (source.structuredAdapter === "openCode") { const created = await profile.cloneOpenCodeProfileFromSeed({ name: `${source.name} copy`, opencode: source.opencode, }); await profile.saveProfile(created); await refresh(); return; } 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); } } return ( void createFromSeed()} disabled={!seed || busy}> Creer un profil } >
{TABS.map((tab) => ( ))}
{error && (

{error}

)} {catalogueWarning && (

{catalogueWarning}

)} {(activeTab === "codex" || activeTab === "claude") && catalogue[activeTab].warnings.map((warning) => (

{warning}

))} {activeTab === "openCode" && } {visibleProfiles.length === 0 ? (

Aucun profil {TABS.find((tab) => tab.id === activeTab)?.label} configure.

) : (
    {visibleProfiles.map((saved) => { const draft = drafts[saved.id] ?? saved; const model = modelOf(draft); const dirty = JSON.stringify(draft) !== JSON.stringify(saved); const errors = validateProfile(draft); return (
  • {activeTab === "codex" || activeTab === "claude" ? ( updateDraft(saved.id, (p) => withModel(p, value)) } /> ) : null}
    {draft.structuredAdapter === "openCode" && ( updateDraft(saved.id, () => next) } /> )} {saveWarnings[saved.id] && (

    {saveWarnings[saved.id]}

    )}
    {draft.command} {model ? ` · ${model}` : ""}
  • ); })}
)}
); }