Files
IdeA/frontend/src/features/first-run/FirstRunWizard.tsx
Blomios 1784027f5d feat(frontend): provider OpenCode dynamique + saisie provider personnalisé (#92)
Le picker de provider OpenCode du first-run wizard consomme le
catalogue dynamique exposé par le backend et propose une option
« provider personnalisé » avec un formulaire de saisie libre
(id + clé API) quand le provider souhaité n'y figure pas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 12:44:02 +02:00

1027 lines
34 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.

/**
* 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 { useCallback, useEffect, useState } from "react";
import type {
AgentProfile,
GatewayError,
HttpChatConfig,
LocalModelServerConfig,
OpenCodeConfig,
OpenCodeProviderCatalogEntry,
} from "@/domain";
import { useGateways } from "@/app/di";
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
import {
ModelServersPanel,
ModelServerSelect,
useModelServers,
} from "@/features/model-servers";
import { useFirstRun, type WizardEntry } from "./useFirstRun";
import {
defaultHttpChatConfig,
defaultOpenCodeConfig,
parseArgs,
validateProfile,
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). */
interface OpenCodeProviderCatalog {
providers: OpenCodeProviderCatalogEntry[] | null;
loading: boolean;
error: string | null;
reload: () => void;
}
/**
* Loads the static OpenCode cloud-provider catalogue once for the whole
* wizard (every Cloud row shares it), so the provider/model pickers can be
* populated. Exposes a `reload` for the blocking "Réessayer" state.
*/
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() };
}
/**
* 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;
}) {
// 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="first run setup"
className="border-primary/40"
title={
<div className="flex flex-col">
<h2 className="text-base font-semibold text-content">Welcome to IdeA</h2>
<p className="text-sm text-muted">
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"
>
Detect installed CLIs
</Button>
{vm.detecting && (
<span role="status" className="text-xs text-muted">
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"
>
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} (copy)`,
opencode: entry.profile.opencode,
})
: undefined
}
/>
))}
</ul>
<footer className="flex gap-2">
<Button variant="primary" onClick={() => void finish()} disabled={vm.busy}>
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 { profile, selected, available } = entry;
const errors = validateProfile(profile);
const isOpenCode = profile.structuredAdapter === "openCode";
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={`use ${profile.name}`}
className="accent-primary"
/>
<strong className="text-sm text-content">{profile.name}</strong>
</label>
<span
aria-label={`${profile.name} availability`}
className={cn(
"text-xs",
available === null
? "text-faint"
: available
? "text-success"
: "text-danger",
)}
>
{available === null ? "—" : available ? "✓ installed" : "✗ not found"}
</span>
<div className="ml-auto flex items-center gap-1">
{onDuplicate && (
<Button
size="sm"
aria-label={`duplicate ${profile.name}`}
onClick={onDuplicate}
>
Duplicate
</Button>
)}
<IconButton
size="sm"
aria-label={`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>Name</Caption>
<Input
aria-label={`${profile.name} 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>Command</Caption>
<Input
aria-label={`${profile.name} command`}
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>Arguments</Caption>
<Input
aria-label={`${profile.name} args`}
value={profile.args.join(" ")}
onChange={(e) => onChange({ ...profile, args: parseArgs(e.target.value) })}
/>
</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}
/>
)}
</li>
);
}
/**
* Segmented control (F — ticket #92) choosing whether an OpenCode profile runs
* against the local `llama.cpp` endpoint or a cloud provider from the OpenCode
* registry, and renders the matching sub-form. The two sub-forms never overlap;
* switching segments keeps the inactive one's draft in memory (component-local
* state) without touching `profile` until it is actually submitted.
*/
function OpenCodeModeFields({
profile,
errors,
servers,
providerCatalog,
onChange,
}: {
profile: AgentProfile;
errors: ProfileErrors;
servers: LocalModelServerConfig[];
providerCatalog: OpenCodeProviderCatalog;
onChange: (p: AgentProfile) => void;
}) {
const [mode, setMode] = useState<"local" | "cloud">(
profile.opencodeProvider ? "cloud" : "local",
);
return (
<div className="mt-1 flex flex-col gap-2">
<div
role="radiogroup"
aria-label="mode du profil OpenCode"
className="flex w-fit gap-1 rounded-md border border-border bg-raised p-0.5"
>
{(
[
{ id: "local", label: "Local (llama.cpp)" },
{ id: "cloud", label: "Provider cloud" },
] as const
).map((seg) => {
const active = mode === seg.id;
return (
<button
key={seg.id}
type="button"
role="radio"
aria-checked={active}
onClick={() => setMode(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";
/**
* The OpenCode **cloud** provider config section (ticket #92): provider ➜
* model (cascading selects fed by the dynamic catalogue, now up to ~170
* entries) ➜ API key, or — via the "Autre / personnalisé" catalogue entry — a
* free-form OpenAI-compatible endpoint (provider id, npm SDK package, base
* URL, model id, optional display name). The key is never pre-filled (create
* or edit) since the backend never returns it — it reseals whatever literal
* it receives on every save, so editing an existing cloud profile requires
* re-entering it every time (§3 of the spec).
*/
function OpenCodeProviderFields({
profile,
catalog,
onChange,
}: {
profile: AgentProfile;
catalog: OpenCodeProviderCatalog;
onChange: (p: AgentProfile) => void;
}) {
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) => {
// Always keep the current selection visible even if it no longer matches
// the filter, so the <select> doesn't silently lose its value.
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 = "Le provider est obligatoire.";
}
if (model.trim().length === 0) errors.model = "Le modèle est obligatoire.";
if (mode === "custom") {
if (customNpm.trim().length === 0) errors.npm = "Le paquet npm est obligatoire.";
if (customBaseUrl.trim().length === 0) {
errors.baseUrl = "L'URL de base est obligatoire.";
}
}
if (apiKey.length === 0) errors.apiKey = "La clé API est obligatoire.";
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">
Provider cloud (OpenCode)
</legend>
{saveError && (
<p role="alert" className="text-sm text-danger">
{saveError}
</p>
)}
{catalog.loading && (
<p className="text-xs text-faint">Chargement des providers</p>
)}
{catalog.error && (
<div className="flex flex-col gap-1">
<p role="alert" className="text-sm text-danger">
Impossible de charger la liste des providers cloud.
</p>
<Button size="sm" onClick={() => catalog.reload()} className="w-fit">
Réessayer
</Button>
</div>
)}
{mode === "catalog" && (
<>
<label className="flex flex-col gap-1">
<Caption>Provider</Caption>
{catalogReady && (
<input
type="text"
aria-label={`${profile.name} provider search`}
placeholder="Rechercher un provider…"
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"
/>
)}
<select
aria-label={`${profile.name} provider`}
value={providerId}
disabled={!catalogReady}
onChange={(e) => {
const v = e.target.value;
if (v === CUSTOM_PROVIDER_VALUE) {
setMode("custom");
setProviderId("");
setModel("");
} else {
setProviderId(v);
setModel("");
}
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
}}
className={cn(
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
fieldErrors.providerId ? "border-danger" : "border-border",
)}
>
<option value="" disabled>
{catalog.loading ? "Chargement des providers…" : "Choisir un provider…"}
</option>
{filteredProviders.map((p) => (
<option key={p.providerId} value={p.providerId}>
{p.displayName}
</option>
))}
<option value={CUSTOM_PROVIDER_VALUE}>Autre / personnalisé</option>
</select>
{fieldErrors.providerId && (
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Modèle</Caption>
<select
aria-label={`${profile.name} model`}
value={model}
disabled={providerId.length === 0}
onChange={(e) => {
setModel(e.target.value);
setFieldErrors((prev) => ({ ...prev, model: undefined }));
}}
className={cn(
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
fieldErrors.model ? "border-danger" : "border-border",
)}
>
<option value="" disabled>
{providerId.length === 0 ? "—" : "Choisir un modèle…"}
</option>
{models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
{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">
Provider personnalisé
</legend>
<Button
variant="ghost"
size="sm"
className="w-fit"
onClick={() => {
setMode("catalog");
setProviderId("");
setModel("");
setFieldErrors({});
}}
>
Choisir un provider du catalogue
</Button>
<label className="flex flex-col gap-1">
<Caption>Identifiant du provider</Caption>
<Input
aria-label={`${profile.name} custom provider id`}
value={providerId}
placeholder="ex. mon-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>Paquet npm</Caption>
<Input
aria-label={`${profile.name} custom npm package`}
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>URL de base</Caption>
<Input
aria-label={`${profile.name} custom base url`}
value={customBaseUrl}
placeholder="https://api.mon-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>Modèle</Caption>
<Input
aria-label={`${profile.name} model`}
value={model}
placeholder="ex. mon-modele-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>Libellé du modèle (optionnel)</Caption>
<Input
aria-label={`${profile.name} custom display name`}
value={customDisplayName}
placeholder="ex. Mon modèle"
onChange={(e) => setCustomDisplayName(e.target.value)}
/>
</label>
</fieldset>
)}
<label className="flex flex-col gap-1">
<Caption>Clé API</Caption>
<div className="flex items-center gap-1">
<Input
aria-label={`${profile.name} api key`}
type={showKey ? "text" : "password"}
value={apiKey}
placeholder={
isEditing
? "Ressaisissez la clé API pour confirmer l'enregistrement"
: "ex. sk-ant-…"
}
invalid={Boolean(fieldErrors.apiKey)}
onChange={(e) => {
setApiKey(e.target.value);
setFieldErrors((prev) => ({ ...prev, apiKey: undefined }));
}}
/>
<IconButton
size="sm"
aria-label="afficher/masquer la clé API"
onClick={() => setShowKey((v) => !v)}
>
{showKey ? "🙈" : "👁"}
</IconButton>
</div>
{fieldErrors.apiKey && (
<small className="text-xs text-danger">{fieldErrors.apiKey}</small>
)}
<small className="text-xs text-faint">
Jamais affichée ni renvoyée par IdeA une fois enregistrée ; stockée
chiffrée localement.
</small>
{isEditing && (
<small className="text-xs text-muted">
Pour des raisons de sécurité, la clé n&apos;est jamais réaffichée :
ressaisissez-la à chaque modification de ce profil.
</small>
)}
</label>
<Button
variant="primary"
size="sm"
loading={saving}
disabled={saveDisabled}
onClick={() => void save()}
className="w-fit"
>
Enregistrer
</Button>
</fieldset>
);
}
/**
* The OpenCode + llama.cpp config section: the base URL of the local
* `llama-server` (`/v1`), the model it serves, and an optional API key (the key
* itself — a local llama.cpp usually needs none). Rendered only for a `openCode`
* profile; edits flow back through `onChange` as a patched `opencode`.
*/
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 opencode = profile.opencode ?? defaultOpenCodeConfig();
const patch = (next: Partial<OpenCodeConfig>) =>
onChange({ ...profile, opencode: { ...opencode, ...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 model (OpenCode + llama.cpp)
</legend>
<label className="flex flex-col gap-1">
<Caption>Base URL</Caption>
<Input
aria-label={`${profile.name} base url`}
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>Model</Caption>
<Input
aria-label={`${profile.name} model`}
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>API key (optional a local llama.cpp needs none)</Caption>
<Input
aria-label={`${profile.name} api key`}
value={opencode.apiKey ?? ""}
placeholder="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={`${profile.name} reasoning`}
// Effective backend default is `true`; treat omitted as enabled.
checked={opencode.reasoning ?? true}
onChange={(e) => patch({ reasoning: e.target.checked })}
className="accent-primary"
/>
<Caption>Reasoning</Caption>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
aria-label={`${profile.name} attachment`}
// Effective backend default is `false`.
checked={opencode.attachment ?? false}
onChange={(e) => patch({ attachment: e.target.checked })}
className="accent-primary"
/>
<Caption>Attachments</Caption>
</label>
</div>
<label className="flex flex-col gap-1">
<Caption>
Local model server (bind to a managed llama.cpp server, or none)
</Caption>
<ModelServerSelect
ariaLabel={`${profile.name} local model server`}
servers={servers}
value={opencode.localModelServerId}
onChange={(serverId) => patch({ localModelServerId: serverId })}
/>
</label>
</fieldset>
);
}
/** 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>
);
}