fix(model-servers): autorise les espaces dans le champ Arguments supplémentaires llama.cpp

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 00:12:07 +02:00
parent 5efb026a80
commit dbaf6fe2f4
4 changed files with 91 additions and 11 deletions

View File

@ -83,6 +83,30 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
expect(cmd.value).toBe("codex-2"); 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 () => { it("detection shows ✓ for claude and ✗ for the rest", async () => {
renderWizard(); renderWizard();
await waitForLoaded(); await waitForLoaded();

View File

@ -20,6 +20,7 @@ import type {
HttpChatConfig, HttpChatConfig,
LocalModelServerConfig, LocalModelServerConfig,
} from "@/domain"; } from "@/domain";
import { useEffect, useState } from "react";
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared"; import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
import { import {
ModelServersPanel, ModelServersPanel,
@ -183,6 +184,16 @@ function ProfileRow({
const { profile, selected, available } = entry; const { profile, selected, available } = entry;
const errors = validateProfile(profile); const errors = validateProfile(profile);
const isOpenCode = profile.structuredAdapter === "openCode"; 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 ( return (
<li className="flex flex-col gap-2 rounded-md border border-border bg-raised p-3"> <li className="flex flex-col gap-2 rounded-md border border-border bg-raised p-3">
@ -260,8 +271,9 @@ function ProfileRow({
<Caption>Arguments</Caption> <Caption>Arguments</Caption>
<Input <Input
aria-label={`${profile.name} args`} aria-label={`${profile.name} args`}
value={profile.args.join(" ")} value={argsText}
onChange={(e) => onChange({ ...profile, args: parseArgs(e.target.value) })} onChange={(e) => setArgsText(e.target.value)}
onBlur={commitArgs}
/> />
</label> </label>

View File

@ -97,9 +97,10 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
setDraft({ ...server }); setDraft({ ...server });
} }
async function submit() { async function submit(nextDraft?: LocalModelServerConfig) {
if (!draft) return; const submitted = nextDraft ?? draft;
const saved = await vm.save(draft); if (!submitted) return;
const saved = await vm.save(submitted);
if (saved) setDraft(null); if (saved) setDraft(null);
} }
@ -225,7 +226,7 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
vm.clearError(); vm.clearError();
setDraft(null); setDraft(null);
}} }}
onSubmit={() => void submit()} onSubmit={(nextDraft) => void submit(nextDraft)}
/> />
)} )}
</div> </div>
@ -343,12 +344,27 @@ function ServerEditor({
) => Promise<ModelServerCommandPreview | null>; ) => Promise<ModelServerCommandPreview | null>;
onChange: (next: LocalModelServerConfig) => void; onChange: (next: LocalModelServerConfig) => void;
onCancel: () => void; onCancel: () => void;
onSubmit: () => void; onSubmit: (nextDraft?: LocalModelServerConfig) => void;
}) { }) {
const errors: ModelServerErrors = validateModelServer(draft); const errors: ModelServerErrors = validateModelServer(draft);
const valid = Object.keys(errors).length === 0; const valid = Object.keys(errors).length === 0;
const patch = (next: Partial<LocalModelServerConfig>) => const patch = (next: Partial<LocalModelServerConfig>) =>
onChange({ ...draft, ...next }); 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 // 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). // 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` captures the argv-affecting fields; `preview` is stable.
}, [draftKey, preview]); // eslint-disable-line react-hooks/exhaustive-deps }, [draftKey, preview]); // eslint-disable-line react-hooks/exhaustive-deps
const reservedHits = reservedFlagsIn(draft.args); const reservedHits = reservedFlagsIn(parseArgs(argsText));
return ( return (
<fieldset <fieldset
@ -642,9 +658,10 @@ function ServerEditor({
<Caption>Arguments supplémentaires</Caption> <Caption>Arguments supplémentaires</Caption>
<Input <Input
aria-label="extra arguments" aria-label="extra arguments"
value={draft.args.join(" ")} value={argsText}
placeholder="--flash-attn --parallel 2" placeholder="--flash-attn --parallel 2"
onChange={(e) => patch({ args: parseArgs(e.target.value) })} onChange={(e) => setArgsText(e.target.value)}
onBlur={commitArgs}
/> />
{reservedHits.length > 0 && ( {reservedHits.length > 0 && (
<small className="text-xs text-warning" role="status"> <small className="text-xs text-warning" role="status">
@ -682,7 +699,7 @@ function ServerEditor({
variant="primary" variant="primary"
size="sm" size="sm"
aria-label="save model server" aria-label="save model server"
onClick={onSubmit} onClick={() => onSubmit(draftWithCommittedArgs())}
disabled={busy || !valid} disabled={busy || !valid}
> >
Save server Save server

View File

@ -276,6 +276,33 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
expect(screen.getByText(/déjà pilotée par un champ/i)).toBeTruthy(); 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", () => { it("blocks auto-start until a model source is set", () => {
renderPanel(); renderPanel();
fireEvent.click(screen.getByLabelText("add model server")); fireEvent.click(screen.getByLabelText("add model server"));