Le champ contrôlé de ModelServersPanel/FirstRunWizard perdait les espaces saisis dans les arguments llama.cpp (trim/split prématuré sur chaque frappe au lieu de la seule sérialisation finale) (#113). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
720 lines
23 KiB
TypeScript
720 lines
23 KiB
TypeScript
/**
|
||
* `ModelServersPanel` (F35, wizard V2) — declare / edit / delete the local
|
||
* `llama.cpp` servers an OpenCode profile can bind to. Presentational: all I/O
|
||
* state comes from a {@link ModelServersViewModel} passed in (`useModelServers`),
|
||
* so the panel renders and is testable in isolation.
|
||
*
|
||
* The editor is a plain-language wizard: a source segmented control (download
|
||
* from Hugging Face vs. a local `.gguf` file), a shared "model name in IdeA"
|
||
* field, the server settings, a collapsed advanced section for the raw
|
||
* llama.cpp knobs, and a read-only command preview built by the backend (never
|
||
* reconstructed here).
|
||
*/
|
||
|
||
import { useEffect, useRef, useState } from "react";
|
||
|
||
import type {
|
||
LocalModelServerConfig,
|
||
ModelArtifact,
|
||
ModelServerCommandPreview,
|
||
ModelSource,
|
||
StopPolicy,
|
||
} from "@/domain";
|
||
import { Button, IconButton, Input, Panel, cn } from "@/shared";
|
||
import type { ModelServersViewModel } from "./useModelServers";
|
||
import {
|
||
deriveServedModelName,
|
||
emptyModelServer,
|
||
parseArgs,
|
||
reservedFlagsIn,
|
||
validateModelServer,
|
||
type ModelServerErrors,
|
||
type ModelSourceKind,
|
||
} from "./modelServer";
|
||
|
||
/** Small caption above a control. */
|
||
function Caption({ children }: { children: React.ReactNode }) {
|
||
return <span className="text-xs font-medium text-muted">{children}</span>;
|
||
}
|
||
|
||
/** Muted helper line under a control. */
|
||
function Hint({ children }: { children: React.ReactNode }) {
|
||
return <small className="text-xs text-muted">{children}</small>;
|
||
}
|
||
|
||
const STOP_POLICIES: { value: StopPolicy; label: string }[] = [
|
||
{ value: "stopOnAppExit", label: "Stop on app exit" },
|
||
{ value: "keepAlive", label: "Keep alive" },
|
||
{ value: "stopWhenUnused", label: "Stop when unused" },
|
||
];
|
||
|
||
function formatBytes(bytes: number): string {
|
||
if (!Number.isFinite(bytes) || bytes < 0) return "";
|
||
const units = ["B", "KB", "MB", "GB", "TB"] as const;
|
||
let value = bytes;
|
||
let unit = 0;
|
||
while (value >= 1000 && unit < units.length - 1) {
|
||
value /= 1000;
|
||
unit += 1;
|
||
}
|
||
const digits = unit === 0 || value >= 10 ? 0 : 1;
|
||
return `${value.toFixed(digits)} ${units[unit]}`;
|
||
}
|
||
|
||
function deleteArtifactConfirmation(
|
||
server: LocalModelServerConfig,
|
||
artifact: Extract<ModelArtifact, { state: "downloaded" }>,
|
||
): string {
|
||
const size =
|
||
artifact.sizeBytes == null ? "" : `Espace libéré : ${formatBytes(artifact.sizeBytes)}.`;
|
||
return [
|
||
"Supprimer le modèle téléchargé ?",
|
||
`Serveur : ${server.name}`,
|
||
"Le serveur local restera configuré, mais IdeA devra retélécharger ce modèle au prochain lancement.",
|
||
size,
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
}
|
||
|
||
export interface ModelServersPanelProps {
|
||
/** The model-server registry view-model (from `useModelServers`). */
|
||
vm: ModelServersViewModel;
|
||
}
|
||
|
||
export function ModelServersPanel({ vm }: ModelServersPanelProps) {
|
||
// The server being edited, or a fresh draft when "Add" is clicked. `null` ⇒
|
||
// no editor open (list-only).
|
||
const [draft, setDraft] = useState<LocalModelServerConfig | null>(null);
|
||
|
||
function startAdd() {
|
||
vm.clearError();
|
||
setDraft(emptyModelServer());
|
||
}
|
||
|
||
function startEdit(server: LocalModelServerConfig) {
|
||
vm.clearError();
|
||
setDraft({ ...server });
|
||
}
|
||
|
||
async function submit(nextDraft?: LocalModelServerConfig) {
|
||
const submitted = nextDraft ?? draft;
|
||
if (!submitted) return;
|
||
const saved = await vm.save(submitted);
|
||
if (saved) setDraft(null);
|
||
}
|
||
|
||
async function confirmDeleteArtifact(
|
||
server: LocalModelServerConfig,
|
||
artifact: Extract<ModelArtifact, { state: "downloaded" }>,
|
||
) {
|
||
if (!window.confirm(deleteArtifactConfirmation(server, artifact))) return;
|
||
await vm.deleteArtifact(server.id);
|
||
}
|
||
|
||
return (
|
||
<Panel
|
||
aria-label="local model servers"
|
||
title={
|
||
<div className="flex items-center gap-2">
|
||
<h3 className="text-sm font-semibold text-content">
|
||
Local model servers
|
||
</h3>
|
||
<Button
|
||
size="sm"
|
||
aria-label="add model server"
|
||
onClick={startAdd}
|
||
className="ml-auto"
|
||
>
|
||
Add server
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="flex flex-col gap-3">
|
||
{vm.error && (
|
||
<p role="alert" className="text-sm text-danger">
|
||
{vm.error}
|
||
</p>
|
||
)}
|
||
{vm.notice && (
|
||
<p role="status" className="text-sm text-muted">
|
||
{vm.notice}
|
||
</p>
|
||
)}
|
||
|
||
{vm.servers.length === 0 && !draft && (
|
||
<p className="text-xs text-muted">
|
||
No local server declared yet. Add one to auto-manage a `llama-server`,
|
||
or keep using an external endpoint.
|
||
</p>
|
||
)}
|
||
|
||
<ul className="flex list-none flex-col gap-2 p-0">
|
||
{vm.servers.map((server) => {
|
||
const artifact = server.artifact;
|
||
const downloaded =
|
||
artifact?.state === "downloaded" ? artifact : undefined;
|
||
const deletingArtifact = vm.deletingArtifactId === server.id;
|
||
const downloadingArtifact = artifact?.state === "downloading";
|
||
return (
|
||
<li
|
||
key={server.id}
|
||
className="flex items-center gap-2 rounded-md border border-border bg-raised p-2"
|
||
>
|
||
<span className="flex min-w-0 flex-col">
|
||
<strong className="truncate text-sm text-content">
|
||
{server.name}
|
||
</strong>
|
||
<span className="truncate text-xs text-muted">
|
||
{server.baseURL} · {server.servedModelName}
|
||
{server.autoStart ? " · auto-start" : ""}
|
||
</span>
|
||
</span>
|
||
<span className="ml-auto flex items-center gap-1">
|
||
{downloadingArtifact && (
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
aria-label={`download in progress ${server.name}`}
|
||
disabled
|
||
>
|
||
Téléchargement en cours
|
||
</Button>
|
||
)}
|
||
{downloaded && (
|
||
<Button
|
||
size="sm"
|
||
variant="danger"
|
||
aria-label={`delete downloaded model ${server.name}`}
|
||
onClick={() => void confirmDeleteArtifact(server, downloaded)}
|
||
loading={deletingArtifact}
|
||
disabled={vm.busy && !deletingArtifact}
|
||
>
|
||
{deletingArtifact ? "Suppression..." : "Supprimer le modèle téléchargé"}
|
||
</Button>
|
||
)}
|
||
<Button
|
||
size="sm"
|
||
aria-label={`edit ${server.name}`}
|
||
onClick={() => startEdit(server)}
|
||
disabled={vm.busy}
|
||
>
|
||
Edit
|
||
</Button>
|
||
<IconButton
|
||
size="sm"
|
||
aria-label={`delete ${server.name}`}
|
||
onClick={() => void vm.remove(server.id)}
|
||
disabled={vm.busy}
|
||
>
|
||
×
|
||
</IconButton>
|
||
</span>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
|
||
{draft && (
|
||
<ServerEditor
|
||
draft={draft}
|
||
busy={vm.busy}
|
||
preview={vm.preview}
|
||
onChange={setDraft}
|
||
onCancel={() => {
|
||
vm.clearError();
|
||
setDraft(null);
|
||
}}
|
||
onSubmit={(nextDraft) => void submit(nextDraft)}
|
||
/>
|
||
)}
|
||
</div>
|
||
</Panel>
|
||
);
|
||
}
|
||
|
||
/** Segmented control choosing the model source (no jargon in the labels). */
|
||
function SourceTabs({
|
||
kind,
|
||
onSelect,
|
||
}: {
|
||
kind: ModelSourceKind;
|
||
onSelect: (kind: ModelSourceKind) => void;
|
||
}) {
|
||
const tab = (value: ModelSourceKind, label: string) => {
|
||
const active = kind === value;
|
||
return (
|
||
<button
|
||
type="button"
|
||
aria-pressed={active}
|
||
onClick={() => onSelect(value)}
|
||
className={cn(
|
||
"h-8 flex-1 rounded-md px-3 text-xs font-medium transition-colors",
|
||
active
|
||
? "bg-primary text-white"
|
||
: "bg-raised text-muted hover:text-content",
|
||
)}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
};
|
||
return (
|
||
<div
|
||
role="group"
|
||
aria-label="source du modèle"
|
||
className="flex gap-1 rounded-md border border-border p-1"
|
||
>
|
||
{tab("huggingFace", "Télécharger depuis Hugging Face")}
|
||
{tab("localPath", "Fichier .gguf local")}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** The read-only, backend-built command preview block. */
|
||
function CommandPreview({
|
||
preview,
|
||
autoStart,
|
||
}: {
|
||
preview: ModelServerCommandPreview | null;
|
||
autoStart: boolean;
|
||
}) {
|
||
const [copied, setCopied] = useState(false);
|
||
const title = autoStart ? "Commande générée" : "Commande à lancer manuellement";
|
||
|
||
async function copy() {
|
||
if (!preview) return;
|
||
try {
|
||
await navigator.clipboard.writeText(preview.display);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 1500);
|
||
} catch {
|
||
// Clipboard denied — nothing actionable, the text stays selectable.
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section
|
||
aria-label="command preview"
|
||
className="flex flex-col gap-1 rounded-md border border-border bg-raised p-2"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<Caption>{title}</Caption>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
aria-label="copier la commande"
|
||
onClick={() => void copy()}
|
||
disabled={!preview}
|
||
className="ml-auto"
|
||
>
|
||
{copied ? "Copié" : "Copier"}
|
||
</Button>
|
||
</div>
|
||
{preview ? (
|
||
<code
|
||
aria-label="preview command line"
|
||
className="whitespace-pre-wrap break-all font-mono text-xs text-content"
|
||
>
|
||
{preview.display}
|
||
</code>
|
||
) : (
|
||
<span className="text-xs text-muted">
|
||
Renseigne une source de modèle pour voir la commande.
|
||
</span>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** The add/edit wizard for one server draft. */
|
||
function ServerEditor({
|
||
draft,
|
||
busy,
|
||
preview,
|
||
onChange,
|
||
onCancel,
|
||
onSubmit,
|
||
}: {
|
||
draft: LocalModelServerConfig;
|
||
busy: boolean;
|
||
preview: (
|
||
config: LocalModelServerConfig,
|
||
) => Promise<ModelServerCommandPreview | null>;
|
||
onChange: (next: LocalModelServerConfig) => void;
|
||
onCancel: () => void;
|
||
onSubmit: (nextDraft?: LocalModelServerConfig) => void;
|
||
}) {
|
||
const errors: ModelServerErrors = validateModelServer(draft);
|
||
const valid = Object.keys(errors).length === 0;
|
||
const patch = (next: Partial<LocalModelServerConfig>) =>
|
||
onChange({ ...draft, ...next });
|
||
const canonicalArgsText = draft.args.join(" ");
|
||
const [argsText, setArgsText] = useState(canonicalArgsText);
|
||
|
||
useEffect(() => {
|
||
setArgsText(canonicalArgsText);
|
||
}, [canonicalArgsText, draft.id]);
|
||
|
||
function draftWithCommittedArgs(): LocalModelServerConfig {
|
||
return { ...draft, args: parseArgs(argsText) };
|
||
}
|
||
|
||
function commitArgs() {
|
||
const next = draftWithCommittedArgs();
|
||
onChange(next);
|
||
}
|
||
|
||
// Sticky source tab: keep the chosen kind even when the field is momentarily
|
||
// blank (an empty field clears `modelSource`, which must not snap the tab).
|
||
const [sourceKind, setSourceKind] = useState<ModelSourceKind>(
|
||
draft.modelSource?.type ?? "huggingFace",
|
||
);
|
||
|
||
const hfRepo =
|
||
draft.modelSource?.type === "huggingFace" ? draft.modelSource.repo : "";
|
||
const localPath =
|
||
draft.modelSource?.type === "localPath" ? draft.modelSource.path : "";
|
||
|
||
// Apply a new source and pre-fill the served name if the user hasn't set one.
|
||
function setSource(source: ModelSource | undefined) {
|
||
const next: Partial<LocalModelServerConfig> = { modelSource: source };
|
||
if (draft.servedModelName.trim().length === 0) {
|
||
const derived = deriveServedModelName(source);
|
||
if (derived.length > 0) next.servedModelName = derived;
|
||
}
|
||
patch(next);
|
||
}
|
||
|
||
function selectKind(kind: ModelSourceKind) {
|
||
if (kind === sourceKind) return;
|
||
setSourceKind(kind);
|
||
// Switching tabs starts the new source empty (⇒ cleared) rather than
|
||
// carrying the previous value into an incompatible field.
|
||
patch({ modelSource: undefined });
|
||
}
|
||
|
||
// Debounced backend preview. The backend is the sole authority on the argv.
|
||
const [cmd, setCmd] = useState<ModelServerCommandPreview | null>(null);
|
||
const draftKey = JSON.stringify({
|
||
modelSource: draft.modelSource,
|
||
port: draft.port,
|
||
host: draft.host,
|
||
gpuLayers: draft.gpuLayers,
|
||
contextSize: draft.contextSize,
|
||
jinja: draft.jinja,
|
||
binaryPath: draft.binaryPath,
|
||
args: draft.args,
|
||
});
|
||
const latest = useRef(draft);
|
||
latest.current = draft;
|
||
useEffect(() => {
|
||
if (!draft.modelSource) {
|
||
setCmd(null);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
const handle = setTimeout(() => {
|
||
void preview(latest.current).then((result) => {
|
||
if (!cancelled) setCmd(result);
|
||
});
|
||
}, 250);
|
||
return () => {
|
||
cancelled = true;
|
||
clearTimeout(handle);
|
||
};
|
||
// `draftKey` captures the argv-affecting fields; `preview` is stable.
|
||
}, [draftKey, preview]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const reservedHits = reservedFlagsIn(parseArgs(argsText));
|
||
|
||
return (
|
||
<fieldset
|
||
aria-label="model server editor"
|
||
className="flex flex-col gap-3 rounded-md border border-primary/40 p-3"
|
||
>
|
||
<legend className="px-1 text-xs font-medium text-muted">
|
||
{draft.name.trim().length > 0 ? draft.name : "New server"}
|
||
</legend>
|
||
|
||
<label className="flex flex-col gap-1">
|
||
<Caption>Nom</Caption>
|
||
<Input
|
||
aria-label="server name"
|
||
value={draft.name}
|
||
invalid={Boolean(errors.name)}
|
||
onChange={(e) => patch({ name: e.target.value })}
|
||
/>
|
||
{errors.name && <small className="text-xs text-danger">{errors.name}</small>}
|
||
</label>
|
||
|
||
{/* --- Source du modèle --------------------------------------------- */}
|
||
<div className="flex flex-col gap-1">
|
||
<Caption>Source du modèle</Caption>
|
||
<SourceTabs kind={sourceKind} onSelect={selectKind} />
|
||
</div>
|
||
|
||
{sourceKind === "huggingFace" ? (
|
||
<label className="flex flex-col gap-1">
|
||
<Caption>Modèle Hugging Face</Caption>
|
||
<Input
|
||
aria-label="hugging face model"
|
||
value={hfRepo}
|
||
placeholder="unsloth/Qwen3.5-9B-GGUF:Q4_K_M"
|
||
invalid={Boolean(errors.modelSource)}
|
||
onChange={(e) => {
|
||
const v = e.target.value;
|
||
setSource(
|
||
v.trim().length === 0
|
||
? undefined
|
||
: { type: "huggingFace", repo: v },
|
||
);
|
||
}}
|
||
/>
|
||
<Hint>llama.cpp téléchargera/cache le fichier automatiquement.</Hint>
|
||
{errors.modelSource && (
|
||
<small className="text-xs text-danger">{errors.modelSource}</small>
|
||
)}
|
||
</label>
|
||
) : (
|
||
<label className="flex flex-col gap-1">
|
||
<Caption>Fichier .gguf</Caption>
|
||
<Input
|
||
aria-label="local gguf file"
|
||
value={localPath}
|
||
placeholder="/home/me/models/qwen.gguf"
|
||
invalid={Boolean(errors.modelSource)}
|
||
onChange={(e) => {
|
||
const v = e.target.value;
|
||
setSource(
|
||
v.trim().length === 0
|
||
? undefined
|
||
: { type: "localPath", path: v },
|
||
);
|
||
}}
|
||
/>
|
||
<Hint>Chemin absolu vers un .gguf déjà présent.</Hint>
|
||
{errors.modelSource && (
|
||
<small className="text-xs text-danger">{errors.modelSource}</small>
|
||
)}
|
||
</label>
|
||
)}
|
||
|
||
{/* --- Nom du modèle dans IdeA -------------------------------------- */}
|
||
<label className="flex flex-col gap-1">
|
||
<Caption>Nom du modèle dans IdeA</Caption>
|
||
<Input
|
||
aria-label="server served model name"
|
||
value={draft.servedModelName}
|
||
placeholder="qwen3-coder-30b"
|
||
invalid={Boolean(errors.servedModelName)}
|
||
onChange={(e) => patch({ servedModelName: e.target.value })}
|
||
/>
|
||
<Hint>Nom envoyé aux clients OpenAI-compatible.</Hint>
|
||
{errors.servedModelName && (
|
||
<small className="text-xs text-danger">{errors.servedModelName}</small>
|
||
)}
|
||
</label>
|
||
|
||
{/* --- Serveur ------------------------------------------------------ */}
|
||
<div className="flex flex-wrap gap-2">
|
||
<label className="flex min-w-[12rem] flex-1 flex-col gap-1">
|
||
<Caption>Commande serveur</Caption>
|
||
<Input
|
||
aria-label="server binary path"
|
||
value={draft.binaryPath ?? ""}
|
||
placeholder="llama-server"
|
||
onChange={(e) => {
|
||
const v = e.target.value;
|
||
patch({ binaryPath: v.length === 0 ? undefined : v });
|
||
}}
|
||
/>
|
||
<Hint>Laisse vide pour résoudre `llama-server` via le PATH.</Hint>
|
||
</label>
|
||
|
||
<label className="flex min-w-[6rem] flex-col gap-1">
|
||
<Caption>Port</Caption>
|
||
<Input
|
||
aria-label="server port"
|
||
value={Number.isNaN(draft.port) ? "" : draft.port.toString()}
|
||
inputMode="numeric"
|
||
invalid={Boolean(errors.port)}
|
||
onChange={(e) => patch({ port: Number.parseInt(e.target.value, 10) })}
|
||
/>
|
||
{errors.port && <small className="text-xs text-danger">{errors.port}</small>}
|
||
</label>
|
||
</div>
|
||
|
||
<label className="flex flex-col gap-1">
|
||
<Caption>Base URL (OpenAI-compatible)</Caption>
|
||
<Input
|
||
aria-label="server base url"
|
||
value={draft.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 items-start gap-2">
|
||
<input
|
||
type="checkbox"
|
||
aria-label="server auto start"
|
||
checked={draft.autoStart}
|
||
disabled={!draft.modelSource}
|
||
onChange={(e) => patch({ autoStart: e.target.checked })}
|
||
className="mt-1 accent-primary disabled:opacity-50"
|
||
/>
|
||
<span className="flex flex-col">
|
||
<Caption>Démarrer automatiquement</Caption>
|
||
{!draft.modelSource && (
|
||
<Hint>Renseigne d'abord une source de modèle pour l'activer.</Hint>
|
||
)}
|
||
</span>
|
||
</label>
|
||
|
||
{/* --- Réglages llama.cpp (avancé) --------------------------------- */}
|
||
<details className="rounded-md border border-border p-2">
|
||
<summary className="cursor-pointer text-xs font-medium text-muted">
|
||
Réglages llama.cpp
|
||
</summary>
|
||
<div className="mt-2 flex flex-col gap-2">
|
||
<div className="flex flex-wrap gap-2">
|
||
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||
<Caption>
|
||
Couches GPU <span className="text-muted">(-ngl)</span>
|
||
</Caption>
|
||
<Input
|
||
aria-label="gpu layers"
|
||
value={draft.gpuLayers === undefined ? "" : String(draft.gpuLayers)}
|
||
inputMode="numeric"
|
||
placeholder="99"
|
||
onChange={(e) => {
|
||
const v = e.target.value.trim();
|
||
patch({
|
||
gpuLayers: v.length === 0 ? undefined : Number.parseInt(v, 10),
|
||
});
|
||
}}
|
||
/>
|
||
<Hint>0 = CPU seul.</Hint>
|
||
</label>
|
||
|
||
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||
<Caption>
|
||
Taille de contexte <span className="text-muted">(-c)</span>
|
||
</Caption>
|
||
<Input
|
||
aria-label="context size"
|
||
value={
|
||
draft.contextSize === undefined ? "" : String(draft.contextSize)
|
||
}
|
||
inputMode="numeric"
|
||
placeholder="16384"
|
||
onChange={(e) => {
|
||
const v = e.target.value.trim();
|
||
patch({
|
||
contextSize:
|
||
v.length === 0 ? undefined : Number.parseInt(v, 10),
|
||
});
|
||
}}
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<label className="flex flex-col gap-1">
|
||
<Caption>
|
||
Adresse d'écoute <span className="text-muted">(--host)</span>
|
||
</Caption>
|
||
<Input
|
||
aria-label="listen host"
|
||
value={draft.host}
|
||
placeholder="127.0.0.1"
|
||
invalid={Boolean(errors.host)}
|
||
onChange={(e) => patch({ host: e.target.value })}
|
||
/>
|
||
{errors.host && (
|
||
<small className="text-xs text-danger">{errors.host}</small>
|
||
)}
|
||
</label>
|
||
|
||
<label className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
aria-label="jinja template"
|
||
checked={draft.jinja}
|
||
onChange={(e) => patch({ jinja: e.target.checked })}
|
||
className="accent-primary"
|
||
/>
|
||
<Caption>
|
||
Template chat Jinja <span className="text-muted">(--jinja)</span>
|
||
</Caption>
|
||
</label>
|
||
|
||
<label className="flex flex-col gap-1">
|
||
<Caption>Arguments supplémentaires</Caption>
|
||
<Input
|
||
aria-label="extra arguments"
|
||
value={argsText}
|
||
placeholder="--flash-attn --parallel 2"
|
||
onChange={(e) => setArgsText(e.target.value)}
|
||
onBlur={commitArgs}
|
||
/>
|
||
{reservedHits.length > 0 && (
|
||
<small className="text-xs text-warning" role="status">
|
||
{reservedHits.join(", ")} : cette option est déjà pilotée par un
|
||
champ ci-dessus.
|
||
</small>
|
||
)}
|
||
</label>
|
||
|
||
<label className="flex items-center gap-2">
|
||
<Caption>Stop policy</Caption>
|
||
<select
|
||
aria-label="server stop policy"
|
||
value={draft.stopPolicy}
|
||
onChange={(e) => patch({ stopPolicy: e.target.value as StopPolicy })}
|
||
className={cn(
|
||
"h-8 rounded-md bg-raised px-2 text-xs text-content",
|
||
"border border-border outline-none focus:border-primary",
|
||
)}
|
||
>
|
||
{STOP_POLICIES.map((p) => (
|
||
<option key={p.value} value={p.value}>
|
||
{p.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</details>
|
||
|
||
<CommandPreview preview={cmd} autoStart={draft.autoStart} />
|
||
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
aria-label="save model server"
|
||
onClick={() => onSubmit(draftWithCommittedArgs())}
|
||
disabled={busy || !valid}
|
||
>
|
||
Save server
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
aria-label="cancel model server"
|
||
onClick={onCancel}
|
||
disabled={busy}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
</div>
|
||
</fieldset>
|
||
);
|
||
}
|