/** * `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 { useI18n } from "@/i18n"; 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}; } type Tr = (french: string, english: string) => string; function stopPolicies(tr: Tr): { value: StopPolicy; label: string }[] { return [ { value: "stopOnAppExit", label: tr("Arrêter à la fermeture de l’application", "Stop on app exit") }, { value: "keepAlive", label: tr("Garder actif", "Keep alive") }, { value: "stopWhenUnused", label: tr("Arrêter si inutilisé", "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, tr: Tr, ): string { const size = artifact.sizeBytes == null ? "" : `${tr("Espace libéré", "Space freed")}: ${formatBytes(artifact.sizeBytes)}.`; return [ tr("Supprimer le modèle téléchargé ?", "Delete downloaded model?"), `${tr("Serveur", "Server")}: ${server.name}`, tr("Le serveur local restera configuré, mais IdeA devra retélécharger ce modèle au prochain lancement.", "The local server will remain configured, but IdeA will have to download this model again on the next launch."), size, ] .filter(Boolean) .join("\n"); } export interface ModelServersPanelProps { /** The model-server registry view-model (from `useModelServers`). */ vm: ModelServersViewModel; } export function ModelServersPanel({ vm }: ModelServersPanelProps) { const { tr } = useI18n(); // 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, tr))) return; await vm.deleteArtifact(server.id); } return (

{tr("Serveurs de modèles locaux", "Local model servers")}

} >
{vm.error && (

{vm.error}

)} {vm.notice && (

{vm.notice}

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

{tr("Aucun serveur local déclaré. Ajoutez-en un pour gérer automatiquement un `llama-server`, ou continuez à utiliser un point d’accès externe.", "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 ? ` · ${tr("démarrage automatique", "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 { tr } = useI18n(); const tab = (value: ModelSourceKind, label: string) => { const active = kind === value; return ( ); }; return (
{tab("huggingFace", tr("Télécharger depuis Hugging Face", "Download from Hugging Face"))} {tab("localPath", tr("Fichier .gguf local", "Local .gguf file"))}
); } /** The read-only, backend-built command preview block. */ function CommandPreview({ preview, autoStart, }: { preview: ModelServerCommandPreview | null; autoStart: boolean; }) { const { tr } = useI18n(); const [copied, setCopied] = useState(false); const title = autoStart ? tr("Commande générée", "Generated command") : tr("Commande à lancer manuellement", "Command to run manually"); 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} ) : ( {tr("Renseignez une source de modèle pour voir la commande.", "Enter a model source to see the command.")} )}
); } /** 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 { tr } = useI18n(); 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 : tr("Nouveau serveur", "New server")} {/* --- Source du modèle --------------------------------------------- */}
{tr("Source du modèle", "Model source")}
{sourceKind === "huggingFace" ? ( ) : ( )} {/* --- Nom du modèle dans IdeA -------------------------------------- */} {/* --- Serveur ------------------------------------------------------ */}
{/* --- Réglages llama.cpp (avancé) --------------------------------- */}
{tr("Réglages llama.cpp", "llama.cpp settings")}
); }