From efa081b07404245eb8d885a660340e1590261bfa Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 12 Jul 2026 14:32:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(model-server):=20wizard=20V2=20config=20mo?= =?UTF-8?q?d=C3=A8les=20locaux=20=E2=80=94=20source,=20options=20structur?= =?UTF-8?q?=C3=A9es=20&=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/adapters/mock/index.ts | 39 ++ frontend/src/adapters/modelServer.ts | 15 +- frontend/src/domain/index.ts | 40 +- .../first-run/FirstRunWizard.test.tsx | 2 + .../model-servers/ModelServersPanel.tsx | 495 ++++++++++++++---- frontend/src/features/model-servers/index.ts | 5 + .../src/features/model-servers/modelServer.ts | 120 ++++- .../model-servers/modelServers.test.tsx | 177 ++++++- .../features/model-servers/useModelServers.ts | 29 +- frontend/src/ports/index.ts | 15 +- 10 files changed, 794 insertions(+), 143 deletions(-) diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 9ff1d29..8908d45 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -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 { + // 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); diff --git a/frontend/src/adapters/modelServer.ts b/frontend/src/adapters/modelServer.ts index b2123b4..72a1d75 100644 --- a/frontend/src/adapters/modelServer.ts +++ b/frontend/src/adapters/modelServer.ts @@ -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 { await invoke("delete_model_server", { serverId }); } + + previewModelServerCommand( + config: LocalModelServerConfig, + ): Promise { + // `preview_model_server_command` takes the config directly (no `request` + // wrapper), unlike `save_model_server`. + return invoke("preview_model_server_command", { + config, + }); + } } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index a74bc36..6e9a902 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -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 } diff --git a/frontend/src/features/first-run/FirstRunWizard.test.tsx b/frontend/src/features/first-run/FirstRunWizard.test.tsx index 893ec28..899e66b 100644 --- a/frontend/src/features/first-run/FirstRunWizard.test.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.test.tsx @@ -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", diff --git a/frontend/src/features/model-servers/ModelServersPanel.tsx b/frontend/src/features/model-servers/ModelServersPanel.tsx index 2c3b968..c24116b 100644 --- a/frontend/src/features/model-servers/ModelServersPanel.tsx +++ b/frontend/src/features/model-servers/ModelServersPanel.tsx @@ -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 {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" }, @@ -132,6 +147,7 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) { { 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 ( + + ); + }; + 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: () => void; @@ -164,17 +278,80 @@ function ServerEditor({ const patch = (next: Partial) => 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( + 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(draft.args); + 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 ------------------------------------------------------ */}
-