Files
IdeA/frontend/src/features/model-servers/ModelServersPanel.tsx
Blomios dbaf6fe2f4 fix(model-servers): autorise les espaces dans le champ Arguments supplémentaires llama.cpp
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>
2026-08-01 00:12:07 +02:00

720 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* `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>
);
}