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:
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user