Files
IdeaSDK/frontend/src/features/first-run/ProfilesSettings.tsx
Blomios 045e0984cc chantier: durcissement reprise headless inter-projets + persistance profils Opencode
- Implémentation reprise de session Opencode headless (conversational_recovery)
- Persistance des profils IA Opencode dans .ideai/memory avec JSON Schema
- Gestion des permissions MCP pour agents externes
- Tests QA verts : structured_launch_d3, conversation_log, tickets_missing_carnet, agents, ProfilesSettings
2026-07-28 12:23:34 +02:00

619 lines
20 KiB
TypeScript

/**
* 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<ModelTab, ProfileModelCatalog & { unavailable: boolean }>;
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 (
<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 modelServers = useModelServers();
const providerCatalog = useOpenCodeProviderCatalog();
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 [catalogueWarning, setCatalogueWarning] = useState<string | null>(null);
const [saveWarnings, setSaveWarnings] = useState<Record<string, string>>({});
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 (
<Panel
aria-label="ai profiles settings"
title="Profils IA"
actions={
<Button size="sm" onClick={() => void createFromSeed()} disabled={!seed || busy}>
Creer un profil
</Button>
}
>
<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 && (
<p role="alert" className="text-sm text-danger">
{error}
</p>
)}
{catalogueWarning && (
<p className="text-xs text-muted">{catalogueWarning}</p>
)}
{(activeTab === "codex" || activeTab === "claude") &&
catalogue[activeTab].warnings.map((warning) => (
<p key={warning} className="text-xs text-warning">
{warning}
</p>
))}
{activeTab === "openCode" && <ModelServersPanel vm={modelServers} />}
{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 gap-3">
{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 (
<li
key={saved.id}
className="rounded-md border border-border bg-surface p-3"
>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<label className="flex min-w-0 flex-col gap-1">
<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>
{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))
}
/>
) : null}
</div>
{draft.structuredAdapter === "openCode" && (
<OpenCodeModeFields
profile={draft}
errors={errors}
servers={modelServers.servers}
providerCatalog={providerCatalog}
onChange={(next) =>
updateDraft(saved.id, () => next)
}
/>
)}
{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">
{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>
)}
</div>
</Panel>
);
}