- 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
619 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|