Lot en un seul commit: les deux features partagent les mêmes contrats (domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes surfaces UI — la séparation par fichiers était impossible sans chirurgie de hunks risquant des états intermédiaires non compilables (convention lot des batches #310-#313 et #315). #325 — icône configurable par projet, watermark CLI custom: - domaine: ProjectIconSet (devicon, simple-icons, lucide, idea), ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon optionnel sur ProjectSettings; sérialisation camelCase et try_from strict - application: UpdateProjectSettings.execute accepte un patch theme_color OU icon (absent = inchangé, null = défaut produit), tests in-dependants avec store mémoire - backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto avec icon optionnel (désérialisation additive), commandes update_project_settings alignées Tauri/HTTP, tests DTO et HTTP (défaut null, set, clear, nom invalide rejeté) - frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en grille multi-lignes dans Paramètres projet, affichage de l'icône dans ProjectTabs et watermark grand format à faible opacité en fond de CLI custom; ports ProjectSettings.icon, Partial<ProjectSettings>, adapters tauri/mock/http et tests alignés #326 — localisation FR/EN et traduction des textes IdeA: - domaine: IdeaLanguage (fr | en), champ language optionnel sur IdeaUserSettings (absence = défaut produit, série additive validée) - backend/web-server: UserSettingsDto.language, update_user_settings accepte language, tests HTTP alignés; fixtures structured_launch_d3 mises à jour - frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression des sources visibles), GeneralSettings avec sélecteur de langue dans les paramètres, setupI18n dans vitest; migration de l'ensemble des textes hardcodés vers le catalogue sur toutes les surfaces (agents, layout, projets, tickets, sprints, web, plugins, permissions, git, templates, skills, mémoire, model servers, embedder, first-run, devices, scheduled tasks, workstate, dialogs), FR conservé par défaut; recherche d'icônes #325 en anglais conformément au ticket QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
633 lines
22 KiB
TypeScript
633 lines
22 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 { useI18n } from "@/i18n";
|
||
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 { tr } = useI18n();
|
||
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={tr("Mode du profil OpenCode", "OpenCode profile mode")}
|
||
className="flex w-fit gap-1 rounded-md border border-border bg-raised p-0.5"
|
||
>
|
||
{(
|
||
[
|
||
{ id: "local", label: tr("Local (llama.cpp)", "Local (llama.cpp)") },
|
||
{ id: "cloud", label: tr("Fournisseur cloud", "Cloud provider") },
|
||
] 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 { tr } = useI18n();
|
||
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 = tr("Le fournisseur est obligatoire.", "Provider is required.");
|
||
}
|
||
if (model.trim().length === 0) errors.model = tr("Le modèle est obligatoire.", "Model is required.");
|
||
if (mode === "custom") {
|
||
if (customNpm.trim().length === 0) errors.npm = tr("Le paquet npm est obligatoire.", "npm package is required.");
|
||
if (customBaseUrl.trim().length === 0) {
|
||
errors.baseUrl = tr("L’URL de base est obligatoire.", "Base URL is required.");
|
||
}
|
||
}
|
||
if (apiKey.length === 0) errors.apiKey = tr("La clé API est obligatoire.", "API key is required.");
|
||
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">
|
||
{tr("Fournisseur cloud (OpenCode)", "Cloud provider (OpenCode)")}
|
||
</legend>
|
||
|
||
{saveError && (
|
||
<p role="alert" className="text-sm text-danger">
|
||
{saveError}
|
||
</p>
|
||
)}
|
||
|
||
{catalog.loading && (
|
||
<p className="text-xs text-faint">{tr("Chargement des fournisseurs…", "Loading providers…")}</p>
|
||
)}
|
||
{catalog.error && (
|
||
<div className="flex flex-col gap-1">
|
||
<p role="alert" className="text-sm text-danger">
|
||
{tr("Impossible de charger la liste des fournisseurs cloud.", "Unable to load the cloud provider list.")}
|
||
</p>
|
||
<Button size="sm" onClick={() => catalog.reload()} className="w-fit">
|
||
{tr("Réessayer", "Retry")}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{mode === "catalog" && (
|
||
<>
|
||
<label className="flex flex-col gap-1">
|
||
<Caption>{tr("Fournisseur", "Provider")}</Caption>
|
||
{catalogReady && (
|
||
<input
|
||
type="text"
|
||
aria-label={tr(`Rechercher un fournisseur pour ${profile.name}`, `Search providers for ${profile.name}`)}
|
||
placeholder={tr("Rechercher un fournisseur…", "Search providers…")}
|
||
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={tr(`Fournisseur de ${profile.name}`, `Provider for ${profile.name}`)}
|
||
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
|
||
? tr("Chargement des fournisseurs…", "Loading providers…")
|
||
: tr("Choisir un fournisseur…", "Choose a provider…"),
|
||
disabled: true,
|
||
},
|
||
...filteredProviders.map((p) => ({
|
||
value: p.providerId,
|
||
label: p.displayName,
|
||
})),
|
||
{
|
||
value: CUSTOM_PROVIDER_VALUE,
|
||
label: tr("Autre / personnalisé…", "Other / custom…"),
|
||
},
|
||
]}
|
||
/>
|
||
{fieldErrors.providerId && (
|
||
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
|
||
)}
|
||
</label>
|
||
|
||
<label className="flex flex-col gap-1">
|
||
<Caption>{tr("Modèle", "Model")}</Caption>
|
||
<SmallDropdown
|
||
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
|
||
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 ? "—" : tr("Choisir un modèle…", "Choose a model…"),
|
||
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">
|
||
{tr("Fournisseur personnalisé", "Custom provider")}
|
||
</legend>
|
||
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="w-fit"
|
||
onClick={() => {
|
||
setMode("catalog");
|
||
setProviderId("");
|
||
setModel("");
|
||
setFieldErrors({});
|
||
}}
|
||
>
|
||
← {tr("Choisir un fournisseur du catalogue", "Choose a catalog provider")}
|
||
</Button>
|
||
|
||
<label className="flex flex-col gap-1">
|
||
<Caption>{tr("Identifiant du fournisseur", "Provider ID")}</Caption>
|
||
<Input
|
||
aria-label={tr(`Identifiant du fournisseur personnalisé de ${profile.name}`, `Custom provider ID for ${profile.name}`)}
|
||
value={providerId}
|
||
placeholder={tr("ex. mon-fournisseur", "e.g. my-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>{tr("Paquet npm", "npm package")}</Caption>
|
||
<Input
|
||
aria-label={tr(`Paquet npm personnalisé de ${profile.name}`, `Custom npm package for ${profile.name}`)}
|
||
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>{tr("URL de base", "Base URL")}</Caption>
|
||
<Input
|
||
aria-label={tr(`URL de base personnalisée de ${profile.name}`, `Custom base URL for ${profile.name}`)}
|
||
value={customBaseUrl}
|
||
placeholder="https://api.my-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>{tr("Modèle", "Model")}</Caption>
|
||
<Input
|
||
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
|
||
value={model}
|
||
placeholder={tr("ex. mon-modele-1", "e.g. my-model-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>{tr("Libellé du modèle (facultatif)", "Model label (optional)")}</Caption>
|
||
<Input
|
||
aria-label={tr(`Libellé personnalisé de ${profile.name}`, `Custom display name for ${profile.name}`)}
|
||
value={customDisplayName}
|
||
placeholder={tr("ex. Mon modèle", "e.g. My model")}
|
||
onChange={(e) => setCustomDisplayName(e.target.value)}
|
||
/>
|
||
</label>
|
||
</fieldset>
|
||
)}
|
||
|
||
<label className="flex flex-col gap-1">
|
||
<Caption>{tr("Clé API", "API key")}</Caption>
|
||
<div className="flex items-center gap-1">
|
||
<Input
|
||
aria-label={tr(`Clé API de ${profile.name}`, `API key for ${profile.name}`)}
|
||
type={showKey ? "text" : "password"}
|
||
value={apiKey}
|
||
placeholder={
|
||
isEditing
|
||
? tr("Ressaisissez la clé API pour confirmer l’enregistrement", "Enter the API key again to confirm saving")
|
||
: tr("ex. sk-ant-…", "e.g. sk-ant-…")
|
||
}
|
||
invalid={Boolean(fieldErrors.apiKey)}
|
||
onChange={(e) => {
|
||
setApiKey(e.target.value);
|
||
setFieldErrors((prev) => ({ ...prev, apiKey: undefined }));
|
||
}}
|
||
/>
|
||
<IconButton
|
||
size="sm"
|
||
aria-label={tr("Afficher ou masquer la clé API", "Show or hide API key")}
|
||
onClick={() => setShowKey((v) => !v)}
|
||
>
|
||
{showKey ? "🙈" : "👁"}
|
||
</IconButton>
|
||
</div>
|
||
{fieldErrors.apiKey && (
|
||
<small className="text-xs text-danger">{fieldErrors.apiKey}</small>
|
||
)}
|
||
<small className="text-xs text-faint">
|
||
{tr("Jamais affichée ni renvoyée par IdeA une fois enregistrée ; stockée chiffrée localement.", "Never displayed or returned by IdeA after saving; stored locally in encrypted form.")}
|
||
</small>
|
||
{isEditing && (
|
||
<small className="text-xs text-muted">
|
||
{tr("Pour des raisons de sécurité, la clé n’est jamais réaffichée : ressaisissez-la à chaque modification de ce profil.", "For security, the key is never displayed again: enter it again whenever you edit this profile.")}
|
||
</small>
|
||
)}
|
||
</label>
|
||
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
loading={saving}
|
||
disabled={saveDisabled}
|
||
onClick={() => void save()}
|
||
className="w-fit"
|
||
>
|
||
{tr("Enregistrer", "Save")}
|
||
</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 { tr } = useI18n();
|
||
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">
|
||
{tr("Modèle local (OpenCode + llama.cpp)", "Local model (OpenCode + llama.cpp)")}
|
||
</legend>
|
||
|
||
<label className="flex flex-col gap-1">
|
||
<Caption>{tr("URL de base", "Base URL")}</Caption>
|
||
<Input
|
||
aria-label={tr(`URL de base de ${profile.name}`, `Base URL for ${profile.name}`)}
|
||
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>{tr("Modèle", "Model")}</Caption>
|
||
<Input
|
||
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
|
||
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>{tr("Clé API (facultative — un llama.cpp local n’en a pas besoin)", "API key (optional — a local llama.cpp needs none)")}</Caption>
|
||
<Input
|
||
aria-label={tr(`Clé API de ${profile.name}`, `API key for ${profile.name}`)}
|
||
value={opencode.apiKey ?? ""}
|
||
placeholder={tr("ex. sk-no-key", "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={tr(`Raisonnement de ${profile.name}`, `Reasoning for ${profile.name}`)}
|
||
checked={opencode.reasoning ?? true}
|
||
onChange={(e) => patch({ reasoning: e.target.checked })}
|
||
className="accent-primary"
|
||
/>
|
||
<Caption>{tr("Raisonnement", "Reasoning")}</Caption>
|
||
</label>
|
||
|
||
<label className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
aria-label={tr(`Pièces jointes de ${profile.name}`, `Attachments for ${profile.name}`)}
|
||
checked={opencode.attachment ?? false}
|
||
onChange={(e) => patch({ attachment: e.target.checked })}
|
||
className="accent-primary"
|
||
/>
|
||
<Caption>{tr("Pièces jointes", "Attachments")}</Caption>
|
||
</label>
|
||
</div>
|
||
|
||
<label className="flex flex-col gap-1">
|
||
<Caption>
|
||
{tr("Serveur de modèle local (lier à un serveur llama.cpp géré, ou aucun)", "Local model server (bind to a managed llama.cpp server, or none)")}
|
||
</Caption>
|
||
<ModelServerSelect
|
||
ariaLabel={tr(`Serveur de modèle local de ${profile.name}`, `Local model server for ${profile.name}`)}
|
||
servers={servers}
|
||
value={opencode.localModelServerId}
|
||
onChange={(serverId) => patch({ localModelServerId: serverId })}
|
||
/>
|
||
</label>
|
||
</fieldset>
|
||
);
|
||
}
|