fix: correction de régression UI wizard first-run
This commit is contained in:
@ -15,85 +15,34 @@
|
|||||||
* `./profile`.
|
* `./profile`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentProfile,
|
AgentProfile,
|
||||||
GatewayError,
|
|
||||||
HttpChatConfig,
|
HttpChatConfig,
|
||||||
LocalModelServerConfig,
|
LocalModelServerConfig,
|
||||||
OpenCodeConfig,
|
|
||||||
OpenCodeProviderCatalogEntry,
|
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
|
||||||
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
|
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
|
||||||
import {
|
import {
|
||||||
ModelServersPanel,
|
ModelServersPanel,
|
||||||
ModelServerSelect,
|
|
||||||
useModelServers,
|
useModelServers,
|
||||||
} from "@/features/model-servers";
|
} from "@/features/model-servers";
|
||||||
import { useFirstRun, type WizardEntry } from "./useFirstRun";
|
import { useFirstRun, type WizardEntry } from "./useFirstRun";
|
||||||
import {
|
import {
|
||||||
defaultHttpChatConfig,
|
defaultHttpChatConfig,
|
||||||
defaultOpenCodeConfig,
|
|
||||||
parseArgs,
|
parseArgs,
|
||||||
validateProfile,
|
validateProfile,
|
||||||
type ProfileErrors,
|
type ProfileErrors,
|
||||||
} from "./profile";
|
} from "./profile";
|
||||||
|
import {
|
||||||
|
OpenCodeModeFields,
|
||||||
|
useOpenCodeProviderCatalog,
|
||||||
|
type OpenCodeProviderCatalog,
|
||||||
|
} from "./OpenCodeModeFields";
|
||||||
|
|
||||||
/** A small caption above a control. */
|
/** A small caption above a control. */
|
||||||
function Caption({ children }: { children: React.ReactNode }) {
|
function Caption({ children }: { children: React.ReactNode }) {
|
||||||
return <span className="text-xs font-medium text-muted">{children}</span>;
|
return <span className="text-xs font-medium text-muted">{children}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeError(e: unknown): string {
|
|
||||||
if (e && typeof e === "object" && "message" in e) {
|
|
||||||
return String((e as GatewayError).message);
|
|
||||||
}
|
|
||||||
return String(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** View-model for the OpenCode cloud-provider catalogue (ticket #92). */
|
|
||||||
interface OpenCodeProviderCatalog {
|
|
||||||
providers: OpenCodeProviderCatalogEntry[] | null;
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
reload: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the static OpenCode cloud-provider catalogue once for the whole
|
|
||||||
* wizard (every Cloud row shares it), so the provider/model pickers can be
|
|
||||||
* populated. Exposes a `reload` for the blocking "Réessayer" state.
|
|
||||||
*/
|
|
||||||
function useOpenCodeProviderCatalog(): OpenCodeProviderCatalog {
|
|
||||||
const { profile } = useGateways();
|
|
||||||
const [providers, setProviders] = useState<OpenCodeProviderCatalogEntry[] | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
setProviders(await profile.listOpenCodeProviders());
|
|
||||||
} catch (e) {
|
|
||||||
setProviders(null);
|
|
||||||
setError(describeError(e));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [profile]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
return { providers, loading, error, reload: () => void load() };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the wizard when it is the first run. Calls `onDone` once the user
|
* Renders the wizard when it is the first run. Calls `onDone` once the user
|
||||||
* finishes (so the host can drop the wizard and show the normal UI). Returns
|
* finishes (so the host can drop the wizard and show the normal UI). Returns
|
||||||
@ -339,467 +288,6 @@ function ProfileRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Segmented control (F — ticket #92) choosing whether an OpenCode profile runs
|
|
||||||
* against the local `llama.cpp` endpoint or a cloud provider from the OpenCode
|
|
||||||
* registry, and renders the matching sub-form. The two sub-forms never overlap;
|
|
||||||
* switching segments keeps the inactive one's draft in memory (component-local
|
|
||||||
* state) without touching `profile` until it is actually submitted.
|
|
||||||
*/
|
|
||||||
function OpenCodeModeFields({
|
|
||||||
profile,
|
|
||||||
errors,
|
|
||||||
servers,
|
|
||||||
providerCatalog,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
profile: AgentProfile;
|
|
||||||
errors: ProfileErrors;
|
|
||||||
servers: LocalModelServerConfig[];
|
|
||||||
providerCatalog: OpenCodeProviderCatalog;
|
|
||||||
onChange: (p: AgentProfile) => void;
|
|
||||||
}) {
|
|
||||||
const [mode, setMode] = useState<"local" | "cloud">(
|
|
||||||
profile.opencodeProvider ? "cloud" : "local",
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-1 flex flex-col gap-2">
|
|
||||||
<div
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label="mode du profil OpenCode"
|
|
||||||
className="flex w-fit gap-1 rounded-md border border-border bg-raised p-0.5"
|
|
||||||
>
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
{ id: "local", label: "Local (llama.cpp)" },
|
|
||||||
{ id: "cloud", label: "Provider cloud" },
|
|
||||||
] as const
|
|
||||||
).map((seg) => {
|
|
||||||
const active = mode === seg.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={seg.id}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={active}
|
|
||||||
onClick={() => setMode(seg.id)}
|
|
||||||
className={cn(
|
|
||||||
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
|
|
||||||
active
|
|
||||||
? "bg-primary text-on-primary"
|
|
||||||
: "text-muted hover:text-content",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{seg.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mode === "local" && (
|
|
||||||
<OpenCodeFields
|
|
||||||
profile={profile}
|
|
||||||
errors={errors}
|
|
||||||
servers={servers}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mode === "cloud" && (
|
|
||||||
<OpenCodeProviderFields
|
|
||||||
profile={profile}
|
|
||||||
catalog={providerCatalog}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Field-keyed validation errors for the Cloud sub-form, surfaced on submit. */
|
|
||||||
interface CloudFieldErrors {
|
|
||||||
providerId?: string;
|
|
||||||
model?: string;
|
|
||||||
apiKey?: string;
|
|
||||||
npm?: string;
|
|
||||||
baseUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sentinel `<option>` value picking the custom-provider sub-form. */
|
|
||||||
const CUSTOM_PROVIDER_VALUE = "__custom__";
|
|
||||||
|
|
||||||
/** Default AI SDK package pre-filled for a fresh custom-provider draft. */
|
|
||||||
const DEFAULT_CUSTOM_NPM = "@ai-sdk/openai-compatible";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The OpenCode **cloud** provider config section (ticket #92): provider ➜
|
|
||||||
* model (cascading selects fed by the dynamic catalogue, now up to ~170
|
|
||||||
* entries) ➜ API key, or — via the "Autre / personnalisé" catalogue entry — a
|
|
||||||
* free-form OpenAI-compatible endpoint (provider id, npm SDK package, base
|
|
||||||
* URL, model id, optional display name). The key is never pre-filled (create
|
|
||||||
* or edit) since the backend never returns it — it reseals whatever literal
|
|
||||||
* it receives on every save, so editing an existing cloud profile requires
|
|
||||||
* re-entering it every time (§3 of the spec).
|
|
||||||
*/
|
|
||||||
function OpenCodeProviderFields({
|
|
||||||
profile,
|
|
||||||
catalog,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
profile: AgentProfile;
|
|
||||||
catalog: OpenCodeProviderCatalog;
|
|
||||||
onChange: (p: AgentProfile) => void;
|
|
||||||
}) {
|
|
||||||
const { profile: profileGateway } = useGateways();
|
|
||||||
const existing = profile.opencodeProvider;
|
|
||||||
const [mode, setMode] = useState<"catalog" | "custom">(
|
|
||||||
existing?.custom ? "custom" : "catalog",
|
|
||||||
);
|
|
||||||
const [providerId, setProviderId] = useState(existing?.providerId ?? "");
|
|
||||||
const [model, setModel] = useState(existing?.model ?? "");
|
|
||||||
const [providerFilter, setProviderFilter] = useState("");
|
|
||||||
const [customNpm, setCustomNpm] = useState(existing?.custom?.npm ?? DEFAULT_CUSTOM_NPM);
|
|
||||||
const [customBaseUrl, setCustomBaseUrl] = useState(existing?.custom?.baseUrl ?? "");
|
|
||||||
const [customDisplayName, setCustomDisplayName] = useState(
|
|
||||||
existing?.custom?.displayName ?? "",
|
|
||||||
);
|
|
||||||
const [apiKey, setApiKey] = useState("");
|
|
||||||
const [showKey, setShowKey] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<CloudFieldErrors>({});
|
|
||||||
|
|
||||||
const isEditing = existing !== undefined;
|
|
||||||
const models =
|
|
||||||
catalog.providers?.find((p) => p.providerId === providerId)?.models ?? [];
|
|
||||||
const catalogReady = catalog.providers !== null && !catalog.loading;
|
|
||||||
const filteredProviders = (catalog.providers ?? []).filter((p) => {
|
|
||||||
// Always keep the current selection visible even if it no longer matches
|
|
||||||
// the filter, so the <select> doesn't silently lose its value.
|
|
||||||
if (p.providerId === providerId) return true;
|
|
||||||
const q = providerFilter.trim().toLowerCase();
|
|
||||||
if (q.length === 0) return true;
|
|
||||||
return (
|
|
||||||
p.displayName.toLowerCase().includes(q) ||
|
|
||||||
p.providerId.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const saveDisabled =
|
|
||||||
saving ||
|
|
||||||
apiKey.length === 0 ||
|
|
||||||
(mode === "catalog" && (!catalogReady || Boolean(catalog.error)));
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
const errors: CloudFieldErrors = {};
|
|
||||||
if (providerId.trim().length === 0) {
|
|
||||||
errors.providerId = "Le provider est obligatoire.";
|
|
||||||
}
|
|
||||||
if (model.trim().length === 0) errors.model = "Le modèle est obligatoire.";
|
|
||||||
if (mode === "custom") {
|
|
||||||
if (customNpm.trim().length === 0) errors.npm = "Le paquet npm est obligatoire.";
|
|
||||||
if (customBaseUrl.trim().length === 0) {
|
|
||||||
errors.baseUrl = "L'URL de base est obligatoire.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (apiKey.length === 0) errors.apiKey = "La clé API est obligatoire.";
|
|
||||||
setFieldErrors(errors);
|
|
||||||
if (Object.keys(errors).length > 0) return;
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
setSaveError(null);
|
|
||||||
try {
|
|
||||||
const saved = await profileGateway.saveOpenCodeProviderProfile({
|
|
||||||
profile,
|
|
||||||
providerId: providerId.trim(),
|
|
||||||
model: model.trim(),
|
|
||||||
apiKey,
|
|
||||||
...(mode === "custom"
|
|
||||||
? {
|
|
||||||
custom: {
|
|
||||||
npm: customNpm.trim(),
|
|
||||||
baseUrl: customBaseUrl.trim(),
|
|
||||||
displayName:
|
|
||||||
customDisplayName.trim().length > 0
|
|
||||||
? customDisplayName.trim()
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
onChange(saved);
|
|
||||||
setApiKey("");
|
|
||||||
} catch (e) {
|
|
||||||
setSaveError(describeError(e));
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
|
|
||||||
<legend className="px-1 text-xs font-medium text-muted">
|
|
||||||
Provider cloud (OpenCode)
|
|
||||||
</legend>
|
|
||||||
|
|
||||||
{saveError && (
|
|
||||||
<p role="alert" className="text-sm text-danger">
|
|
||||||
{saveError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{catalog.loading && (
|
|
||||||
<p className="text-xs text-faint">Chargement des providers…</p>
|
|
||||||
)}
|
|
||||||
{catalog.error && (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<p role="alert" className="text-sm text-danger">
|
|
||||||
Impossible de charger la liste des providers cloud.
|
|
||||||
</p>
|
|
||||||
<Button size="sm" onClick={() => catalog.reload()} className="w-fit">
|
|
||||||
Réessayer
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mode === "catalog" && (
|
|
||||||
<>
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<Caption>Provider</Caption>
|
|
||||||
{catalogReady && (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
aria-label={`${profile.name} provider search`}
|
|
||||||
placeholder="Rechercher un provider…"
|
|
||||||
value={providerFilter}
|
|
||||||
onChange={(e) => setProviderFilter(e.target.value)}
|
|
||||||
className="h-8 w-full rounded-md border border-border bg-raised px-3 text-xs text-content outline-none"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<select
|
|
||||||
aria-label={`${profile.name} provider`}
|
|
||||||
value={providerId}
|
|
||||||
disabled={!catalogReady}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
if (v === CUSTOM_PROVIDER_VALUE) {
|
|
||||||
setMode("custom");
|
|
||||||
setProviderId("");
|
|
||||||
setModel("");
|
|
||||||
} else {
|
|
||||||
setProviderId(v);
|
|
||||||
setModel("");
|
|
||||||
}
|
|
||||||
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
|
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
fieldErrors.providerId ? "border-danger" : "border-border",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<option value="" disabled>
|
|
||||||
{catalog.loading ? "Chargement des providers…" : "Choisir un provider…"}
|
|
||||||
</option>
|
|
||||||
{filteredProviders.map((p) => (
|
|
||||||
<option key={p.providerId} value={p.providerId}>
|
|
||||||
{p.displayName}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
<option value={CUSTOM_PROVIDER_VALUE}>Autre / personnalisé…</option>
|
|
||||||
</select>
|
|
||||||
{fieldErrors.providerId && (
|
|
||||||
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<Caption>Modèle</Caption>
|
|
||||||
<select
|
|
||||||
aria-label={`${profile.name} model`}
|
|
||||||
value={model}
|
|
||||||
disabled={providerId.length === 0}
|
|
||||||
onChange={(e) => {
|
|
||||||
setModel(e.target.value);
|
|
||||||
setFieldErrors((prev) => ({ ...prev, model: undefined }));
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
|
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
fieldErrors.model ? "border-danger" : "border-border",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<option value="" disabled>
|
|
||||||
{providerId.length === 0 ? "—" : "Choisir un modèle…"}
|
|
||||||
</option>
|
|
||||||
{models.map((m) => (
|
|
||||||
<option key={m} value={m}>
|
|
||||||
{m}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{fieldErrors.model && (
|
|
||||||
<small className="text-xs text-danger">{fieldErrors.model}</small>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mode === "custom" && (
|
|
||||||
<fieldset className="flex flex-col gap-2 rounded-md border border-border/50 p-2">
|
|
||||||
<legend className="px-1 text-xs font-medium text-muted">
|
|
||||||
Provider personnalisé
|
|
||||||
</legend>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-fit"
|
|
||||||
onClick={() => {
|
|
||||||
setMode("catalog");
|
|
||||||
setProviderId("");
|
|
||||||
setModel("");
|
|
||||||
setFieldErrors({});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
← Choisir un provider du catalogue
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<Caption>Identifiant du provider</Caption>
|
|
||||||
<Input
|
|
||||||
aria-label={`${profile.name} custom provider id`}
|
|
||||||
value={providerId}
|
|
||||||
placeholder="ex. mon-provider"
|
|
||||||
invalid={Boolean(fieldErrors.providerId)}
|
|
||||||
onChange={(e) => {
|
|
||||||
setProviderId(e.target.value);
|
|
||||||
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{fieldErrors.providerId && (
|
|
||||||
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<Caption>Paquet npm</Caption>
|
|
||||||
<Input
|
|
||||||
aria-label={`${profile.name} custom npm package`}
|
|
||||||
value={customNpm}
|
|
||||||
placeholder={DEFAULT_CUSTOM_NPM}
|
|
||||||
invalid={Boolean(fieldErrors.npm)}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCustomNpm(e.target.value);
|
|
||||||
setFieldErrors((prev) => ({ ...prev, npm: undefined }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{fieldErrors.npm && (
|
|
||||||
<small className="text-xs text-danger">{fieldErrors.npm}</small>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<Caption>URL de base</Caption>
|
|
||||||
<Input
|
|
||||||
aria-label={`${profile.name} custom base url`}
|
|
||||||
value={customBaseUrl}
|
|
||||||
placeholder="https://api.mon-provider.example/v1"
|
|
||||||
invalid={Boolean(fieldErrors.baseUrl)}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCustomBaseUrl(e.target.value);
|
|
||||||
setFieldErrors((prev) => ({ ...prev, baseUrl: undefined }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{fieldErrors.baseUrl && (
|
|
||||||
<small className="text-xs text-danger">{fieldErrors.baseUrl}</small>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<Caption>Modèle</Caption>
|
|
||||||
<Input
|
|
||||||
aria-label={`${profile.name} model`}
|
|
||||||
value={model}
|
|
||||||
placeholder="ex. mon-modele-1"
|
|
||||||
invalid={Boolean(fieldErrors.model)}
|
|
||||||
onChange={(e) => {
|
|
||||||
setModel(e.target.value);
|
|
||||||
setFieldErrors((prev) => ({ ...prev, model: undefined }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{fieldErrors.model && (
|
|
||||||
<small className="text-xs text-danger">{fieldErrors.model}</small>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<Caption>Libellé du modèle (optionnel)</Caption>
|
|
||||||
<Input
|
|
||||||
aria-label={`${profile.name} custom display name`}
|
|
||||||
value={customDisplayName}
|
|
||||||
placeholder="ex. Mon modèle"
|
|
||||||
onChange={(e) => setCustomDisplayName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<Caption>Clé API</Caption>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Input
|
|
||||||
aria-label={`${profile.name} api key`}
|
|
||||||
type={showKey ? "text" : "password"}
|
|
||||||
value={apiKey}
|
|
||||||
placeholder={
|
|
||||||
isEditing
|
|
||||||
? "Ressaisissez la clé API pour confirmer l'enregistrement"
|
|
||||||
: "ex. sk-ant-…"
|
|
||||||
}
|
|
||||||
invalid={Boolean(fieldErrors.apiKey)}
|
|
||||||
onChange={(e) => {
|
|
||||||
setApiKey(e.target.value);
|
|
||||||
setFieldErrors((prev) => ({ ...prev, apiKey: undefined }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
aria-label="afficher/masquer la clé API"
|
|
||||||
onClick={() => setShowKey((v) => !v)}
|
|
||||||
>
|
|
||||||
{showKey ? "🙈" : "👁"}
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
{fieldErrors.apiKey && (
|
|
||||||
<small className="text-xs text-danger">{fieldErrors.apiKey}</small>
|
|
||||||
)}
|
|
||||||
<small className="text-xs text-faint">
|
|
||||||
Jamais affichée ni renvoyée par IdeA une fois enregistrée ; stockée
|
|
||||||
chiffrée localement.
|
|
||||||
</small>
|
|
||||||
{isEditing && (
|
|
||||||
<small className="text-xs text-muted">
|
|
||||||
Pour des raisons de sécurité, la clé n'est jamais réaffichée :
|
|
||||||
ressaisissez-la à chaque modification de ce profil.
|
|
||||||
</small>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
loading={saving}
|
|
||||||
disabled={saveDisabled}
|
|
||||||
onClick={() => void save()}
|
|
||||||
className="w-fit"
|
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</Button>
|
|
||||||
</fieldset>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CliModelField({
|
function CliModelField({
|
||||||
profile,
|
profile,
|
||||||
onChange,
|
onChange,
|
||||||
@ -829,114 +317,6 @@ function CliModelField({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The OpenCode + llama.cpp config section: the base URL of the local
|
|
||||||
* `llama-server` (`/v1`), the model it serves, and an optional API key (the key
|
|
||||||
* itself — a local llama.cpp usually needs none). Rendered only for a `openCode`
|
|
||||||
* profile; edits flow back through `onChange` as a patched `opencode`.
|
|
||||||
*/
|
|
||||||
function OpenCodeFields({
|
|
||||||
profile,
|
|
||||||
errors,
|
|
||||||
servers,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
profile: AgentProfile;
|
|
||||||
errors: ProfileErrors;
|
|
||||||
/** Declared local model servers (F35.2), for the binding dropdown. */
|
|
||||||
servers: LocalModelServerConfig[];
|
|
||||||
onChange: (p: AgentProfile) => void;
|
|
||||||
}) {
|
|
||||||
const opencode = profile.opencode ?? defaultOpenCodeConfig();
|
|
||||||
const patch = (next: Partial<OpenCodeConfig>) =>
|
|
||||||
onChange({ ...profile, opencode: { ...opencode, ...next } });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
|
|
||||||
<legend className="px-1 text-xs font-medium text-muted">
|
|
||||||
Local model (OpenCode + llama.cpp)
|
|
||||||
</legend>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<Caption>Base URL</Caption>
|
|
||||||
<Input
|
|
||||||
aria-label={`${profile.name} base url`}
|
|
||||||
value={opencode.baseURL}
|
|
||||||
placeholder="http://localhost:8080/v1"
|
|
||||||
invalid={Boolean(errors.baseURL)}
|
|
||||||
onChange={(e) => patch({ baseURL: e.target.value })}
|
|
||||||
/>
|
|
||||||
{errors.baseURL && (
|
|
||||||
<small className="text-xs text-danger">{errors.baseURL}</small>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<Caption>Model</Caption>
|
|
||||||
<Input
|
|
||||||
aria-label={`${profile.name} model`}
|
|
||||||
value={opencode.model}
|
|
||||||
placeholder="qwen3-coder-30b"
|
|
||||||
invalid={Boolean(errors.model)}
|
|
||||||
onChange={(e) => patch({ model: e.target.value })}
|
|
||||||
/>
|
|
||||||
{errors.model && <small className="text-xs text-danger">{errors.model}</small>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<Caption>API key (optional — a local llama.cpp needs none)</Caption>
|
|
||||||
<Input
|
|
||||||
aria-label={`${profile.name} api key`}
|
|
||||||
value={opencode.apiKey ?? ""}
|
|
||||||
placeholder="e.g. sk-no-key"
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
patch({ apiKey: v.length === 0 ? undefined : v });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label={`${profile.name} reasoning`}
|
|
||||||
// Effective backend default is `true`; treat omitted as enabled.
|
|
||||||
checked={opencode.reasoning ?? true}
|
|
||||||
onChange={(e) => patch({ reasoning: e.target.checked })}
|
|
||||||
className="accent-primary"
|
|
||||||
/>
|
|
||||||
<Caption>Reasoning</Caption>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label={`${profile.name} attachment`}
|
|
||||||
// Effective backend default is `false`.
|
|
||||||
checked={opencode.attachment ?? false}
|
|
||||||
onChange={(e) => patch({ attachment: e.target.checked })}
|
|
||||||
className="accent-primary"
|
|
||||||
/>
|
|
||||||
<Caption>Attachments</Caption>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<Caption>
|
|
||||||
Local model server (bind to a managed llama.cpp server, or none)
|
|
||||||
</Caption>
|
|
||||||
<ModelServerSelect
|
|
||||||
ariaLabel={`${profile.name} local model server`}
|
|
||||||
servers={servers}
|
|
||||||
value={opencode.localModelServerId}
|
|
||||||
onChange={(serverId) => patch({ localModelServerId: serverId })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parses a number field: blank ⇒ `undefined` (backend applies its default). */
|
/** Parses a number field: blank ⇒ `undefined` (backend applies its default). */
|
||||||
function parseOptInt(raw: string): number | undefined {
|
function parseOptInt(raw: string): number | undefined {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
|
|||||||
629
frontend/src/features/first-run/OpenCodeModeFields.tsx
Normal file
629
frontend/src/features/first-run/OpenCodeModeFields.tsx
Normal file
@ -0,0 +1,629 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AgentProfile,
|
||||||
|
GatewayError,
|
||||||
|
LocalModelServerConfig,
|
||||||
|
OpenCodeConfig,
|
||||||
|
OpenCodeProviderCatalogEntry,
|
||||||
|
} from "@/domain";
|
||||||
|
import { useGateways } from "@/app/di";
|
||||||
|
import { Button, IconButton, Input, cn } from "@/shared";
|
||||||
|
import { ModelServerSelect } from "@/features/model-servers";
|
||||||
|
import {
|
||||||
|
defaultOpenCodeConfig,
|
||||||
|
type ProfileErrors,
|
||||||
|
} from "./profile";
|
||||||
|
|
||||||
|
/** A small caption above a control. */
|
||||||
|
function Caption({ children }: { children: React.ReactNode }) {
|
||||||
|
return <span className="text-xs font-medium text-muted">{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeError(e: unknown): string {
|
||||||
|
if (e && typeof e === "object" && "message" in e) {
|
||||||
|
return String((e as GatewayError).message);
|
||||||
|
}
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** View-model for the OpenCode cloud-provider catalogue (ticket #92). */
|
||||||
|
export interface OpenCodeProviderCatalog {
|
||||||
|
providers: OpenCodeProviderCatalogEntry[] | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
reload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the static OpenCode cloud-provider catalogue once for the whole form
|
||||||
|
* surface, so Cloud OpenCode rows share the same provider/model options.
|
||||||
|
*/
|
||||||
|
export function useOpenCodeProviderCatalog(): OpenCodeProviderCatalog {
|
||||||
|
const { profile } = useGateways();
|
||||||
|
const [providers, setProviders] = useState<OpenCodeProviderCatalogEntry[] | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setProviders(await profile.listOpenCodeProviders());
|
||||||
|
} catch (e) {
|
||||||
|
setProviders(null);
|
||||||
|
setError(describeError(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { providers, loading, error, reload: () => void load() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Segmented control choosing whether an OpenCode profile runs against the local
|
||||||
|
* `llama.cpp` endpoint or a cloud provider from the OpenCode registry.
|
||||||
|
*/
|
||||||
|
export function OpenCodeModeFields({
|
||||||
|
profile,
|
||||||
|
errors,
|
||||||
|
servers,
|
||||||
|
providerCatalog,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
profile: AgentProfile;
|
||||||
|
errors: ProfileErrors;
|
||||||
|
servers: LocalModelServerConfig[];
|
||||||
|
providerCatalog: OpenCodeProviderCatalog;
|
||||||
|
onChange: (p: AgentProfile) => void;
|
||||||
|
}) {
|
||||||
|
const [mode, setMode] = useState<"local" | "cloud">(
|
||||||
|
profile.opencodeProvider ? "cloud" : "local",
|
||||||
|
);
|
||||||
|
|
||||||
|
function selectMode(next: "local" | "cloud") {
|
||||||
|
setMode(next);
|
||||||
|
if (next === "local" && profile.opencodeProvider) {
|
||||||
|
onChange({
|
||||||
|
...profile,
|
||||||
|
opencode: profile.opencode ?? defaultOpenCodeConfig(),
|
||||||
|
opencodeProvider: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-1 flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="mode du profil OpenCode"
|
||||||
|
className="flex w-fit gap-1 rounded-md border border-border bg-raised p-0.5"
|
||||||
|
>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ id: "local", label: "Local (llama.cpp)" },
|
||||||
|
{ id: "cloud", label: "Provider cloud" },
|
||||||
|
] as const
|
||||||
|
).map((seg) => {
|
||||||
|
const active = mode === seg.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={seg.id}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={active}
|
||||||
|
onClick={() => selectMode(seg.id)}
|
||||||
|
className={cn(
|
||||||
|
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-primary text-on-primary"
|
||||||
|
: "text-muted hover:text-content",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{seg.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "local" && (
|
||||||
|
<OpenCodeFields
|
||||||
|
profile={profile}
|
||||||
|
errors={errors}
|
||||||
|
servers={servers}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "cloud" && (
|
||||||
|
<OpenCodeProviderFields
|
||||||
|
profile={profile}
|
||||||
|
catalog={providerCatalog}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Field-keyed validation errors for the Cloud sub-form, surfaced on submit. */
|
||||||
|
interface CloudFieldErrors {
|
||||||
|
providerId?: string;
|
||||||
|
model?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
npm?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sentinel `<option>` value picking the custom-provider sub-form. */
|
||||||
|
const CUSTOM_PROVIDER_VALUE = "__custom__";
|
||||||
|
|
||||||
|
/** Default AI SDK package pre-filled for a fresh custom-provider draft. */
|
||||||
|
const DEFAULT_CUSTOM_NPM = "@ai-sdk/openai-compatible";
|
||||||
|
|
||||||
|
function OpenCodeProviderFields({
|
||||||
|
profile,
|
||||||
|
catalog,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
profile: AgentProfile;
|
||||||
|
catalog: OpenCodeProviderCatalog;
|
||||||
|
onChange: (p: AgentProfile) => void;
|
||||||
|
}) {
|
||||||
|
const { profile: profileGateway } = useGateways();
|
||||||
|
const existing = profile.opencodeProvider;
|
||||||
|
const [mode, setMode] = useState<"catalog" | "custom">(
|
||||||
|
existing?.custom ? "custom" : "catalog",
|
||||||
|
);
|
||||||
|
const [providerId, setProviderId] = useState(existing?.providerId ?? "");
|
||||||
|
const [model, setModel] = useState(existing?.model ?? "");
|
||||||
|
const [providerFilter, setProviderFilter] = useState("");
|
||||||
|
const [customNpm, setCustomNpm] = useState(existing?.custom?.npm ?? DEFAULT_CUSTOM_NPM);
|
||||||
|
const [customBaseUrl, setCustomBaseUrl] = useState(existing?.custom?.baseUrl ?? "");
|
||||||
|
const [customDisplayName, setCustomDisplayName] = useState(
|
||||||
|
existing?.custom?.displayName ?? "",
|
||||||
|
);
|
||||||
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const [showKey, setShowKey] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<CloudFieldErrors>({});
|
||||||
|
|
||||||
|
const isEditing = existing !== undefined;
|
||||||
|
const models =
|
||||||
|
catalog.providers?.find((p) => p.providerId === providerId)?.models ?? [];
|
||||||
|
const catalogReady = catalog.providers !== null && !catalog.loading;
|
||||||
|
const filteredProviders = (catalog.providers ?? []).filter((p) => {
|
||||||
|
if (p.providerId === providerId) return true;
|
||||||
|
const q = providerFilter.trim().toLowerCase();
|
||||||
|
if (q.length === 0) return true;
|
||||||
|
return (
|
||||||
|
p.displayName.toLowerCase().includes(q) ||
|
||||||
|
p.providerId.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const saveDisabled =
|
||||||
|
saving ||
|
||||||
|
apiKey.length === 0 ||
|
||||||
|
(mode === "catalog" && (!catalogReady || Boolean(catalog.error)));
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const errors: CloudFieldErrors = {};
|
||||||
|
if (providerId.trim().length === 0) {
|
||||||
|
errors.providerId = "Le provider est obligatoire.";
|
||||||
|
}
|
||||||
|
if (model.trim().length === 0) errors.model = "Le modèle est obligatoire.";
|
||||||
|
if (mode === "custom") {
|
||||||
|
if (customNpm.trim().length === 0) errors.npm = "Le paquet npm est obligatoire.";
|
||||||
|
if (customBaseUrl.trim().length === 0) {
|
||||||
|
errors.baseUrl = "L'URL de base est obligatoire.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (apiKey.length === 0) errors.apiKey = "La clé API est obligatoire.";
|
||||||
|
setFieldErrors(errors);
|
||||||
|
if (Object.keys(errors).length > 0) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
|
try {
|
||||||
|
const saved = await profileGateway.saveOpenCodeProviderProfile({
|
||||||
|
profile,
|
||||||
|
providerId: providerId.trim(),
|
||||||
|
model: model.trim(),
|
||||||
|
apiKey,
|
||||||
|
...(mode === "custom"
|
||||||
|
? {
|
||||||
|
custom: {
|
||||||
|
npm: customNpm.trim(),
|
||||||
|
baseUrl: customBaseUrl.trim(),
|
||||||
|
displayName:
|
||||||
|
customDisplayName.trim().length > 0
|
||||||
|
? customDisplayName.trim()
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
onChange(saved);
|
||||||
|
setApiKey("");
|
||||||
|
} catch (e) {
|
||||||
|
setSaveError(describeError(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
|
||||||
|
<legend className="px-1 text-xs font-medium text-muted">
|
||||||
|
Provider cloud (OpenCode)
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
{saveError && (
|
||||||
|
<p role="alert" className="text-sm text-danger">
|
||||||
|
{saveError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{catalog.loading && (
|
||||||
|
<p className="text-xs text-faint">Chargement des providers…</p>
|
||||||
|
)}
|
||||||
|
{catalog.error && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p role="alert" className="text-sm text-danger">
|
||||||
|
Impossible de charger la liste des providers cloud.
|
||||||
|
</p>
|
||||||
|
<Button size="sm" onClick={() => catalog.reload()} className="w-fit">
|
||||||
|
Réessayer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "catalog" && (
|
||||||
|
<>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Provider</Caption>
|
||||||
|
{catalogReady && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
aria-label={`${profile.name} provider search`}
|
||||||
|
placeholder="Rechercher un provider…"
|
||||||
|
value={providerFilter}
|
||||||
|
onChange={(e) => setProviderFilter(e.target.value)}
|
||||||
|
className="h-8 w-full rounded-md border border-border bg-raised px-3 text-xs text-content outline-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
aria-label={`${profile.name} provider`}
|
||||||
|
value={providerId}
|
||||||
|
disabled={!catalogReady}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (v === CUSTOM_PROVIDER_VALUE) {
|
||||||
|
setMode("custom");
|
||||||
|
setProviderId("");
|
||||||
|
setModel("");
|
||||||
|
} else {
|
||||||
|
setProviderId(v);
|
||||||
|
setModel("");
|
||||||
|
}
|
||||||
|
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
fieldErrors.providerId ? "border-danger" : "border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
{catalog.loading ? "Chargement des providers…" : "Choisir un provider…"}
|
||||||
|
</option>
|
||||||
|
{filteredProviders.map((p) => (
|
||||||
|
<option key={p.providerId} value={p.providerId}>
|
||||||
|
{p.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value={CUSTOM_PROVIDER_VALUE}>Autre / personnalisé…</option>
|
||||||
|
</select>
|
||||||
|
{fieldErrors.providerId && (
|
||||||
|
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Modèle</Caption>
|
||||||
|
<select
|
||||||
|
aria-label={`${profile.name} model`}
|
||||||
|
value={model}
|
||||||
|
disabled={providerId.length === 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
setModel(e.target.value);
|
||||||
|
setFieldErrors((prev) => ({ ...prev, model: undefined }));
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
fieldErrors.model ? "border-danger" : "border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
{providerId.length === 0 ? "—" : "Choisir un modèle…"}
|
||||||
|
</option>
|
||||||
|
{models.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{fieldErrors.model && (
|
||||||
|
<small className="text-xs text-danger">{fieldErrors.model}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "custom" && (
|
||||||
|
<fieldset className="flex flex-col gap-2 rounded-md border border-border/50 p-2">
|
||||||
|
<legend className="px-1 text-xs font-medium text-muted">
|
||||||
|
Provider personnalisé
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-fit"
|
||||||
|
onClick={() => {
|
||||||
|
setMode("catalog");
|
||||||
|
setProviderId("");
|
||||||
|
setModel("");
|
||||||
|
setFieldErrors({});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Choisir un provider du catalogue
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Identifiant du provider</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} custom provider id`}
|
||||||
|
value={providerId}
|
||||||
|
placeholder="ex. mon-provider"
|
||||||
|
invalid={Boolean(fieldErrors.providerId)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setProviderId(e.target.value);
|
||||||
|
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{fieldErrors.providerId && (
|
||||||
|
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Paquet npm</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} custom npm package`}
|
||||||
|
value={customNpm}
|
||||||
|
placeholder={DEFAULT_CUSTOM_NPM}
|
||||||
|
invalid={Boolean(fieldErrors.npm)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomNpm(e.target.value);
|
||||||
|
setFieldErrors((prev) => ({ ...prev, npm: undefined }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{fieldErrors.npm && (
|
||||||
|
<small className="text-xs text-danger">{fieldErrors.npm}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>URL de base</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} custom base url`}
|
||||||
|
value={customBaseUrl}
|
||||||
|
placeholder="https://api.mon-provider.example/v1"
|
||||||
|
invalid={Boolean(fieldErrors.baseUrl)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomBaseUrl(e.target.value);
|
||||||
|
setFieldErrors((prev) => ({ ...prev, baseUrl: undefined }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{fieldErrors.baseUrl && (
|
||||||
|
<small className="text-xs text-danger">{fieldErrors.baseUrl}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Modèle</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} model`}
|
||||||
|
value={model}
|
||||||
|
placeholder="ex. mon-modele-1"
|
||||||
|
invalid={Boolean(fieldErrors.model)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setModel(e.target.value);
|
||||||
|
setFieldErrors((prev) => ({ ...prev, model: undefined }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{fieldErrors.model && (
|
||||||
|
<small className="text-xs text-danger">{fieldErrors.model}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Libellé du modèle (optionnel)</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} custom display name`}
|
||||||
|
value={customDisplayName}
|
||||||
|
placeholder="ex. Mon modèle"
|
||||||
|
onChange={(e) => setCustomDisplayName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Clé API</Caption>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} api key`}
|
||||||
|
type={showKey ? "text" : "password"}
|
||||||
|
value={apiKey}
|
||||||
|
placeholder={
|
||||||
|
isEditing
|
||||||
|
? "Ressaisissez la clé API pour confirmer l'enregistrement"
|
||||||
|
: "ex. sk-ant-…"
|
||||||
|
}
|
||||||
|
invalid={Boolean(fieldErrors.apiKey)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setApiKey(e.target.value);
|
||||||
|
setFieldErrors((prev) => ({ ...prev, apiKey: undefined }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
aria-label="afficher/masquer la clé API"
|
||||||
|
onClick={() => setShowKey((v) => !v)}
|
||||||
|
>
|
||||||
|
{showKey ? "🙈" : "👁"}
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
{fieldErrors.apiKey && (
|
||||||
|
<small className="text-xs text-danger">{fieldErrors.apiKey}</small>
|
||||||
|
)}
|
||||||
|
<small className="text-xs text-faint">
|
||||||
|
Jamais affichée ni renvoyée par IdeA une fois enregistrée ; stockée
|
||||||
|
chiffrée localement.
|
||||||
|
</small>
|
||||||
|
{isEditing && (
|
||||||
|
<small className="text-xs text-muted">
|
||||||
|
Pour des raisons de sécurité, la clé n'est jamais réaffichée :
|
||||||
|
ressaisissez-la à chaque modification de ce profil.
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
loading={saving}
|
||||||
|
disabled={saveDisabled}
|
||||||
|
onClick={() => void save()}
|
||||||
|
className="w-fit"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</Button>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpenCodeFields({
|
||||||
|
profile,
|
||||||
|
errors,
|
||||||
|
servers,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
profile: AgentProfile;
|
||||||
|
errors: ProfileErrors;
|
||||||
|
/** Declared local model servers (F35.2), for the binding dropdown. */
|
||||||
|
servers: LocalModelServerConfig[];
|
||||||
|
onChange: (p: AgentProfile) => void;
|
||||||
|
}) {
|
||||||
|
const opencode = profile.opencode ?? defaultOpenCodeConfig();
|
||||||
|
const patch = (next: Partial<OpenCodeConfig>) =>
|
||||||
|
onChange({
|
||||||
|
...profile,
|
||||||
|
opencode: { ...opencode, ...next },
|
||||||
|
opencodeProvider: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
|
||||||
|
<legend className="px-1 text-xs font-medium text-muted">
|
||||||
|
Local model (OpenCode + llama.cpp)
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Base URL</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} base url`}
|
||||||
|
value={opencode.baseURL}
|
||||||
|
placeholder="http://localhost:8080/v1"
|
||||||
|
invalid={Boolean(errors.baseURL)}
|
||||||
|
onChange={(e) => patch({ baseURL: e.target.value })}
|
||||||
|
/>
|
||||||
|
{errors.baseURL && (
|
||||||
|
<small className="text-xs text-danger">{errors.baseURL}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Model</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} model`}
|
||||||
|
value={opencode.model}
|
||||||
|
placeholder="qwen3-coder-30b"
|
||||||
|
invalid={Boolean(errors.model)}
|
||||||
|
onChange={(e) => patch({ model: e.target.value })}
|
||||||
|
/>
|
||||||
|
{errors.model && <small className="text-xs text-danger">{errors.model}</small>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>API key (optional — a local llama.cpp needs none)</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} api key`}
|
||||||
|
value={opencode.apiKey ?? ""}
|
||||||
|
placeholder="e.g. sk-no-key"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
patch({ apiKey: v.length === 0 ? undefined : v });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`${profile.name} reasoning`}
|
||||||
|
checked={opencode.reasoning ?? true}
|
||||||
|
onChange={(e) => patch({ reasoning: e.target.checked })}
|
||||||
|
className="accent-primary"
|
||||||
|
/>
|
||||||
|
<Caption>Reasoning</Caption>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`${profile.name} attachment`}
|
||||||
|
checked={opencode.attachment ?? false}
|
||||||
|
onChange={(e) => patch({ attachment: e.target.checked })}
|
||||||
|
className="accent-primary"
|
||||||
|
/>
|
||||||
|
<Caption>Attachments</Caption>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>
|
||||||
|
Local model server (bind to a managed llama.cpp server, or none)
|
||||||
|
</Caption>
|
||||||
|
<ModelServerSelect
|
||||||
|
ariaLabel={`${profile.name} local model server`}
|
||||||
|
servers={servers}
|
||||||
|
value={opencode.localModelServerId}
|
||||||
|
onChange={(serverId) => patch({ localModelServerId: serverId })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,16 +2,20 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import { MockProfileGateway } from "@/adapters/mock";
|
import { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock";
|
||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
import type { ProfileModelCatalog } from "@/domain";
|
import type { LocalModelServerConfig, ProfileModelCatalog } from "@/domain";
|
||||||
import { ProfilesSettings } from "./ProfilesSettings";
|
import { ProfilesSettings } from "./ProfilesSettings";
|
||||||
|
|
||||||
function renderSettings(profile: MockProfileGateway = new MockProfileGateway()) {
|
function renderSettings(
|
||||||
|
profile: MockProfileGateway = new MockProfileGateway(),
|
||||||
|
modelServer: MockModelServerGateway = new MockModelServerGateway(),
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
profile,
|
profile,
|
||||||
|
modelServer,
|
||||||
...render(
|
...render(
|
||||||
<DIProvider gateways={{ profile } as unknown as Gateways}>
|
<DIProvider gateways={{ profile, modelServer } as unknown as Gateways}>
|
||||||
<ProfilesSettings />
|
<ProfilesSettings />
|
||||||
</DIProvider>,
|
</DIProvider>,
|
||||||
),
|
),
|
||||||
@ -159,4 +163,62 @@ describe("ProfilesSettings", () => {
|
|||||||
await screen.findByText(/Ce modèle semble plus récent que votre Codex CLI 0\.145\.0/),
|
await screen.findByText(/Ce modèle semble plus récent que votre Codex CLI 0\.145\.0/),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores OpenCode local servers, local binding, and cloud provider choice in settings", async () => {
|
||||||
|
const modelServer = new MockModelServerGateway();
|
||||||
|
const server: LocalModelServerConfig = {
|
||||||
|
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
kind: "llamaCpp",
|
||||||
|
name: "Local A",
|
||||||
|
baseURL: "http://localhost:8080/v1",
|
||||||
|
port: 8080,
|
||||||
|
servedModelName: "qwen3-coder-30b",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
jinja: false,
|
||||||
|
args: [],
|
||||||
|
autoStart: false,
|
||||||
|
stopPolicy: "stopOnAppExit",
|
||||||
|
};
|
||||||
|
await modelServer.saveModelServer(server);
|
||||||
|
|
||||||
|
const { profile } = renderSettings(new MockProfileGateway(), modelServer);
|
||||||
|
await waitReady();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "OpenCode-local" }));
|
||||||
|
await waitReady();
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "add model server" })).toBeTruthy();
|
||||||
|
expect(screen.getByText("Local A")).toBeTruthy();
|
||||||
|
|
||||||
|
await createProfile();
|
||||||
|
const select = await screen.findByLabelText(
|
||||||
|
"OpenCode + llama.cpp copy local model server",
|
||||||
|
);
|
||||||
|
fireEvent.change(select, { target: { value: server.id } });
|
||||||
|
|
||||||
|
const profileRow = select.closest("li");
|
||||||
|
expect(profileRow).not.toBeNull();
|
||||||
|
fireEvent.click(
|
||||||
|
within(profileRow as HTMLElement).getByRole("button", {
|
||||||
|
name: "Enregistrer",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const saved = await profile.listProfiles();
|
||||||
|
const opencode = saved.find((p) => p.structuredAdapter === "openCode");
|
||||||
|
expect(opencode?.opencode?.localModelServerId).toBe(server.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
within(profileRow as HTMLElement).getByRole("radio", {
|
||||||
|
name: "Provider cloud",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await within(profileRow as HTMLElement).findByLabelText(
|
||||||
|
"OpenCode + llama.cpp copy provider",
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,6 +14,15 @@ import type {
|
|||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { Button, Input, Panel, cn } from "@/shared";
|
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 ProfileTab = "codex" | "claude" | "openCode";
|
||||||
type ModelTab = "codex" | "claude";
|
type ModelTab = "codex" | "claude";
|
||||||
@ -258,6 +267,8 @@ function ModelField({
|
|||||||
|
|
||||||
export function ProfilesSettings() {
|
export function ProfilesSettings() {
|
||||||
const { profile } = useGateways();
|
const { profile } = useGateways();
|
||||||
|
const modelServers = useModelServers();
|
||||||
|
const providerCatalog = useOpenCodeProviderCatalog();
|
||||||
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
|
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
|
||||||
const [references, setReferences] = useState<AgentProfile[]>([]);
|
const [references, setReferences] = useState<AgentProfile[]>([]);
|
||||||
const [catalogue, setCatalogue] = useState(EMPTY_CATALOGUE);
|
const [catalogue, setCatalogue] = useState(EMPTY_CATALOGUE);
|
||||||
@ -423,11 +434,6 @@ export function ProfilesSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelOptions =
|
|
||||||
activeTab === "codex" || activeTab === "claude"
|
|
||||||
? catalogue[activeTab].models
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
aria-label="ai profiles settings"
|
aria-label="ai profiles settings"
|
||||||
@ -479,6 +485,8 @@ export function ProfilesSettings() {
|
|||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{activeTab === "openCode" && <ModelServersPanel vm={modelServers} />}
|
||||||
|
|
||||||
{visibleProfiles.length === 0 ? (
|
{visibleProfiles.length === 0 ? (
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
Aucun profil {TABS.find((tab) => tab.id === activeTab)?.label} configure.
|
Aucun profil {TABS.find((tab) => tab.id === activeTab)?.label} configure.
|
||||||
@ -489,6 +497,7 @@ export function ProfilesSettings() {
|
|||||||
const draft = drafts[saved.id] ?? saved;
|
const draft = drafts[saved.id] ?? saved;
|
||||||
const model = modelOf(draft);
|
const model = modelOf(draft);
|
||||||
const dirty = JSON.stringify(draft) !== JSON.stringify(saved);
|
const dirty = JSON.stringify(draft) !== JSON.stringify(saved);
|
||||||
|
const errors = validateProfile(draft);
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={saved.id}
|
key={saved.id}
|
||||||
@ -520,26 +529,21 @@ export function ProfilesSettings() {
|
|||||||
updateDraft(saved.id, (p) => withModel(p, value))
|
updateDraft(saved.id, (p) => withModel(p, value))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : null}
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
|
{draft.structuredAdapter === "openCode" && (
|
||||||
|
<OpenCodeModeFields
|
||||||
|
profile={draft}
|
||||||
|
errors={errors}
|
||||||
|
servers={modelServers.servers}
|
||||||
|
providerCatalog={providerCatalog}
|
||||||
|
onChange={(next) =>
|
||||||
|
updateDraft(saved.id, () => next)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{saveWarnings[saved.id] && (
|
{saveWarnings[saved.id] && (
|
||||||
<p className="mt-2 rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
<p className="mt-2 rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||||
{saveWarnings[saved.id]}
|
{saveWarnings[saved.id]}
|
||||||
|
|||||||
Reference in New Issue
Block a user