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.
458 lines
17 KiB
TypeScript
458 lines
17 KiB
TypeScript
/**
|
||
* First-run wizard (L5). Shown on the very first IDE launch: it offers the
|
||
* pre-filled, selectable reference profiles (Claude/Codex — only AIs drivable in
|
||
* structured mode, §17.6/D7) with **editable** commands, lets the user detect
|
||
* which CLIs are installed (✓/✗), then saves the chosen profiles and closes the
|
||
* first run.
|
||
*
|
||
* The candidate list is already filtered server-side (`reference_profiles` only
|
||
* exposes selectable profiles), so the wizard renders whatever it receives.
|
||
* Adding an arbitrary custom profile is no longer offered, since we cannot drive
|
||
* it in structured mode.
|
||
*
|
||
* Pure presentation: all behaviour comes from {@link useFirstRun} (the
|
||
* {@link ProfileGateway} port). Profile validation is the pure logic in
|
||
* `./profile`.
|
||
*/
|
||
|
||
import type {
|
||
AgentProfile,
|
||
HttpChatConfig,
|
||
LocalModelServerConfig,
|
||
} from "@/domain";
|
||
import { useEffect, useState } from "react";
|
||
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
|
||
import { useI18n } from "@/i18n";
|
||
import {
|
||
ModelServersPanel,
|
||
useModelServers,
|
||
} from "@/features/model-servers";
|
||
import { useFirstRun, type WizardEntry } from "./useFirstRun";
|
||
import {
|
||
defaultHttpChatConfig,
|
||
parseArgs,
|
||
validateProfile,
|
||
type ProfileErrors,
|
||
} from "./profile";
|
||
import {
|
||
OpenCodeModeFields,
|
||
useOpenCodeProviderCatalog,
|
||
type OpenCodeProviderCatalog,
|
||
} from "./OpenCodeModeFields";
|
||
|
||
/** A small caption above a control. */
|
||
function Caption({ children }: { children: React.ReactNode }) {
|
||
return <span className="text-xs font-medium text-muted">{children}</span>;
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
* Returns `null` while loading. By default it also returns `null` once the first
|
||
* run is done (auto-show path in {@link App}); pass `forceOpen` to render it
|
||
* regardless — used by "Settings ▸ Configure profiles" to reopen the wizard after
|
||
* the first run.
|
||
*/
|
||
export function FirstRunWizard({
|
||
onDone,
|
||
forceOpen = false,
|
||
}: {
|
||
onDone?: () => void;
|
||
/** Render the wizard even when it is no longer the first run. */
|
||
forceOpen?: boolean;
|
||
}) {
|
||
const { tr } = useI18n();
|
||
// Mode is explicit (ticket #44), driven by the entry point — never inferred
|
||
// from `isFirstRun`: reopening from Settings (`forceOpen`) is the "edit" mode,
|
||
// which pre-loads and pre-selects the already-configured profiles.
|
||
const vm = useFirstRun(forceOpen ? "edit" : "firstRun");
|
||
const modelServers = useModelServers();
|
||
const providerCatalog = useOpenCodeProviderCatalog();
|
||
|
||
if (vm.isFirstRun === null) return null;
|
||
if (!forceOpen && vm.isFirstRun === false) return null;
|
||
|
||
async function finish() {
|
||
await vm.finish();
|
||
onDone?.();
|
||
}
|
||
|
||
return (
|
||
<Panel
|
||
aria-label={tr("Configuration initiale", "First run setup")}
|
||
className="border-primary/40"
|
||
title={
|
||
<div className="flex flex-col">
|
||
<h2 className="text-base font-semibold text-content">{tr("Bienvenue dans IdeA", "Welcome to IdeA")}</h2>
|
||
<p className="text-sm text-muted">
|
||
{tr("Choisissez les CLI d’IA à configurer. Les commandes sont préremplies et modifiables.", "Choose which AI CLIs to configure. Commands are pre-filled and editable.")}
|
||
</p>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="flex flex-col gap-4">
|
||
{vm.error && (
|
||
<p role="alert" className="text-sm text-danger">
|
||
{vm.error}
|
||
</p>
|
||
)}
|
||
|
||
<Toolbar>
|
||
{/* `detecting` deliberately does NOT disable this button (ticket #28):
|
||
detection is best-effort and may never answer. */}
|
||
<Button
|
||
onClick={() => void vm.detect()}
|
||
disabled={vm.busy}
|
||
className="whitespace-nowrap"
|
||
>
|
||
{tr("Détecter les CLI installées", "Detect installed CLIs")}
|
||
</Button>
|
||
{vm.detecting && (
|
||
<span role="status" className="text-xs text-muted">
|
||
{tr("Détection…", "Detecting…")}
|
||
</span>
|
||
)}
|
||
{/* F36: declare several local OpenCode profiles. Each click clones the
|
||
canonical `opencode-llamacpp` seed into a new, editable row. */}
|
||
<Button
|
||
onClick={() => void vm.addOpenCodeProfile()}
|
||
disabled={vm.busy}
|
||
className="whitespace-nowrap"
|
||
>
|
||
{tr("Ajouter un profil OpenCode", "Add OpenCode profile")}
|
||
</Button>
|
||
</Toolbar>
|
||
|
||
{/* F35.2 — declare/edit/delete the local llama.cpp servers an OpenCode
|
||
profile can bind to. Sits above the profile list so a server exists
|
||
before it is picked in the OpenCode dropdown. */}
|
||
<ModelServersPanel vm={modelServers} />
|
||
|
||
<ul className="flex list-none flex-col gap-3 p-0">
|
||
{vm.entries.map((entry) => (
|
||
<ProfileRow
|
||
key={entry.profile.id}
|
||
entry={entry}
|
||
servers={modelServers.servers}
|
||
providerCatalog={providerCatalog}
|
||
onToggle={() => vm.toggle(entry.profile.id)}
|
||
onChange={(p) => vm.updateProfile(entry.profile.id, p)}
|
||
onRemove={() => vm.remove(entry.profile.id)}
|
||
onDuplicate={
|
||
entry.profile.structuredAdapter === "openCode"
|
||
? () =>
|
||
vm.addOpenCodeProfile({
|
||
name: `${entry.profile.name} (${tr("copie", "copy")})`,
|
||
opencode: entry.profile.opencode,
|
||
})
|
||
: undefined
|
||
}
|
||
/>
|
||
))}
|
||
</ul>
|
||
|
||
<footer className="flex gap-2">
|
||
<Button variant="primary" onClick={() => void finish()} disabled={vm.busy}>
|
||
{tr("Enregistrer et continuer", "Save and continue")}
|
||
</Button>
|
||
</footer>
|
||
</div>
|
||
</Panel>
|
||
);
|
||
}
|
||
|
||
/** One editable candidate row: select, edit command/args, see availability. */
|
||
function ProfileRow({
|
||
entry,
|
||
servers,
|
||
providerCatalog,
|
||
onToggle,
|
||
onChange,
|
||
onRemove,
|
||
onDuplicate,
|
||
}: {
|
||
entry: WizardEntry;
|
||
/** Declared local model servers (F35.2), for the OpenCode binding dropdown. */
|
||
servers: LocalModelServerConfig[];
|
||
/** OpenCode cloud-provider catalogue (ticket #92), shared across rows. */
|
||
providerCatalog: OpenCodeProviderCatalog;
|
||
onToggle: () => void;
|
||
onChange: (p: AgentProfile) => void;
|
||
onRemove: () => void;
|
||
/** Present only for OpenCode rows: clone this row into a new profile (F36). */
|
||
onDuplicate?: () => void;
|
||
}) {
|
||
const { tr } = useI18n();
|
||
const { profile, selected, available } = entry;
|
||
const errors = validateProfile(profile);
|
||
const isOpenCode = profile.structuredAdapter === "openCode";
|
||
const canonicalArgsText = profile.args.join(" ");
|
||
const [argsText, setArgsText] = useState(canonicalArgsText);
|
||
|
||
useEffect(() => {
|
||
setArgsText(canonicalArgsText);
|
||
}, [canonicalArgsText, profile.id]);
|
||
|
||
function commitArgs() {
|
||
onChange({ ...profile, args: parseArgs(argsText) });
|
||
}
|
||
|
||
return (
|
||
<li className="flex flex-col gap-2 rounded-md border border-border bg-raised p-3">
|
||
<div className="flex items-center gap-2">
|
||
<label className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={selected}
|
||
onChange={onToggle}
|
||
aria-label={tr(`Utiliser ${profile.name}`, `Use ${profile.name}`)}
|
||
className="accent-primary"
|
||
/>
|
||
<strong className="text-sm text-content">{profile.name}</strong>
|
||
</label>
|
||
<span
|
||
aria-label={tr(`Disponibilité de ${profile.name}`, `${profile.name} availability`)}
|
||
className={cn(
|
||
"text-xs",
|
||
available === null
|
||
? "text-faint"
|
||
: available
|
||
? "text-success"
|
||
: "text-danger",
|
||
)}
|
||
>
|
||
{available === null ? "—" : available ? tr("✓ installée", "✓ installed") : tr("✗ introuvable", "✗ not found")}
|
||
</span>
|
||
<div className="ml-auto flex items-center gap-1">
|
||
{onDuplicate && (
|
||
<Button
|
||
size="sm"
|
||
aria-label={tr(`Dupliquer ${profile.name}`, `Duplicate ${profile.name}`)}
|
||
onClick={onDuplicate}
|
||
>
|
||
{tr("Dupliquer", "Duplicate")}
|
||
</Button>
|
||
)}
|
||
<IconButton
|
||
size="sm"
|
||
aria-label={tr(`Retirer ${profile.name}`, `Remove ${profile.name}`)}
|
||
onClick={onRemove}
|
||
>
|
||
×
|
||
</IconButton>
|
||
</div>
|
||
</div>
|
||
|
||
{/* F36: an OpenCode profile's name is identity-neutral but user-facing, so
|
||
it is editable per profile (several local models coexist). */}
|
||
{isOpenCode && (
|
||
<label className="flex flex-col gap-1">
|
||
<Caption>{tr("Nom", "Name")}</Caption>
|
||
<Input
|
||
aria-label={tr(`Nom de ${profile.name}`, `Name for ${profile.name}`)}
|
||
value={profile.name}
|
||
invalid={Boolean(errors.name)}
|
||
onChange={(e) => onChange({ ...profile, name: e.target.value })}
|
||
/>
|
||
{errors.name && <small className="text-xs text-danger">{errors.name}</small>}
|
||
</label>
|
||
)}
|
||
|
||
<label className="flex flex-col gap-1">
|
||
<Caption>{tr("Commande", "Command")}</Caption>
|
||
<Input
|
||
aria-label={tr(`Commande de ${profile.name}`, `Command for ${profile.name}`)}
|
||
value={profile.command}
|
||
invalid={Boolean(errors.command)}
|
||
onChange={(e) => onChange({ ...profile, command: e.target.value })}
|
||
/>
|
||
{errors.command && <small className="text-xs text-danger">{errors.command}</small>}
|
||
</label>
|
||
|
||
<label className="flex flex-col gap-1">
|
||
<Caption>{tr("Arguments", "Arguments")}</Caption>
|
||
<Input
|
||
aria-label={tr(`Arguments de ${profile.name}`, `Arguments for ${profile.name}`)}
|
||
value={argsText}
|
||
onChange={(e) => setArgsText(e.target.value)}
|
||
onBlur={commitArgs}
|
||
/>
|
||
</label>
|
||
|
||
{profile.structuredAdapter === "openAiCompatible" && (
|
||
<HttpChatFields profile={profile} errors={errors} onChange={onChange} />
|
||
)}
|
||
|
||
{profile.structuredAdapter === "openCode" && (
|
||
<OpenCodeModeFields
|
||
profile={profile}
|
||
errors={errors}
|
||
servers={servers}
|
||
providerCatalog={providerCatalog}
|
||
onChange={onChange}
|
||
/>
|
||
)}
|
||
|
||
{(profile.structuredAdapter === "codex" ||
|
||
profile.structuredAdapter === "claude") &&
|
||
(selected || profile.model) && (
|
||
<CliModelField profile={profile} onChange={onChange} />
|
||
)}
|
||
</li>
|
||
);
|
||
}
|
||
|
||
function CliModelField({
|
||
profile,
|
||
onChange,
|
||
}: {
|
||
profile: AgentProfile;
|
||
onChange: (p: AgentProfile) => void;
|
||
}) {
|
||
const { tr } = useI18n();
|
||
return (
|
||
<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={profile.model ?? ""}
|
||
placeholder={tr("Laisser vide pour le modèle par défaut de la CLI", "Leave empty for the CLI default model")}
|
||
onChange={(e) => {
|
||
const model = e.target.value.trim();
|
||
onChange({
|
||
...profile,
|
||
model: model.length > 0 ? model : undefined,
|
||
});
|
||
}}
|
||
/>
|
||
<small className="text-xs text-faint">
|
||
{tr("Facultatif. L’authentification Codex/Claude reste gérée par la CLI.", "Optional. Codex/Claude authentication remains managed by the CLI.")}
|
||
</small>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
/** Parses a number field: blank ⇒ `undefined` (backend applies its default). */
|
||
function parseOptInt(raw: string): number | undefined {
|
||
const trimmed = raw.trim();
|
||
if (trimmed.length === 0) return undefined;
|
||
const n = Number(trimmed);
|
||
return Number.isFinite(n) ? n : NaN;
|
||
}
|
||
|
||
/**
|
||
* The OpenAI-compatible (local/LAN) HTTP config section: endpoint, model, the
|
||
* *name* of the API-key env var (never the key), and optional timeouts / tool
|
||
* guard. Rendered only for a `openAiCompatible` profile; edits flow back through
|
||
* `onChange` as a patched `chatHttp`.
|
||
*/
|
||
function HttpChatFields({
|
||
profile,
|
||
errors,
|
||
onChange,
|
||
}: {
|
||
profile: AgentProfile;
|
||
errors: ProfileErrors;
|
||
onChange: (p: AgentProfile) => void;
|
||
}) {
|
||
const { tr } = useI18n();
|
||
const http = profile.chatHttp ?? defaultHttpChatConfig();
|
||
const patch = (next: Partial<HttpChatConfig>) =>
|
||
onChange({ ...profile, chatHttp: { ...http, ...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">
|
||
{tr("Modèle local / réseau (compatible OpenAI)", "Local / LAN model (OpenAI-compatible)")}
|
||
</legend>
|
||
|
||
<label className="flex flex-col gap-1">
|
||
<Caption>{tr("Point d’accès", "Endpoint")}</Caption>
|
||
<Input
|
||
aria-label={tr(`Point d’accès de ${profile.name}`, `Endpoint for ${profile.name}`)}
|
||
value={http.endpoint}
|
||
placeholder="http://localhost:11434/v1"
|
||
invalid={Boolean(errors.endpoint)}
|
||
onChange={(e) => patch({ endpoint: e.target.value })}
|
||
/>
|
||
{errors.endpoint && (
|
||
<small className="text-xs text-danger">{errors.endpoint}</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={http.model}
|
||
placeholder="qwen2.5-coder"
|
||
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 — nom de la variable d’environnement (jamais la clé elle-même)", "API key — environment variable name (never the key itself)")}</Caption>
|
||
<Input
|
||
aria-label={tr(`Variable de clé API de ${profile.name}`, `API key environment variable for ${profile.name}`)}
|
||
value={http.apiKeyEnv ?? ""}
|
||
placeholder={tr("ex. OPENAI_API_KEY (nom de variable, pas le secret)", "e.g. OPENAI_API_KEY (variable name, not the secret)")}
|
||
invalid={Boolean(errors.apiKeyEnv)}
|
||
onChange={(e) => {
|
||
const v = e.target.value.trim();
|
||
patch({ apiKeyEnv: v.length === 0 ? undefined : v });
|
||
}}
|
||
/>
|
||
{errors.apiKeyEnv && (
|
||
<small className="text-xs text-danger">{errors.apiKeyEnv}</small>
|
||
)}
|
||
</label>
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||
<Caption>{tr("Délai de requête (ms)", "Request timeout (ms)")}</Caption>
|
||
<Input
|
||
aria-label={tr(`Délai de requête de ${profile.name}`, `Request timeout for ${profile.name}`)}
|
||
value={http.requestTimeoutMs?.toString() ?? ""}
|
||
inputMode="numeric"
|
||
invalid={Boolean(errors.requestTimeoutMs)}
|
||
onChange={(e) => patch({ requestTimeoutMs: parseOptInt(e.target.value) })}
|
||
/>
|
||
{errors.requestTimeoutMs && (
|
||
<small className="text-xs text-danger">{errors.requestTimeoutMs}</small>
|
||
)}
|
||
</label>
|
||
|
||
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||
<Caption>{tr("Délai de connexion (ms)", "Connect timeout (ms)")}</Caption>
|
||
<Input
|
||
aria-label={tr(`Délai de connexion de ${profile.name}`, `Connect timeout for ${profile.name}`)}
|
||
value={http.connectTimeoutMs?.toString() ?? ""}
|
||
inputMode="numeric"
|
||
invalid={Boolean(errors.connectTimeoutMs)}
|
||
onChange={(e) => patch({ connectTimeoutMs: parseOptInt(e.target.value) })}
|
||
/>
|
||
{errors.connectTimeoutMs && (
|
||
<small className="text-xs text-danger">{errors.connectTimeoutMs}</small>
|
||
)}
|
||
</label>
|
||
|
||
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||
<Caption>{tr("Nombre maximal d’itérations d’outils", "Max tool iterations")}</Caption>
|
||
<Input
|
||
aria-label={tr(`Itérations d’outils maximales de ${profile.name}`, `Max tool iterations for ${profile.name}`)}
|
||
value={http.maxToolIterations?.toString() ?? ""}
|
||
inputMode="numeric"
|
||
invalid={Boolean(errors.maxToolIterations)}
|
||
onChange={(e) => patch({ maxToolIterations: parseOptInt(e.target.value) })}
|
||
/>
|
||
{errors.maxToolIterations && (
|
||
<small className="text-xs text-danger">{errors.maxToolIterations}</small>
|
||
)}
|
||
</label>
|
||
</div>
|
||
</fieldset>
|
||
);
|
||
}
|