Files
IdeA/frontend/src/features/first-run/OpenCodeModeFields.tsx
Blomios 1a1429f10e feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)
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.
2026-09-11 21:53:56 +02:00

633 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}