fix: correction de régression UI wizard first-run
This commit is contained in:
629
frontend/src/features/first-run/OpenCodeModeFields.tsx
Normal file
629
frontend/src/features/first-run/OpenCodeModeFields.tsx
Normal file
@ -0,0 +1,629 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type {
|
||||
AgentProfile,
|
||||
GatewayError,
|
||||
LocalModelServerConfig,
|
||||
OpenCodeConfig,
|
||||
OpenCodeProviderCatalogEntry,
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { Button, IconButton, Input, cn } from "@/shared";
|
||||
import { ModelServerSelect } from "@/features/model-servers";
|
||||
import {
|
||||
defaultOpenCodeConfig,
|
||||
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). */
|
||||
export interface OpenCodeProviderCatalog {
|
||||
providers: OpenCodeProviderCatalogEntry[] | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the static OpenCode cloud-provider catalogue once for the whole form
|
||||
* surface, so Cloud OpenCode rows share the same provider/model options.
|
||||
*/
|
||||
export 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() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Segmented control choosing whether an OpenCode profile runs against the local
|
||||
* `llama.cpp` endpoint or a cloud provider from the OpenCode registry.
|
||||
*/
|
||||
export 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",
|
||||
);
|
||||
|
||||
function selectMode(next: "local" | "cloud") {
|
||||
setMode(next);
|
||||
if (next === "local" && profile.opencodeProvider) {
|
||||
onChange({
|
||||
...profile,
|
||||
opencode: profile.opencode ?? defaultOpenCodeConfig(),
|
||||
opencodeProvider: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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={() => selectMode(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";
|
||||
|
||||
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) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
opencodeProvider: undefined,
|
||||
});
|
||||
|
||||
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`}
|
||||
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`}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user