- backend A-C: VO Codex/Claude, renderers/projecteurs modèle, SecretRef/env, catalogues/use cases/Tauri commands - frontend D: profils Codex/Claude provider->model->secret, validations, conservation SecretRef - fix: app-tauri embedded_server isolant IDEA_WEB_ROOT QA: backend 57/57 tests, frontend 74/74 tests OK
1553 lines
51 KiB
TypeScript
1553 lines
51 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 { useCallback, useEffect, useState } from "react";
|
||
|
||
import type {
|
||
AgentProfile,
|
||
ClaudeProviderCatalogEntry,
|
||
CodexCustomProviderConfig,
|
||
CodexProviderCatalogEntry,
|
||
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;
|
||
}
|
||
|
||
interface CodexProviderCatalog {
|
||
providers: CodexProviderCatalogEntry[] | null;
|
||
loading: boolean;
|
||
error: string | null;
|
||
reload: () => void;
|
||
}
|
||
|
||
interface ClaudeProviderCatalog {
|
||
providers: ClaudeProviderCatalogEntry[] | 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() };
|
||
}
|
||
|
||
function useCodexProviderCatalog(): CodexProviderCatalog {
|
||
const { profile } = useGateways();
|
||
const [providers, setProviders] = useState<CodexProviderCatalogEntry[] | 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.listCodexProviders());
|
||
} catch (e) {
|
||
setProviders(null);
|
||
setError(describeError(e));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [profile]);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
return { providers, loading, error, reload: () => void load() };
|
||
}
|
||
|
||
function useClaudeProviderCatalog(): ClaudeProviderCatalog {
|
||
const { profile } = useGateways();
|
||
const [providers, setProviders] = useState<ClaudeProviderCatalogEntry[] | 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.listClaudeProviders());
|
||
} 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();
|
||
const codexProviderCatalog = useCodexProviderCatalog();
|
||
const claudeProviderCatalog = useClaudeProviderCatalog();
|
||
|
||
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}
|
||
codexProviderCatalog={codexProviderCatalog}
|
||
claudeProviderCatalog={claudeProviderCatalog}
|
||
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,
|
||
codexProviderCatalog,
|
||
claudeProviderCatalog,
|
||
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;
|
||
codexProviderCatalog: CodexProviderCatalog;
|
||
claudeProviderCatalog: ClaudeProviderCatalog;
|
||
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}
|
||
/>
|
||
)}
|
||
|
||
{profile.structuredAdapter === "codex" &&
|
||
(selected || profile.codexProvider) && (
|
||
<CodexProviderFields
|
||
profile={profile}
|
||
catalog={codexProviderCatalog}
|
||
onChange={onChange}
|
||
/>
|
||
)}
|
||
|
||
{profile.structuredAdapter === "claude" &&
|
||
(selected || profile.claudeProvider) && (
|
||
<ClaudeProviderFields
|
||
profile={profile}
|
||
catalog={claudeProviderCatalog}
|
||
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'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>
|
||
);
|
||
}
|
||
|
||
type SimpleProviderCatalogEntry =
|
||
| CodexProviderCatalogEntry
|
||
| ClaudeProviderCatalogEntry;
|
||
|
||
interface ProviderModelSecretFieldsProps {
|
||
engine: "Codex" | "Claude";
|
||
profile: AgentProfile;
|
||
catalog: {
|
||
providers: SimpleProviderCatalogEntry[] | null;
|
||
loading: boolean;
|
||
error: string | null;
|
||
reload: () => void;
|
||
};
|
||
existing:
|
||
| AgentProfile["codexProvider"]
|
||
| AgentProfile["claudeProvider"]
|
||
| undefined;
|
||
customSupported: boolean;
|
||
saveProfile: (input: {
|
||
providerId: string;
|
||
model: string;
|
||
apiKey: string;
|
||
custom?: CodexCustomProviderConfig;
|
||
}) => Promise<AgentProfile>;
|
||
onChange: (p: AgentProfile) => void;
|
||
}
|
||
|
||
function ProviderModelSecretFields({
|
||
engine,
|
||
profile,
|
||
catalog,
|
||
existing,
|
||
customSupported,
|
||
saveProfile,
|
||
onChange,
|
||
}: ProviderModelSecretFieldsProps) {
|
||
const [mode, setMode] = useState<"catalog" | "custom">(
|
||
existing && "custom" in existing && existing.custom ? "custom" : "catalog",
|
||
);
|
||
const [providerId, setProviderId] = useState(existing?.providerId ?? "");
|
||
const [model, setModel] = useState(existing?.model ?? "");
|
||
const [providerFilter, setProviderFilter] = useState("");
|
||
const existingCustom =
|
||
existing && "custom" in existing ? existing.custom : undefined;
|
||
const [customBaseUrl, setCustomBaseUrl] = useState(existingCustom?.baseUrl ?? "");
|
||
const [customDisplayName, setCustomDisplayName] = useState(
|
||
existingCustom?.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 catalogReady = catalog.providers !== null && !catalog.loading;
|
||
const models =
|
||
catalog.providers?.find((p) => p.providerId === providerId)?.models ?? [];
|
||
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 = "Le provider est obligatoire.";
|
||
}
|
||
if (model.trim().length === 0) errors.model = "Le modèle est obligatoire.";
|
||
if (mode === "custom" && 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 saveProfile({
|
||
providerId: providerId.trim(),
|
||
model: model.trim(),
|
||
apiKey,
|
||
...(mode === "custom"
|
||
? {
|
||
custom: {
|
||
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 ({engine})
|
||
</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("");
|
||
} else {
|
||
setProviderId(v);
|
||
}
|
||
setModel("");
|
||
setFieldErrors((prev) => ({
|
||
...prev,
|
||
providerId: undefined,
|
||
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.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>
|
||
))}
|
||
{customSupported && (
|
||
<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" && customSupported && (
|
||
<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>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 provider (optionnel)</Caption>
|
||
<Input
|
||
aria-label={`${profile.name} custom display name`}
|
||
value={customDisplayName}
|
||
placeholder="ex. Mon provider"
|
||
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-…"
|
||
}
|
||
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">
|
||
Le profil conserve sa référence de secret existante ; la clé n'est
|
||
jamais réaffichée côté UI.
|
||
</small>
|
||
)}
|
||
</label>
|
||
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
aria-label={`Enregistrer ${engine}`}
|
||
loading={saving}
|
||
disabled={saveDisabled}
|
||
onClick={() => void save()}
|
||
className="w-fit"
|
||
>
|
||
Enregistrer
|
||
</Button>
|
||
</fieldset>
|
||
);
|
||
}
|
||
|
||
function CodexProviderFields({
|
||
profile,
|
||
catalog,
|
||
onChange,
|
||
}: {
|
||
profile: AgentProfile;
|
||
catalog: CodexProviderCatalog;
|
||
onChange: (p: AgentProfile) => void;
|
||
}) {
|
||
const { profile: profileGateway } = useGateways();
|
||
return (
|
||
<ProviderModelSecretFields
|
||
engine="Codex"
|
||
profile={profile}
|
||
catalog={catalog}
|
||
existing={profile.codexProvider}
|
||
customSupported={catalog.providers?.some((p) => p.customSupported) ?? false}
|
||
saveProfile={(input) =>
|
||
profileGateway.saveCodexProviderProfile({
|
||
profile,
|
||
providerId: input.providerId,
|
||
model: input.model,
|
||
apiKey: input.apiKey,
|
||
custom: input.custom,
|
||
})
|
||
}
|
||
onChange={onChange}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function ClaudeProviderFields({
|
||
profile,
|
||
catalog,
|
||
onChange,
|
||
}: {
|
||
profile: AgentProfile;
|
||
catalog: ClaudeProviderCatalog;
|
||
onChange: (p: AgentProfile) => void;
|
||
}) {
|
||
const { profile: profileGateway } = useGateways();
|
||
return (
|
||
<ProviderModelSecretFields
|
||
engine="Claude"
|
||
profile={profile}
|
||
catalog={catalog}
|
||
existing={profile.claudeProvider}
|
||
customSupported={false}
|
||
saveProfile={(input) =>
|
||
profileGateway.saveClaudeProviderProfile({
|
||
profile,
|
||
providerId: input.providerId,
|
||
model: input.model,
|
||
apiKey: input.apiKey,
|
||
})
|
||
}
|
||
onChange={onChange}
|
||
/>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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>
|
||
);
|
||
}
|