feat(model-server): wizard V2 config modèles locaux — source, options structurées & preview

Refonte UX du wizard de configuration des modèles locaux du serveur
llama.cpp intégré, alignée sur la source Hugging Face et les options backend.

- choix de source sans jargon (chemin local .gguf vs dépôt Hugging Face) ;
- champs structurés -ngl (gpu_layers), -c (context_size), --jinja, --host ;
- zone d'arguments libres avec alerte sur les flags réservés ;
- preview de la commande via le backend (previewModelServerCommand, debounced) ;
- gateway/ports et mock adaptés au contrat V2.

QA : Vitest 22 verts, tsc/build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 14:32:04 +02:00
parent 38aecf2cac
commit efa081b074
10 changed files with 794 additions and 143 deletions

View File

@ -1,24 +1,34 @@
/**
* `ModelServersPanel` (F35.2) — 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.
* `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.
*
* A single editable draft is shown at a time (add or edit); the list underneath
* offers Edit / Delete per server. The `model_server_in_use` rejection surfaces
* as the vm's error banner (a referenced server can't be removed).
* 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 { useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { LocalModelServerConfig, StopPolicy } from "@/domain";
import type {
LocalModelServerConfig,
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. */
@ -26,6 +36,11 @@ 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" },
@ -132,6 +147,7 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
<ServerEditor
draft={draft}
busy={vm.busy}
preview={vm.preview}
onChange={setDraft}
onCancel={() => {
vm.clearError();
@ -145,16 +161,114 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
);
}
/** The add/edit form for one server draft. */
/** 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: () => void;
@ -164,17 +278,80 @@ function ServerEditor({
const patch = (next: Partial<LocalModelServerConfig>) =>
onChange({ ...draft, ...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(draft.args);
return (
<fieldset
aria-label="model server editor"
className="flex flex-col gap-2 rounded-md border border-primary/40 p-3"
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>Name</Caption>
<Caption>Nom</Caption>
<Input
aria-label="server name"
value={draft.name}
@ -184,19 +361,88 @@ function ServerEditor({
{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>Base URL</Caption>
<Caption>Commande serveur</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 })}
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 });
}}
/>
{errors.baseURL && (
<small className="text-xs text-danger">{errors.baseURL}</small>
)}
<Hint>Laisse vide pour résoudre `llama-server` via le PATH.</Hint>
</label>
<label className="flex min-w-[6rem] flex-col gap-1">
@ -213,90 +459,151 @@ function ServerEditor({
</div>
<label className="flex flex-col gap-1">
<Caption>Served model name (sent to OpenCode as `model`)</Caption>
<Caption>Base URL (OpenAI-compatible)</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 })}
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.servedModelName && (
<small className="text-xs text-danger">{errors.servedModelName}</small>
{errors.baseURL && (
<small className="text-xs text-danger">{errors.baseURL}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Model path (absolute `.gguf` required for auto-start)</Caption>
<Input
aria-label="server model path"
value={draft.modelPath ?? ""}
placeholder="/models/qwen3-coder-30b.gguf"
invalid={Boolean(errors.modelPath)}
onChange={(e) => {
const v = e.target.value;
patch({ modelPath: v.length === 0 ? undefined : v });
}}
<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"
/>
{errors.modelPath && (
<small className="text-xs text-danger">{errors.modelPath}</small>
)}
<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>
<label className="flex flex-col gap-1">
<Caption>Binary path (optional `llama-server` command)</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 });
}}
/>
</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 flex-col gap-1">
<Caption>Extra arguments</Caption>
<Input
aria-label="server args"
value={draft.args.join(" ")}
placeholder="--ctx-size 8192 --n-gpu-layers 99"
onChange={(e) => patch({ args: parseArgs(e.target.value) })}
/>
</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>
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
aria-label="server auto start"
checked={draft.autoStart}
onChange={(e) => patch({ autoStart: e.target.checked })}
className="accent-primary"
/>
<Caption>Auto-start</Caption>
</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",
<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>
)}
>
{STOP_POLICIES.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
</label>
</div>
</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={draft.args.join(" ")}
placeholder="--flash-attn --parallel 2"
onChange={(e) => patch({ args: parseArgs(e.target.value) })}
/>
{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