feat(ui): profils locaux/LAN OpenAI-compatible dans le first-run et les terminaux (#14)

Câble la surface frontend des profils IA locaux/LAN OpenAI-compatible,
en parité avec l'adapter backend additif (aab4bca).

- domain: types de profil OpenAI-compatible
- first-run: édition/validation du profil dans le FirstRunWizard
- adapters/mock: mock de profil pour les tests
- terminals: rendu des round-trips et erreurs endpoint (role=alert)

Validé QA (frontend GO): typecheck exit 0, vitest 59 fichiers / 566 tests,
0 echec ; couverture timeouts round-trip + erreur endpoint role=alert
prouvees ; pas de regression sur les tests de bail headless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 22:34:50 +02:00
parent aab4bcafb6
commit d89380cdf0
9 changed files with 656 additions and 10 deletions

View File

@ -15,10 +15,15 @@
* `./profile`.
*/
import type { AgentProfile } from "@/domain";
import type { AgentProfile, HttpChatConfig } from "@/domain";
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
import { useFirstRun, type WizardEntry } from "./useFirstRun";
import { parseArgs, validateProfile } from "./profile";
import {
defaultHttpChatConfig,
parseArgs,
validateProfile,
type ProfileErrors,
} from "./profile";
/** A small caption above a control. */
function Caption({ children }: { children: React.ReactNode }) {
@ -174,6 +179,133 @@ function ProfileRow({
onChange={(e) => onChange({ ...profile, args: parseArgs(e.target.value) })}
/>
</label>
{profile.structuredAdapter === "openAiCompatible" && (
<HttpChatFields profile={profile} errors={errors} onChange={onChange} />
)}
</li>
);
}
/** 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 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">
Local / LAN model (OpenAI-compatible)
</legend>
<label className="flex flex-col gap-1">
<Caption>Endpoint</Caption>
<Input
aria-label={`${profile.name} endpoint`}
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>Model</Caption>
<Input
aria-label={`${profile.name} model`}
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>API key environment variable name (never the key itself)</Caption>
<Input
aria-label={`${profile.name} api key env`}
value={http.apiKeyEnv ?? ""}
placeholder="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>Request timeout (ms)</Caption>
<Input
aria-label={`${profile.name} request timeout`}
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>Connect timeout (ms)</Caption>
<Input
aria-label={`${profile.name} connect timeout`}
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>Max tool iterations</Caption>
<Input
aria-label={`${profile.name} max tool iterations`}
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>
);
}