/** * `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 {children}; } /** Muted helper line under a control. */ function Hint({ children }: { children: React.ReactNode }) { return {children}; } 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, ): 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(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, ) { if (!window.confirm(deleteArtifactConfirmation(server, artifact))) return; await vm.deleteArtifact(server.id); } return (

Local model servers

} >
{vm.error && (

{vm.error}

)} {vm.notice && (

{vm.notice}

)} {vm.servers.length === 0 && !draft && (

No local server declared yet. Add one to auto-manage a `llama-server`, or keep using an external endpoint.

)}
    {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 (
  • {server.name} {server.baseURL} · {server.servedModelName} {server.autoStart ? " · auto-start" : ""} {downloadingArtifact && ( )} {downloaded && ( )} void vm.remove(server.id)} disabled={vm.busy} > ×
  • ); })}
{draft && ( { vm.clearError(); setDraft(null); }} onSubmit={(nextDraft) => void submit(nextDraft)} /> )}
); } /** 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 ( ); }; return (
{tab("huggingFace", "Télécharger depuis Hugging Face")} {tab("localPath", "Fichier .gguf local")}
); } /** 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 (
{title}
{preview ? ( {preview.display} ) : ( Renseigne une source de modèle pour voir la commande. )}
); } /** The add/edit wizard for one server draft. */ function ServerEditor({ draft, busy, preview, onChange, onCancel, onSubmit, }: { draft: LocalModelServerConfig; busy: boolean; preview: ( config: LocalModelServerConfig, ) => Promise; 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) => 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( 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 = { 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(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 (
{draft.name.trim().length > 0 ? draft.name : "New server"} {/* --- Source du modèle --------------------------------------------- */}
Source du modèle
{sourceKind === "huggingFace" ? ( ) : ( )} {/* --- Nom du modèle dans IdeA -------------------------------------- */} {/* --- Serveur ------------------------------------------------------ */}
{/* --- Réglages llama.cpp (avancé) --------------------------------- */}
Réglages llama.cpp
); }