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:
@ -24,6 +24,7 @@ import type {
|
||||
LayoutOperation,
|
||||
LayoutTree,
|
||||
LocalModelServerConfig,
|
||||
ModelServerCommandPreview,
|
||||
Memory,
|
||||
MemoryIndexEntry,
|
||||
MemoryLink,
|
||||
@ -1284,6 +1285,44 @@ export class MockModelServerGateway implements ModelServerGateway {
|
||||
this.servers = this.servers.filter((s) => s.id !== serverId);
|
||||
}
|
||||
|
||||
async previewModelServerCommand(
|
||||
config: LocalModelServerConfig,
|
||||
): Promise<ModelServerCommandPreview> {
|
||||
// Mirror of the backend `LlamaCppRuntime::build_argv` (order matters), kept
|
||||
// deliberately simple: it exists so the UI preview flow is testable, never
|
||||
// as the source of truth (production uses the real backend command).
|
||||
if (!config.modelSource) {
|
||||
const err: GatewayError = {
|
||||
code: "INVALID",
|
||||
message: "model.source missing",
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
const command =
|
||||
config.binaryPath && config.binaryPath.trim().length > 0
|
||||
? config.binaryPath
|
||||
: "llama-server";
|
||||
const args: string[] = [];
|
||||
if (config.modelSource.type === "localPath") {
|
||||
args.push("--model", config.modelSource.path);
|
||||
} else {
|
||||
args.push("-hf", config.modelSource.repo);
|
||||
}
|
||||
args.push("--port", String(config.port), "--host", config.host);
|
||||
if (config.gpuLayers !== undefined) {
|
||||
args.push("-ngl", String(config.gpuLayers));
|
||||
}
|
||||
if (config.contextSize !== undefined) {
|
||||
args.push("-c", String(config.contextSize));
|
||||
}
|
||||
if (config.jinja) args.push("--jinja");
|
||||
args.push(...config.args);
|
||||
const display = [command, ...args]
|
||||
.map((part) => (/\s/.test(part) ? `'${part}'` : part))
|
||||
.join(" ");
|
||||
return { command, args, display };
|
||||
}
|
||||
|
||||
/** Test hook: mark a server id as still referenced (delete then rejects). */
|
||||
markInUse(serverId: string): void {
|
||||
this.inUse.add(serverId);
|
||||
|
||||
@ -9,7 +9,10 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { LocalModelServerConfig } from "@/domain";
|
||||
import type {
|
||||
LocalModelServerConfig,
|
||||
ModelServerCommandPreview,
|
||||
} from "@/domain";
|
||||
import type { ModelServerGateway } from "@/ports";
|
||||
|
||||
export class TauriModelServerGateway implements ModelServerGateway {
|
||||
@ -28,4 +31,14 @@ export class TauriModelServerGateway implements ModelServerGateway {
|
||||
async deleteModelServer(serverId: string): Promise<void> {
|
||||
await invoke("delete_model_server", { serverId });
|
||||
}
|
||||
|
||||
previewModelServerCommand(
|
||||
config: LocalModelServerConfig,
|
||||
): Promise<ModelServerCommandPreview> {
|
||||
// `preview_model_server_command` takes the config directly (no `request`
|
||||
// wrapper), unlike `save_model_server`.
|
||||
return invoke<ModelServerCommandPreview>("preview_model_server_command", {
|
||||
config,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,6 +39,17 @@ export type ModelServerStatus =
|
||||
*/
|
||||
export type StopPolicy = "keepAlive" | "stopOnAppExit" | "stopWhenUnused";
|
||||
|
||||
/**
|
||||
* Where the served `.gguf` model comes from (F35 V2, mirror of the backend
|
||||
* `ModelSourceDto`, camelCase wire, tagged on `type`):
|
||||
* - `localPath`: an absolute `.gguf` path already present on disk (`--model`),
|
||||
* - `huggingFace`: a `namespace/repo[:quant]` reference llama.cpp downloads and
|
||||
* caches automatically (`-hf`).
|
||||
*/
|
||||
export type ModelSource =
|
||||
| { type: "localPath"; path: string }
|
||||
| { type: "huggingFace"; repo: string };
|
||||
|
||||
/**
|
||||
* A declared local model server (F35, mirror of the backend flat
|
||||
* `LocalModelServerConfigDto`, camelCase wire). Global to IdeA (not project
|
||||
@ -50,7 +61,8 @@ export type StopPolicy = "keepAlive" | "stopOnAppExit" | "stopWhenUnused";
|
||||
* UUID; it does not generate the server id, only an internal model id it hides).
|
||||
* - `servedModelName` is what the user configures and what is sent to OpenCode as
|
||||
* the `model` — the backend's internal `model.id`/`model.label` are not exposed.
|
||||
* - `modelPath` is required by the backend when `autoStart` is `true`.
|
||||
* - `modelSource` (V2) replaces the former `modelPath`; it is required by the
|
||||
* backend when `autoStart` is `true`.
|
||||
*/
|
||||
export interface LocalModelServerConfig {
|
||||
id: string;
|
||||
@ -60,17 +72,39 @@ export interface LocalModelServerConfig {
|
||||
/** OpenAI-compatible base URL served by `llama-server` (normalised to `/v1`). */
|
||||
baseURL: string;
|
||||
port: number;
|
||||
/** Absolute `.gguf` path. Required when `autoStart` is `true`. */
|
||||
modelPath?: string;
|
||||
/** Explicit model source (`.gguf` path or Hugging Face ref). Required for auto-start. */
|
||||
modelSource?: ModelSource;
|
||||
/** Model name exposed by the server and sent to OpenCode as `model`. */
|
||||
servedModelName: string;
|
||||
/** Explicit `llama-server` executable path/command (optional). */
|
||||
binaryPath?: string;
|
||||
/** llama.cpp `--host` (default `127.0.0.1`). */
|
||||
host: string;
|
||||
/** llama.cpp `-ngl` GPU layers (optional; `0` = CPU only). */
|
||||
gpuLayers?: number;
|
||||
/** llama.cpp `-c` context size (optional). */
|
||||
contextSize?: number;
|
||||
/** Whether to pass `--jinja` (Jinja chat template). */
|
||||
jinja: boolean;
|
||||
args: string[];
|
||||
autoStart: boolean;
|
||||
stopPolicy: StopPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
* A previewed `llama-server` command line (F35 V2, mirror of the backend
|
||||
* `PreviewModelServerCommandDto`). Built by the backend from a draft config —
|
||||
* never reconstructed client-side.
|
||||
*/
|
||||
export interface ModelServerCommandPreview {
|
||||
/** Resolved executable (e.g. `llama-server` or an absolute path). */
|
||||
command: string;
|
||||
/** Argument vector, unescaped. */
|
||||
args: string[];
|
||||
/** Human-readable, shell-escaped command line. */
|
||||
display: string;
|
||||
}
|
||||
|
||||
/** A domain event relayed from the backend (tagged union on `type`). */
|
||||
export type DomainEvent =
|
||||
| { type: "projectCreated"; projectId: string }
|
||||
|
||||
@ -425,6 +425,8 @@ describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
|
||||
baseURL: "http://localhost:8080/v1",
|
||||
port: 8080,
|
||||
servedModelName: "qwen3-coder-30b",
|
||||
host: "127.0.0.1",
|
||||
jinja: false,
|
||||
args: [],
|
||||
autoStart: false,
|
||||
stopPolicy: "stopOnAppExit",
|
||||
|
||||
@ -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,69 +459,127 @@ 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 });
|
||||
}}
|
||||
/>
|
||||
{errors.modelPath && (
|
||||
<small className="text-xs text-danger">{errors.modelPath}</small>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<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>Auto-start</Caption>
|
||||
<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">
|
||||
@ -297,6 +601,9 @@ function ServerEditor({
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<CommandPreview preview={cmd} autoStart={draft.autoStart} />
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
|
||||
@ -14,6 +14,11 @@ export {
|
||||
validateModelServer,
|
||||
isModelServerValid,
|
||||
isAbsolutePath,
|
||||
isValidHfRef,
|
||||
parseArgs,
|
||||
deriveServedModelName,
|
||||
reservedFlagsIn,
|
||||
RESERVED_LLAMACPP_FLAGS,
|
||||
type ModelServerErrors,
|
||||
type ModelSourceKind,
|
||||
} from "./modelServer";
|
||||
|
||||
@ -1,20 +1,24 @@
|
||||
/**
|
||||
* Pure, framework-free helpers for the local model server feature (F35). Draft
|
||||
* validation mirrors the backend invariants (`ModelServerEndpoint::new`,
|
||||
* `LocalModelRef::new`, `LocalModelServerConfig::new`) so the form can surface
|
||||
* Pure, framework-free helpers for the local model server feature (F35, V2 with
|
||||
* the Hugging Face source). Draft validation mirrors the backend invariants
|
||||
* (`ModelServerEndpoint::new`, `ModelPath::new`, `HfModelRef::new`,
|
||||
* `LlamaCppOptions::new`, `LocalModelServerConfig::new`) so the form can surface
|
||||
* errors before any `invoke`. No React, no Tauri here.
|
||||
*/
|
||||
|
||||
import type { LocalModelServerConfig } from "@/domain";
|
||||
import type { LocalModelServerConfig, ModelSource } from "@/domain";
|
||||
|
||||
/** A field-keyed validation error map (empty ⇒ valid). */
|
||||
export type ModelServerErrors = Partial<
|
||||
Record<
|
||||
"name" | "baseURL" | "port" | "servedModelName" | "modelPath",
|
||||
"name" | "baseURL" | "port" | "servedModelName" | "modelSource" | "host",
|
||||
string
|
||||
>
|
||||
>;
|
||||
|
||||
/** Which source tab is active in the editor (kept even when the field is blank). */
|
||||
export type ModelSourceKind = "huggingFace" | "localPath";
|
||||
|
||||
/** Whether a string is a valid `http://`/`https://` URL (mirror of backend). */
|
||||
function isValidHttpUrl(v: string): boolean {
|
||||
if (v.trim().length === 0) return false;
|
||||
@ -31,6 +35,23 @@ export function isAbsolutePath(path: string): boolean {
|
||||
return /^[a-zA-Z]:[/\\]/.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a Hugging Face reference is well-formed (mirror of the backend
|
||||
* `HfModelRef::new`): `namespace/repo` or `namespace/repo:quant`, no spaces, not
|
||||
* an URL, at most one `:`. Blank is handled by the caller.
|
||||
*/
|
||||
export function isValidHfRef(repo: string): boolean {
|
||||
if (/\s/.test(repo)) return false;
|
||||
if (repo.startsWith("http://") || repo.startsWith("https://")) return false;
|
||||
if ((repo.match(/:/g) ?? []).length > 1) return false;
|
||||
const [base, quant] = repo.includes(":")
|
||||
? [repo.slice(0, repo.indexOf(":")), repo.slice(repo.indexOf(":") + 1)]
|
||||
: [repo, undefined];
|
||||
if (quant !== undefined && quant.length === 0) return false;
|
||||
const slash = base.split("/");
|
||||
return slash.length === 2 && slash[0].length > 0 && slash[1].length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a {@link LocalModelServerConfig} draft the way the backend would.
|
||||
* Returns an empty object when the draft is valid.
|
||||
@ -45,15 +66,32 @@ export function validateModelServer(c: LocalModelServerConfig): ModelServerError
|
||||
errors.port = "Port must be an integer between 1 and 65535.";
|
||||
}
|
||||
if (c.servedModelName.trim().length === 0) {
|
||||
errors.servedModelName = "Served model name is required.";
|
||||
errors.servedModelName = "Le nom du modèle est requis.";
|
||||
}
|
||||
const modelPath = c.modelPath?.trim() ?? "";
|
||||
if (modelPath.length > 0 && !isAbsolutePath(modelPath)) {
|
||||
errors.modelPath = "Model path must be absolute.";
|
||||
if (c.host.trim().length === 0 || /\s/.test(c.host)) {
|
||||
errors.host = "L'adresse d'écoute est requise et ne doit pas contenir d'espace.";
|
||||
}
|
||||
// The backend rejects auto-start without an absolute model path.
|
||||
if (c.autoStart && modelPath.length === 0) {
|
||||
errors.modelPath = "Auto-start requires an absolute model path.";
|
||||
|
||||
const source = c.modelSource;
|
||||
if (source) {
|
||||
if (source.type === "localPath") {
|
||||
if (source.path.trim().length === 0) {
|
||||
errors.modelSource = "Le chemin du fichier .gguf est requis.";
|
||||
} else if (!isAbsolutePath(source.path.trim())) {
|
||||
errors.modelSource = "Le chemin du fichier doit être absolu.";
|
||||
}
|
||||
} else if (source.repo.trim().length === 0) {
|
||||
errors.modelSource = "Le modèle Hugging Face est requis.";
|
||||
} else if (!isValidHfRef(source.repo.trim())) {
|
||||
errors.modelSource =
|
||||
"Format attendu : auteur/modèle ou auteur/modèle:quant (ex. unsloth/Qwen3.5-9B-GGUF:Q4_K_M).";
|
||||
}
|
||||
}
|
||||
|
||||
// The backend rejects auto-start without a model source.
|
||||
if (c.autoStart && !source) {
|
||||
errors.modelSource =
|
||||
"Le démarrage automatique nécessite une source de modèle.";
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@ -72,7 +110,8 @@ export function newModelServerId(): string {
|
||||
|
||||
/**
|
||||
* A fresh model-server draft (id minted client-side) pre-filled with a sensible
|
||||
* local `llama-server` endpoint, so the form starts valid apart from the name.
|
||||
* local `llama-server` endpoint, so the form starts valid apart from the name
|
||||
* and the (still empty) model source.
|
||||
*/
|
||||
export function emptyModelServer(): LocalModelServerConfig {
|
||||
return {
|
||||
@ -81,7 +120,9 @@ export function emptyModelServer(): LocalModelServerConfig {
|
||||
name: "",
|
||||
baseURL: "http://localhost:8080/v1",
|
||||
port: 8080,
|
||||
servedModelName: "qwen3-coder-30b",
|
||||
servedModelName: "",
|
||||
host: "127.0.0.1",
|
||||
jinja: false,
|
||||
args: [],
|
||||
autoStart: false,
|
||||
stopPolicy: "stopOnAppExit",
|
||||
@ -95,3 +136,54 @@ export function parseArgs(raw: string): string[] {
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a default served-model name from a source when the user has not typed
|
||||
* one yet: the file stem for a local path, or the repo name (without namespace
|
||||
* and quant) for a Hugging Face reference. Returns `""` when nothing usable.
|
||||
*/
|
||||
export function deriveServedModelName(source: ModelSource | undefined): string {
|
||||
if (!source) return "";
|
||||
if (source.type === "localPath") {
|
||||
const path = source.path.trim();
|
||||
if (path.length === 0) return "";
|
||||
const base = path.split(/[/\\]/).pop() ?? "";
|
||||
return base.replace(/\.gguf$/i, "");
|
||||
}
|
||||
const repo = source.repo.trim();
|
||||
if (repo.length === 0) return "";
|
||||
const base = repo.includes(":") ? repo.slice(0, repo.indexOf(":")) : repo;
|
||||
return base.split("/").pop() ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* llama.cpp flags already piloted by dedicated editor fields. Typing any of them
|
||||
* (bare or as `--flag=value`) in the free "extra arguments" field is a
|
||||
* conflict: the backend stays the authority, but we mirror a soft warning.
|
||||
*/
|
||||
export const RESERVED_LLAMACPP_FLAGS: readonly string[] = [
|
||||
"--model",
|
||||
"-m",
|
||||
"-hf",
|
||||
"--port",
|
||||
"--host",
|
||||
"-ngl",
|
||||
"--gpu-layers",
|
||||
"-c",
|
||||
"--ctx-size",
|
||||
"--jinja",
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns the reserved flags present in a free-form args list (matching bare
|
||||
* tokens and the `--flag=value` form), or an empty list when none clash.
|
||||
*/
|
||||
export function reservedFlagsIn(args: string[]): string[] {
|
||||
const reserved = new Set(RESERVED_LLAMACPP_FLAGS);
|
||||
const hits = new Set<string>();
|
||||
for (const token of args) {
|
||||
const flag = token.includes("=") ? token.slice(0, token.indexOf("=")) : token;
|
||||
if (reserved.has(flag)) hits.add(flag);
|
||||
}
|
||||
return [...hits];
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
/**
|
||||
* F35.2 — local model server registry: the pure validation helpers, the
|
||||
* {@link useModelServers} hook behind the real DIProvider + mock gateway, the
|
||||
* {@link ModelServersPanel} CRUD UI (incl. the `model_server_in_use` rejection),
|
||||
* and the {@link ModelServerSelect} binding dropdown.
|
||||
* F35 (wizard V2) — local model server registry: the pure validation/derivation
|
||||
* helpers, the {@link useModelServers} hook behind the real DIProvider + mock
|
||||
* gateway, the {@link ModelServersPanel} wizard (source toggle, reserved-flag
|
||||
* mirror, V2 save payload, served-name prefill, command preview), and the
|
||||
* {@link ModelServerSelect} binding dropdown.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
@ -16,8 +17,11 @@ import { useModelServers } from "./useModelServers";
|
||||
import { ModelServersPanel } from "./ModelServersPanel";
|
||||
import { ModelServerSelect } from "./ModelServerSelect";
|
||||
import {
|
||||
deriveServedModelName,
|
||||
emptyModelServer,
|
||||
isAbsolutePath,
|
||||
isValidHfRef,
|
||||
reservedFlagsIn,
|
||||
validateModelServer,
|
||||
} from "./modelServer";
|
||||
|
||||
@ -27,7 +31,10 @@ const SERVER: LocalModelServerConfig = {
|
||||
name: "Local A",
|
||||
baseURL: "http://localhost:8080/v1",
|
||||
port: 8080,
|
||||
modelSource: { type: "huggingFace", repo: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
|
||||
servedModelName: "qwen3-coder-30b",
|
||||
host: "127.0.0.1",
|
||||
jinja: false,
|
||||
args: [],
|
||||
autoStart: false,
|
||||
stopPolicy: "stopOnAppExit",
|
||||
@ -46,10 +53,16 @@ function setup(modelServer = new MockModelServerGateway()) {
|
||||
// Pure helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("modelServer helpers (F35.2)", () => {
|
||||
it("emptyModelServer starts valid apart from the name", () => {
|
||||
describe("modelServer helpers (F35 V2)", () => {
|
||||
it("emptyModelServer needs a name and a served model name, and starts jinja-off on 127.0.0.1", () => {
|
||||
const draft = emptyModelServer();
|
||||
expect(validateModelServer(draft)).toEqual({ name: "Name is required." });
|
||||
expect(draft.host).toBe("127.0.0.1");
|
||||
expect(draft.jinja).toBe(false);
|
||||
expect(draft.modelSource).toBeUndefined();
|
||||
expect(validateModelServer(draft)).toEqual({
|
||||
name: "Name is required.",
|
||||
servedModelName: "Le nom du modèle est requis.",
|
||||
});
|
||||
});
|
||||
|
||||
it("flags a non-http base URL and an out-of-range port", () => {
|
||||
@ -62,14 +75,37 @@ describe("modelServer helpers (F35.2)", () => {
|
||||
expect(errors.port).toBeTruthy();
|
||||
});
|
||||
|
||||
it("requires an absolute model path, and requires it when auto-start is on", () => {
|
||||
expect(validateModelServer({ ...SERVER, modelPath: "relative/x.gguf" }).modelPath).toBeTruthy();
|
||||
expect(validateModelServer({ ...SERVER, autoStart: true }).modelPath).toMatch(
|
||||
/auto-start/i,
|
||||
);
|
||||
it("validates the local path source (absolute) and requires a source for auto-start", () => {
|
||||
expect(
|
||||
validateModelServer({ ...SERVER, autoStart: true, modelPath: "/m/x.gguf" }).modelPath,
|
||||
validateModelServer({
|
||||
...SERVER,
|
||||
modelSource: { type: "localPath", path: "relative/x.gguf" },
|
||||
}).modelSource,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
validateModelServer({
|
||||
...SERVER,
|
||||
modelSource: { type: "localPath", path: "/m/x.gguf" },
|
||||
}).modelSource,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validateModelServer({ ...SERVER, modelSource: undefined, autoStart: true })
|
||||
.modelSource,
|
||||
).toMatch(/démarrage automatique/i);
|
||||
});
|
||||
|
||||
it("validates the Hugging Face source format", () => {
|
||||
expect(isValidHfRef("unsloth/Qwen3.5-9B-GGUF")).toBe(true);
|
||||
expect(isValidHfRef("unsloth/Qwen3.5-9B-GGUF:Q4_K_M")).toBe(true);
|
||||
expect(isValidHfRef("https://huggingface.co/x/y")).toBe(false);
|
||||
expect(isValidHfRef("just-a-name")).toBe(false);
|
||||
expect(isValidHfRef("a/b:c:d")).toBe(false);
|
||||
expect(
|
||||
validateModelServer({
|
||||
...SERVER,
|
||||
modelSource: { type: "huggingFace", repo: "not valid ref" },
|
||||
}).modelSource,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("recognises absolute POSIX and Windows paths", () => {
|
||||
@ -77,13 +113,30 @@ describe("modelServer helpers (F35.2)", () => {
|
||||
expect(isAbsolutePath("C:/models/x.gguf")).toBe(true);
|
||||
expect(isAbsolutePath("models/x.gguf")).toBe(false);
|
||||
});
|
||||
|
||||
it("derives a served model name from either source", () => {
|
||||
expect(
|
||||
deriveServedModelName({ type: "localPath", path: "/models/qwen3-coder.gguf" }),
|
||||
).toBe("qwen3-coder");
|
||||
expect(
|
||||
deriveServedModelName({ type: "huggingFace", repo: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" }),
|
||||
).toBe("Qwen3.5-9B-GGUF");
|
||||
expect(deriveServedModelName(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("spots reserved llama.cpp flags typed in the free args, incl. --flag=value", () => {
|
||||
expect(reservedFlagsIn(["--flash-attn", "--parallel", "2"])).toEqual([]);
|
||||
expect(reservedFlagsIn(["--model", "/x.gguf"])).toEqual(["--model"]);
|
||||
expect(reservedFlagsIn(["--host=0.0.0.0"])).toEqual(["--host"]);
|
||||
expect(reservedFlagsIn(["-ngl", "99"])).toEqual(["-ngl"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useModelServers (F35.2)", () => {
|
||||
describe("useModelServers (F35 V2)", () => {
|
||||
it("saves and lists servers", async () => {
|
||||
const { view } = setup();
|
||||
await act(async () => {
|
||||
@ -119,6 +172,18 @@ describe("useModelServers (F35.2)", () => {
|
||||
expect(view.result.current.error).toMatch(/encore utilisé par un profil/i);
|
||||
expect(view.result.current.servers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("previews a command for a valid draft and returns null for a source-less one", async () => {
|
||||
const { view } = setup();
|
||||
const withSource = await view.result.current.preview(SERVER);
|
||||
expect(withSource?.args).toContain("-hf");
|
||||
expect(withSource?.display).toMatch(/unsloth\/Qwen3\.5-9B-GGUF:Q4_K_M/);
|
||||
const withoutSource = await view.result.current.preview({
|
||||
...SERVER,
|
||||
modelSource: undefined,
|
||||
});
|
||||
expect(withoutSource).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -141,35 +206,95 @@ function renderPanel(modelServer = new MockModelServerGateway()) {
|
||||
};
|
||||
}
|
||||
|
||||
describe("ModelServersPanel (F35.2)", () => {
|
||||
it("declares a new server end-to-end", async () => {
|
||||
describe("ModelServersPanel wizard (F35 V2)", () => {
|
||||
it("declares a Hugging Face server end-to-end with a V2 payload", async () => {
|
||||
const { modelServer } = renderPanel();
|
||||
fireEvent.click(screen.getByLabelText("add model server"));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("server name"), {
|
||||
target: { value: "Local A" },
|
||||
});
|
||||
// A valid draft (pre-filled endpoint + servedModelName) enables Save once
|
||||
// the initial passive load has settled.
|
||||
fireEvent.change(screen.getByLabelText("hugging face model"), {
|
||||
target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
|
||||
});
|
||||
|
||||
const save = screen.getByLabelText("save model server") as HTMLButtonElement;
|
||||
await waitFor(() => expect(save.disabled).toBe(false));
|
||||
fireEvent.click(save);
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await modelServer.listModelServers();
|
||||
expect(saved.map((s) => s.name)).toEqual(["Local A"]);
|
||||
expect(saved).toHaveLength(1);
|
||||
});
|
||||
// The editor closed and the server shows in the list.
|
||||
const [saved] = await modelServer.listModelServers();
|
||||
// Payload conforms to the V2 DTO: modelSource (not modelPath), host, jinja.
|
||||
expect(saved.modelSource).toEqual({
|
||||
type: "huggingFace",
|
||||
repo: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M",
|
||||
});
|
||||
expect(saved.host).toBe("127.0.0.1");
|
||||
expect(saved.jinja).toBe(false);
|
||||
expect("modelPath" in saved).toBe(false);
|
||||
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("blocks Save while the draft is invalid (blank name)", () => {
|
||||
it("toggles between the Hugging Face and local .gguf source fields", () => {
|
||||
renderPanel();
|
||||
fireEvent.click(screen.getByLabelText("add model server"));
|
||||
// Invalid draft (blank name) keeps Save disabled regardless of busy.
|
||||
|
||||
// HF tab is the default.
|
||||
expect(screen.getByLabelText("hugging face model")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("local gguf file")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /fichier \.gguf local/i }));
|
||||
expect(screen.getByLabelText("local gguf file")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("hugging face model")).toBeNull();
|
||||
});
|
||||
|
||||
it("prefills the served model name from the source when left blank", () => {
|
||||
renderPanel();
|
||||
fireEvent.click(screen.getByLabelText("add model server"));
|
||||
fireEvent.change(screen.getByLabelText("hugging face model"), {
|
||||
target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
|
||||
});
|
||||
expect(
|
||||
(screen.getByLabelText("save model server") as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
(screen.getByLabelText("server served model name") as HTMLInputElement).value,
|
||||
).toBe("Qwen3.5-9B-GGUF");
|
||||
});
|
||||
|
||||
it("mirrors a soft warning when a reserved flag is typed in the extra args", () => {
|
||||
renderPanel();
|
||||
fireEvent.click(screen.getByLabelText("add model server"));
|
||||
fireEvent.change(screen.getByLabelText("extra arguments"), {
|
||||
target: { value: "--host 0.0.0.0" },
|
||||
});
|
||||
expect(screen.getByText(/déjà pilotée par un champ/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("blocks auto-start until a model source is set", () => {
|
||||
renderPanel();
|
||||
fireEvent.click(screen.getByLabelText("add model server"));
|
||||
const autoStart = screen.getByLabelText("server auto start") as HTMLInputElement;
|
||||
expect(autoStart.disabled).toBe(true);
|
||||
fireEvent.change(screen.getByLabelText("hugging face model"), {
|
||||
target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
|
||||
});
|
||||
expect(
|
||||
(screen.getByLabelText("server auto start") as HTMLInputElement).disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("renders the backend-built command preview after a source is entered", async () => {
|
||||
renderPanel();
|
||||
fireEvent.click(screen.getByLabelText("add model server"));
|
||||
fireEvent.change(screen.getByLabelText("hugging face model"), {
|
||||
target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
|
||||
});
|
||||
const line = (await screen.findByLabelText(
|
||||
"preview command line",
|
||||
)) as HTMLElement;
|
||||
expect(line.textContent).toMatch(/-hf unsloth\/Qwen3\.5-9B-GGUF:Q4_K_M/);
|
||||
expect(line.textContent).toMatch(/--port 8080/);
|
||||
});
|
||||
|
||||
it("shows an actionable banner when deleting a referenced server", async () => {
|
||||
@ -184,7 +309,6 @@ describe("ModelServersPanel (F35.2)", () => {
|
||||
expect((await screen.findByRole("alert")).textContent).toMatch(
|
||||
/encore utilisé par un profil/i,
|
||||
);
|
||||
// The server is still listed (delete was refused).
|
||||
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
|
||||
});
|
||||
|
||||
@ -210,7 +334,7 @@ describe("ModelServersPanel (F35.2)", () => {
|
||||
// Select
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ModelServerSelect (F35.2)", () => {
|
||||
describe("ModelServerSelect (F35)", () => {
|
||||
it("offers a 'none' option plus each declared server, and emits the id", () => {
|
||||
let picked: string | undefined = "sentinel";
|
||||
render(
|
||||
@ -221,7 +345,6 @@ describe("ModelServerSelect (F35.2)", () => {
|
||||
/>,
|
||||
);
|
||||
const select = screen.getByLabelText("local model server") as HTMLSelectElement;
|
||||
// "None" is selected when value is undefined.
|
||||
expect(select.value).toBe("");
|
||||
fireEvent.change(select, { target: { value: SERVER.id } });
|
||||
expect(picked).toBe(SERVER.id);
|
||||
|
||||
@ -11,7 +11,11 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { GatewayError, LocalModelServerConfig } from "@/domain";
|
||||
import type {
|
||||
GatewayError,
|
||||
LocalModelServerConfig,
|
||||
ModelServerCommandPreview,
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
/** What the model-server UI needs from this hook. */
|
||||
@ -28,6 +32,14 @@ export interface ModelServersViewModel {
|
||||
save: (config: LocalModelServerConfig) => Promise<LocalModelServerConfig | null>;
|
||||
/** Deletes a server by id; returns `true` on success. */
|
||||
remove: (serverId: string) => Promise<boolean>;
|
||||
/**
|
||||
* Asks the backend to build the `llama-server` command line for a draft
|
||||
* (never reconstructed client-side). Returns `null` when the draft is
|
||||
* invalid — the editor shows a placeholder instead of a stale command.
|
||||
*/
|
||||
preview: (
|
||||
config: LocalModelServerConfig,
|
||||
) => Promise<ModelServerCommandPreview | null>;
|
||||
/** Clears the current error banner. */
|
||||
clearError: () => void;
|
||||
}
|
||||
@ -120,7 +132,20 @@ export function useModelServers(): ModelServersViewModel {
|
||||
[modelServer],
|
||||
);
|
||||
|
||||
const preview = useCallback(
|
||||
async (config: LocalModelServerConfig) => {
|
||||
try {
|
||||
return await modelServer.previewModelServerCommand(config);
|
||||
} catch {
|
||||
// A preview is best-effort UI sugar: an invalid draft (e.g. no source)
|
||||
// simply yields no command, without touching the CRUD error banner.
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[modelServer],
|
||||
);
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
return { servers, error, busy, reload, save, remove, clearError };
|
||||
return { servers, error, busy, reload, save, remove, preview, clearError };
|
||||
}
|
||||
|
||||
@ -26,6 +26,7 @@ import type {
|
||||
LayoutOperation,
|
||||
LayoutTree,
|
||||
LocalModelServerConfig,
|
||||
ModelServerCommandPreview,
|
||||
Memory,
|
||||
MemoryIndexEntry,
|
||||
MemoryLink,
|
||||
@ -649,8 +650,9 @@ export interface CloneOpenCodeProfileFromSeedInput {
|
||||
/**
|
||||
* Local model servers (F35). CRUD over the global registry of declared
|
||||
* `llama.cpp` servers an OpenCode profile can bind to via
|
||||
* `OpenCodeConfig.localModelServerId`. Mirrors the three backend commands
|
||||
* (`list_model_servers`, `save_model_server`, `delete_model_server`).
|
||||
* `OpenCodeConfig.localModelServerId`. Mirrors the backend commands
|
||||
* (`list_model_servers`, `save_model_server`, `delete_model_server`,
|
||||
* `preview_model_server_command`).
|
||||
*/
|
||||
export interface ModelServerGateway {
|
||||
/** Lists the declared local model servers. */
|
||||
@ -665,6 +667,15 @@ export interface ModelServerGateway {
|
||||
* `model_server_in_use` when a profile still references it.
|
||||
*/
|
||||
deleteModelServer(serverId: string): Promise<void>;
|
||||
/**
|
||||
* Builds the `llama-server` command line the backend would launch for the
|
||||
* draft config, without persisting it. The backend is the sole authority on
|
||||
* the argv (never reconstruct it client-side). Rejects when the config is
|
||||
* invalid (e.g. `modelSource` missing).
|
||||
*/
|
||||
previewModelServerCommand(
|
||||
config: LocalModelServerConfig,
|
||||
): Promise<ModelServerCommandPreview>;
|
||||
}
|
||||
|
||||
/** Input for {@link EmbedderGateway.saveEmbedderProfile}. */
|
||||
|
||||
Reference in New Issue
Block a user