From dbaf6fe2f42ba87009b95bfaede422a893db2248 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 1 Aug 2026 00:12:07 +0200 Subject: [PATCH] =?UTF-8?q?fix(model-servers):=20autorise=20les=20espaces?= =?UTF-8?q?=20dans=20le=20champ=20Arguments=20suppl=C3=A9mentaires=20llama?= =?UTF-8?q?.cpp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../first-run/FirstRunWizard.test.tsx | 24 +++++++++++++ .../src/features/first-run/FirstRunWizard.tsx | 16 +++++++-- .../model-servers/ModelServersPanel.tsx | 35 ++++++++++++++----- .../model-servers/modelServers.test.tsx | 27 ++++++++++++++ 4 files changed, 91 insertions(+), 11 deletions(-) diff --git a/frontend/src/features/first-run/FirstRunWizard.test.tsx b/frontend/src/features/first-run/FirstRunWizard.test.tsx index 7011ff5..4711f07 100644 --- a/frontend/src/features/first-run/FirstRunWizard.test.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.test.tsx @@ -83,6 +83,30 @@ describe("FirstRunWizard (with MockProfileGateway)", () => { expect(cmd.value).toBe("codex-2"); }); + it("keeps args spaces while typing and parses them on blur", async () => { + const { profile } = renderWizard(); + await waitForLoaded(); + await waitFor(() => + expect( + (screen.getByLabelText("use Claude Code") as HTMLInputElement).checked, + ).toBe(true), + ); + + const args = screen.getByLabelText("Claude Code args") as HTMLInputElement; + fireEvent.change(args, { + target: { value: "--model claude-sonnet-4-5 --verbose " }, + }); + expect(args.value).toBe("--model claude-sonnet-4-5 --verbose "); + + fireEvent.blur(args); + fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); + + await waitFor(async () => { + const [saved] = await profile.listProfiles(); + expect(saved.args).toEqual(["--model", "claude-sonnet-4-5", "--verbose"]); + }); + }); + it("detection shows ✓ for claude and ✗ for the rest", async () => { renderWizard(); await waitForLoaded(); diff --git a/frontend/src/features/first-run/FirstRunWizard.tsx b/frontend/src/features/first-run/FirstRunWizard.tsx index edfcb7b..14bfd0e 100644 --- a/frontend/src/features/first-run/FirstRunWizard.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.tsx @@ -20,6 +20,7 @@ import type { HttpChatConfig, LocalModelServerConfig, } from "@/domain"; +import { useEffect, useState } from "react"; import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared"; import { ModelServersPanel, @@ -183,6 +184,16 @@ function ProfileRow({ const { profile, selected, available } = entry; const errors = validateProfile(profile); const isOpenCode = profile.structuredAdapter === "openCode"; + const canonicalArgsText = profile.args.join(" "); + const [argsText, setArgsText] = useState(canonicalArgsText); + + useEffect(() => { + setArgsText(canonicalArgsText); + }, [canonicalArgsText, profile.id]); + + function commitArgs() { + onChange({ ...profile, args: parseArgs(argsText) }); + } return (
  • @@ -260,8 +271,9 @@ function ProfileRow({ Arguments onChange({ ...profile, args: parseArgs(e.target.value) })} + value={argsText} + onChange={(e) => setArgsText(e.target.value)} + onBlur={commitArgs} /> diff --git a/frontend/src/features/model-servers/ModelServersPanel.tsx b/frontend/src/features/model-servers/ModelServersPanel.tsx index ce7eb6d..e5236c1 100644 --- a/frontend/src/features/model-servers/ModelServersPanel.tsx +++ b/frontend/src/features/model-servers/ModelServersPanel.tsx @@ -97,9 +97,10 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) { setDraft({ ...server }); } - async function submit() { - if (!draft) return; - const saved = await vm.save(draft); + async function submit(nextDraft?: LocalModelServerConfig) { + const submitted = nextDraft ?? draft; + if (!submitted) return; + const saved = await vm.save(submitted); if (saved) setDraft(null); } @@ -225,7 +226,7 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) { vm.clearError(); setDraft(null); }} - onSubmit={() => void submit()} + onSubmit={(nextDraft) => void submit(nextDraft)} /> )} @@ -343,12 +344,27 @@ function ServerEditor({ ) => Promise; onChange: (next: LocalModelServerConfig) => void; onCancel: () => void; - onSubmit: () => 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). @@ -411,7 +427,7 @@ function ServerEditor({ // `draftKey` captures the argv-affecting fields; `preview` is stable. }, [draftKey, preview]); // eslint-disable-line react-hooks/exhaustive-deps - const reservedHits = reservedFlagsIn(draft.args); + const reservedHits = reservedFlagsIn(parseArgs(argsText)); return (
    Arguments supplémentaires patch({ args: parseArgs(e.target.value) })} + onChange={(e) => setArgsText(e.target.value)} + onBlur={commitArgs} /> {reservedHits.length > 0 && ( @@ -682,7 +699,7 @@ function ServerEditor({ variant="primary" size="sm" aria-label="save model server" - onClick={onSubmit} + onClick={() => onSubmit(draftWithCommittedArgs())} disabled={busy || !valid} > Save server diff --git a/frontend/src/features/model-servers/modelServers.test.tsx b/frontend/src/features/model-servers/modelServers.test.tsx index 9a3f8c0..872f941 100644 --- a/frontend/src/features/model-servers/modelServers.test.tsx +++ b/frontend/src/features/model-servers/modelServers.test.tsx @@ -276,6 +276,33 @@ describe("ModelServersPanel wizard (F35 V2)", () => { expect(screen.getByText(/déjà pilotée par un champ/i)).toBeTruthy(); }); + it("keeps extra-args spaces while typing and only parses them on save", async () => { + const { modelServer } = renderPanel(); + fireEvent.click(screen.getByLabelText("add model server")); + + fireEvent.change(screen.getByLabelText("server name"), { + target: { value: "Spaced Args" }, + }); + fireEvent.change(screen.getByLabelText("hugging face model"), { + target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" }, + }); + const args = screen.getByLabelText("extra arguments") as HTMLInputElement; + fireEvent.change(args, { + target: { value: "--flash-attn --parallel 2 " }, + }); + + expect(args.value).toBe("--flash-attn --parallel 2 "); + + 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.args).toEqual(["--flash-attn", "--parallel", "2"]); + }); + }); + it("blocks auto-start until a model source is set", () => { renderPanel(); fireEvent.click(screen.getByLabelText("add model server"));