Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions frontend/src/features/settings/SettingsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -355,4 +355,102 @@ describe("SettingsPanel", () => {
expect(screen.getByRole("status")).toHaveTextContent("Save the folder setting before downloading");
expect(onDownloadGGUF).not.toHaveBeenCalled();
});
it("does not read a cleared number field as zero", async () => {
// Number("") is 0. Clearing a field to retype it wrote a real zero into
// the draft: below the server's minimum for the context window, and for
// the seed a *valid* value that silently turned "-1, keep replies varied"
// into a pinned seed.
const user = userEvent.setup();
const onSave = vi.fn<(settings: CortexSettings) => Promise<void>>().mockResolvedValue();
const settings: CortexSettings = {
models: { chat: "local-chat:7b", title: null, translation: "translategemma:4b" },
generation: { temperature: 0.7, num_ctx: 4096, seed: -1, system_instructions: "", bypass_system_prompt: false },
};
const models: ModelResponse = {
required_models: [],
optional_models: [],
installed_models: ["local-chat:7b"],
models: [{ name: "local-chat:7b" }],
connection: { success: true, status: "connected", message: "Connected." },
};
render(
<SettingsPanel
settings={settings}
memos={[]}
saving={false}
memoryBusy={false}
onSave={onSave}
onAddMemory={vi.fn<(memo: string) => Promise<void>>().mockResolvedValue()}
onReplaceMemory={vi.fn<(memos: string[]) => Promise<void>>().mockResolvedValue()}
onClearMemory={vi.fn<() => Promise<void>>().mockResolvedValue()}
models={models}
modelBusy={false}
modelProgress={null}
setupUrl="https://ollama.com/download"
onCheckModels={vi.fn<() => Promise<void>>().mockResolvedValue()}
onPullModel={vi.fn<(model: string) => Promise<void>>().mockResolvedValue()}
llamacppStatus={{ state: "idle", binary_present: false, loaded_model: null, last_error: null, models_directory: "" }}
onDownloadGGUF={vi.fn().mockResolvedValue(undefined)}
onClose={vi.fn()}
/>,
);

await user.click(screen.getByRole("button", { name: /AI Model/ }));
await user.clear(screen.getByLabelText("Context window"));
await user.clear(screen.getByLabelText("Seed"));
await user.click(screen.getByRole("button", { name: "Save settings" }));

await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
generation: expect.objectContaining({ num_ctx: 4096, seed: -1 }),
}));
});

it("still accepts a retyped number", async () => {
const user = userEvent.setup();
const onSave = vi.fn<(settings: CortexSettings) => Promise<void>>().mockResolvedValue();
const settings: CortexSettings = {
models: { chat: "local-chat:7b", title: null, translation: "translategemma:4b" },
generation: { temperature: 0.7, num_ctx: 4096, seed: -1, system_instructions: "", bypass_system_prompt: false },
};
const models: ModelResponse = {
required_models: [],
optional_models: [],
installed_models: ["local-chat:7b"],
models: [{ name: "local-chat:7b" }],
connection: { success: true, status: "connected", message: "Connected." },
};
render(
<SettingsPanel
settings={settings}
memos={[]}
saving={false}
memoryBusy={false}
onSave={onSave}
onAddMemory={vi.fn<(memo: string) => Promise<void>>().mockResolvedValue()}
onReplaceMemory={vi.fn<(memos: string[]) => Promise<void>>().mockResolvedValue()}
onClearMemory={vi.fn<() => Promise<void>>().mockResolvedValue()}
models={models}
modelBusy={false}
modelProgress={null}
setupUrl="https://ollama.com/download"
onCheckModels={vi.fn<() => Promise<void>>().mockResolvedValue()}
onPullModel={vi.fn<(model: string) => Promise<void>>().mockResolvedValue()}
llamacppStatus={{ state: "idle", binary_present: false, loaded_model: null, last_error: null, models_directory: "" }}
onDownloadGGUF={vi.fn().mockResolvedValue(undefined)}
onClose={vi.fn()}
/>,
);

await user.click(screen.getByRole("button", { name: /AI Model/ }));
const seed = screen.getByLabelText("Seed");
await user.clear(seed);
await user.type(seed, "42");
await user.click(screen.getByRole("button", { name: "Save settings" }));

await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
generation: expect.objectContaining({ seed: 42 }),
}));
});
});
61 changes: 59 additions & 2 deletions frontend/src/features/settings/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,23 @@ const sections: { id: SettingsSection; label: string; detail: string }[] = [
{ id: "system", label: "System", detail: "Runtime and installed models" },
];

