Introduit SmallDropdown comme composant partagé et le fait adopter par AgentsPanel, ModelServerSelect, OpenCodeModeFields, TemplateEditor et TicketViewportSelect, pour que toutes les listes déroulantes dynamiques (agent, modèle, template, viewport) partagent la même apparence que le sélecteur d'agent de la création de ticket. Refs #114 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
631 lines
20 KiB
TypeScript
631 lines
20 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
|
|
import type {
|
|
AgentProfile,
|
|
GatewayError,
|
|
LocalModelServerConfig,
|
|
OpenCodeConfig,
|
|
OpenCodeProviderCatalogEntry,
|
|
} from "@/domain";
|
|
import { useGateways } from "@/app/di";
|
|
import { Button, IconButton, Input, SmallDropdown, 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"
|
|
/>
|
|
)}
|
|
<SmallDropdown
|
|
aria-label={`${profile.name} provider`}
|
|
value={providerId}
|
|
disabled={!catalogReady}
|
|
onChange={(v) => {
|
|
if (v === CUSTOM_PROVIDER_VALUE) {
|
|
setMode("custom");
|
|
setProviderId("");
|
|
setModel("");
|
|
} else {
|
|
setProviderId(v);
|
|
setModel("");
|
|
}
|
|
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
|
|
}}
|
|
className="w-full"
|
|
size="md"
|
|
invalid={Boolean(fieldErrors.providerId)}
|
|
options={[
|
|
{
|
|
value: "",
|
|
label: catalog.loading
|
|
? "Chargement des providers…"
|
|
: "Choisir un provider…",
|
|
disabled: true,
|
|
},
|
|
...filteredProviders.map((p) => ({
|
|
value: p.providerId,
|
|
label: p.displayName,
|
|
})),
|
|
{
|
|
value: CUSTOM_PROVIDER_VALUE,
|
|
label: "Autre / personnalisé…",
|
|
},
|
|
]}
|
|
/>
|
|
{fieldErrors.providerId && (
|
|
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
|
|
)}
|
|
</label>
|
|
|
|
<label className="flex flex-col gap-1">
|
|
<Caption>Modèle</Caption>
|
|
<SmallDropdown
|
|
aria-label={`${profile.name} model`}
|
|
value={model}
|
|
disabled={providerId.length === 0}
|
|
onChange={(next) => {
|
|
setModel(next);
|
|
setFieldErrors((prev) => ({ ...prev, model: undefined }));
|
|
}}
|
|
className="w-full"
|
|
size="md"
|
|
invalid={Boolean(fieldErrors.model)}
|
|
options={[
|
|
{
|
|
value: "",
|
|
label: providerId.length === 0 ? "—" : "Choisir un modèle…",
|
|
disabled: true,
|
|
},
|
|
...models.map((m) => ({ value: m, label: m })),
|
|
]}
|
|
/>
|
|
{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>
|
|
);
|
|
}
|