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:
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"));
|
||||||
|
|||||||
Reference in New Issue
Block a user