type NumericField = "num_ctx" | "seed";

/**
* Read a number input, or null when it holds nothing usable.
*
* `Number("")` is 0, so clearing a field to retype it used to write a real
* zero into the draft. For the context window that is below the server's
* minimum and the save was rejected; for the seed it is a *valid* value, so
* it silently turned "-1, keep replies varied" into a pinned seed. An empty
* field means the user is mid-edit, not that they chose zero.
*/
function readNumericInput(raw: string): number | null {
if (raw.trim() === "") return null;
const value = Number(raw);
return Number.isFinite(value) ? value : null;
}

export function SettingsPanel({
settings,
memos,
Expand Down Expand Up @@ -132,6 +149,29 @@ export function SettingsPanel({

const update = (next: Partial<CortexSettings>) => setDraft((current) => ({ ...current, ...next }));

// What the user has literally typed into a number field, while they are
// typing it. The saved settings hold numbers, so a field bound straight to
// them can never be empty -- clearing it to retype snapped the old value
// back and the new digits appended to it ("-1" + "42" = -142). Keeping the
// raw text lets the box be empty mid-edit without ever writing a number
// nobody chose; `commitNumber` drops the override on blur so the field
// re-syncs to whatever was actually saved.
const [numericInputs, setNumericInputs] = useState<Partial<Record<NumericField, string>>>({});

const editNumber = (field: NumericField, raw: string, commit: (value: number) => void) => {
setNumericInputs((current) => ({ ...current, [field]: raw }));
const value = readNumericInput(raw);
if (value !== null) commit(value);
};

const commitNumber = (field: NumericField) =>
setNumericInputs((current) => {
if (current[field] === undefined) return current;
const next = { ...current };
delete next[field];
return next;
});

const chooseChatModel = (chat: string) => update({ models: { ...modelSettings, chat, title: null } });

const setTranslationEnabled = (enabled: boolean) => {
Expand Down Expand Up @@ -268,10 +308,27 @@ export function SettingsPanel({
</div>
<div className="settings-field-row">
<label className="field-label" htmlFor="num-ctx">Context window
<input id="num-ctx" type="number" min="2048" max="65536" step="1024" value={generation.num_ctx ?? 8192} onChange={(event) => update({ generation: { ...generation, num_ctx: Number(event.target.value) } })} />
<input
id="num-ctx"
type="number"
min="2048"
max="65536"
step="1024"
value={numericInputs.num_ctx ?? String(generation.num_ctx ?? 8192)}
onChange={(event) => editNumber("num_ctx", event.target.value, (num_ctx) => update({ generation: { ...generation, num_ctx } }))}
onBlur={() => commitNumber("num_ctx")}
/>
</label>
<label className="field-label" htmlFor="seed">Seed
<input id="seed" type="number" min="-1" max="2147483647" value={generation.seed ?? -1} onChange={(event) => update({ generation: { ...generation, seed: Number(event.target.value) } })} />
<input
id="seed"
type="number"
min="-1"
max="2147483647"
value={numericInputs.seed ?? String(generation.seed ?? -1)}
onChange={(event) => editNumber("seed", event.target.value, (seed) => update({ generation: { ...generation, seed } }))}
onBlur={() => commitNumber("seed")}
/>
</label>
</div>

Expand Down