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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ second -- with copy, regenerate, and fork controls that appear on hover.
Settings keep model selection and generation defaults in one place, and the chat
model doubles as the title model so there is only one choice to make:

![Cortex settings, AI Model section: top-p, top-k, and repeat penalty sliders,
context window and seed fields, a system instructions box, and a toggle to
bypass Cortex's default system prompt.](docs/images/settings.png)
![Cortex settings, AI Model section: the local model picker above grouped
Sampling and Context controls, with filled-track sliders and their current
values shown beside each label.](docs/images/settings.png)

`Ctrl`/`Cmd`+`K` opens a command palette that reaches new chat, settings, theme,
model switching, and recent conversations without leaving the keyboard:
Expand Down
Binary file modified docs/images/settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 24 additions & 12 deletions frontend/src/features/settings/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ModelResponse,
} from "../../../../contracts/cortex-api";
import { displayModelName, isGGUFModel, localModelNames } from "../../lib/localModels";
import { RangeField } from "../../shared/ui/RangeField";
import { Select } from "../../shared/ui/Select";
import { MemoryPanel } from "./MemoryPanel";
import { ModelsPanel } from "../models/ModelsPanel";
Expand Down Expand Up @@ -184,18 +185,23 @@ export function SettingsPanel({
<button className="button button-secondary" type="button" onClick={() => void onCheckModels()} disabled={modelBusy}>Rescan local models</button>
</div>
)}
<label className="field-label" htmlFor="temperature">Temperature <span className="field-value">{generation.temperature ?? 0.7}</span>
<input id="temperature" type="range" min="0" max="2" step="0.1" value={generation.temperature ?? 0.7} onChange={(event) => update({ generation: { ...generation, temperature: Number(event.target.value) } })} />
</label>
<label className="field-label" htmlFor="top-p">Top P <span className="field-value">{generation.top_p ?? 0.9}</span>
<input id="top-p" type="range" min="0" max="1" step="0.05" value={generation.top_p ?? 0.9} onChange={(event) => update({ generation: { ...generation, top_p: Number(event.target.value) } })} />
</label>
<label className="field-label" htmlFor="top-k">Top K <span className="field-value">{generation.top_k ?? 40}</span>
<input id="top-k" type="range" min="0" max="200" step="1" value={generation.top_k ?? 40} onChange={(event) => update({ generation: { ...generation, top_k: Number(event.target.value) } })} />
</label>
<label className="field-label" htmlFor="repeat-penalty">Repeat penalty <span className="field-value">{generation.repeat_penalty ?? 1.1}</span>
<input id="repeat-penalty" type="range" min="0.5" max="2" step="0.05" value={generation.repeat_penalty ?? 1.1} onChange={(event) => update({ generation: { ...generation, repeat_penalty: Number(event.target.value) } })} />
</label>
<hr className="settings-divider" />
<div className="settings-subhead">
<strong>Sampling</strong>
<small>How the model picks its next token. Defaults suit most local models.</small>
</div>
<div className="settings-range-grid">
<RangeField id="temperature" label="Temperature" min={0} max={2} step={0.1} value={generation.temperature ?? 0.7} format={(value) => value.toFixed(1)} onChange={(temperature) => update({ generation: { ...generation, temperature } })} />
<RangeField id="top-p" label="Top P" min={0} max={1} step={0.05} value={generation.top_p ?? 0.9} format={(value) => value.toFixed(2)} onChange={(top_p) => update({ generation: { ...generation, top_p } })} />
<RangeField id="top-k" label="Top K" min={0} max={200} step={1} value={generation.top_k ?? 40} onChange={(top_k) => update({ generation: { ...generation, top_k } })} />
<RangeField id="repeat-penalty" label="Repeat penalty" min={0.5} max={2} step={0.05} value={generation.repeat_penalty ?? 1.1} format={(value) => value.toFixed(2)} onChange={(repeat_penalty) => update({ generation: { ...generation, repeat_penalty } })} />
</div>

<hr className="settings-divider" />
<div className="settings-subhead">
<strong>Context</strong>
<small>A larger window holds more conversation but uses more memory. Seed -1 keeps replies varied.</small>
</div>
<div className="settings-field-row">
<label className="field-label" htmlFor="num-ctx">Context window
<input id="num-ctx" type="number" min="2048" max="16384" step="1024" value={generation.num_ctx ?? 4096} onChange={(event) => update({ generation: { ...generation, num_ctx: Number(event.target.value) } })} />
Expand All @@ -204,6 +210,12 @@ export function SettingsPanel({
<input id="seed" type="number" min="-1" max="2147483647" value={generation.seed ?? -1} onChange={(event) => update({ generation: { ...generation, seed: Number(event.target.value) } })} />
</label>
</div>

<hr className="settings-divider" />
<div className="settings-subhead">
<strong>System prompt</strong>
<small>Standing instructions sent with every message in every chat.</small>
</div>
<label className="field-label" htmlFor="system-instructions">System instructions
<textarea id="system-instructions" value={generation.system_instructions ?? ""} onChange={(event) => update({ generation: { ...generation, system_instructions: event.target.value } })} rows={4} />
</label>
Expand Down
52 changes: 52 additions & 0 deletions frontend/src/shared/ui/RangeField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { CSSProperties } from "react";

type Props = {
id: string;
label: string;
value: number;
min: number;
max: number;
step: number;
onChange: (value: number) => void;
/** Short explanation shown under the track. */
hint?: string;
/** Override the printed value, e.g. to add a unit or fix decimals. */
format?: (value: number) => string;
};

/**
* A labelled slider with a filled track.
*
* Browsers only paint the filled portion of a range input natively in Firefox
* (`::-moz-range-progress`); WebKit has no equivalent. Publishing the position
* as a `--range-fill` percentage lets the track be painted with a gradient, so
* the control looks the same everywhere instead of falling back to the default
* grey rail on Chromium and WebView2 -- which is what Cortex actually ships in.
*/
export function RangeField({ id, label, value, min, max, step, onChange, hint, format }: Props) {
const span = max - min;
const fill = span > 0 ? ((value - min) / span) * 100 : 0;

return (
<div className="range-field">
<div className="range-field-head">
<label htmlFor={id}>{label}</label>
<output htmlFor={id} className="range-field-value">
{format ? format(value) : value}
</output>
</div>
<input
id={id}
className="range-input"
type="range"
min={min}
max={max}
step={step}
value={value}
style={{ "--range-fill": `${Math.min(100, Math.max(0, fill))}%` } as CSSProperties}
onChange={(event) => onChange(Number(event.target.value))}
/>
{hint && <small className="range-field-hint">{hint}</small>}
</div>
);
}
114 changes: 113 additions & 1 deletion frontend/src/styles/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -663,11 +663,122 @@ a { color: inherit; text-decoration: none; }
.translation-install-status > strong { margin-left: auto; font-variant-numeric: tabular-nums; }
.field-value { float: right; color: var(--accent); }
input[type="range"] { width: 100%; accent-color: var(--accent); }

/* Sliders -------------------------------------------------------------------
The default range control is styled per browser and reads as an unfinished
form rather than part of this interface, so the track and thumb are drawn
here instead. The filled portion comes from --range-fill, published by
RangeField, because WebKit has no ::-moz-range-progress equivalent. */
.range-field { display: grid; min-width: 0; gap: 9px; }
.range-field-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; min-width: 0; }
.range-field-head label { min-width: 0; overflow: hidden; color: var(--text-muted); font-size: var(--text-sm); font-weight: var(--weight-semi); text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.range-field-value {
flex: 0 0 auto;
padding: 2px 8px;
border-radius: var(--radius-xs);
background: var(--accent-soft);
color: var(--accent);
font-size: var(--text-2xs);
font-variant-numeric: tabular-nums;
font-weight: var(--weight-semi);
}
.range-field-hint { color: var(--text-faint); font-size: var(--text-2xs); line-height: 1.45; }

.range-input {
--range-fill: 50%;
width: 100%;
height: 16px;
margin: 0;
padding: 0;
appearance: none;
-webkit-appearance: none;
background: transparent;
cursor: pointer;
}
.range-input::-webkit-slider-runnable-track {
height: 4px;
border-radius: 999px;
background: linear-gradient(
to right,
var(--accent) 0 var(--range-fill),
var(--line-strong) var(--range-fill) 100%
);
}
.range-input::-webkit-slider-thumb {
width: 14px;
height: 14px;
margin-top: -5px;
appearance: none;
-webkit-appearance: none;
border: 2px solid var(--accent);
border-radius: 50%;
background: var(--surface);
box-shadow: var(--shadow-sm);
transition: transform 130ms ease, box-shadow 130ms ease;
}
.range-input::-moz-range-track { height: 4px; border-radius: 999px; background: var(--line-strong); }
.range-input::-moz-range-progress { height: 4px; border-radius: 999px; background: var(--accent); }
.range-input::-moz-range-thumb {
width: 14px;
height: 14px;
border: 2px solid var(--accent);
border-radius: 50%;
background: var(--surface);
box-shadow: var(--shadow-sm);
transition: transform 130ms ease, box-shadow 130ms ease;
}
.range-input:hover::-webkit-slider-thumb { transform: scale(1.14); }
.range-input:hover::-moz-range-thumb { transform: scale(1.14); }
.range-input:focus-visible { outline: 0; }
.range-input:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 4px var(--accent-soft); }
.range-input:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 4px var(--accent-soft); }

/* Two columns keeps a 0-2 range from being stretched across the whole pane,
where a pixel of travel stops meaning anything. */
.settings-range-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 26px; }
.settings-subhead { display: grid; gap: 2px; margin: 4px 0 -4px; }
.settings-subhead strong { color: var(--text); font-size: var(--text-sm); font-weight: var(--weight-semi); }
.settings-subhead small { color: var(--text-faint); font-size: var(--text-2xs); line-height: 1.45; }
.settings-divider { height: 1px; margin: 4px 0 0; border: 0; background: var(--line); }

.toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 16px 0; border-bottom: 1px solid var(--line); }
.toggle-row span { display: grid; gap: 3px; }
.toggle-row strong { color: var(--text); font-size: 0.85rem; }
.toggle-row small { color: var(--text-muted); font-size: 0.75rem; line-height: 1.45; }
.toggle-row input { width: 42px; height: 23px; accent-color: var(--accent); }

/* A real switch. This stays an <input type="checkbox"> -- the look is painted
on, so the control keeps its native role, keyboard behaviour, and label
association rather than being rebuilt out of divs. */
.toggle-row input[type="checkbox"] {
position: relative;
flex: 0 0 auto;
width: 40px;
height: 22px;
margin: 0;
appearance: none;
-webkit-appearance: none;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: var(--surface-soft);
cursor: pointer;
transition: background 160ms ease, border-color 160ms ease;
}
.toggle-row input[type="checkbox"]::after {
content: "";
position: absolute;
top: 50%;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--text-faint);
transform: translateY(-50%);
transition: transform 180ms cubic-bezier(0.3, 0.7, 0.4, 1), background 160ms ease;
}
.toggle-row input[type="checkbox"]:checked { border-color: var(--accent); background: var(--accent); }
.toggle-row input[type="checkbox"]:checked::after { background: var(--surface); transform: translateY(-50%) translateX(18px); }
.toggle-row input[type="checkbox"]:focus-visible { outline: 0; box-shadow: 0 0 0 4px var(--accent-soft); }
.toggle-row input[type="checkbox"]:disabled { cursor: not-allowed; opacity: 0.5; }
.inline-form { display: flex; gap: 8px; margin-bottom: 17px; }
.inline-form input { flex: 1; }
.memory-list { display: grid; gap: 8px; padding: 0; margin: 0 0 20px; list-style: none; }
Expand Down Expand Up @@ -821,6 +932,7 @@ kbd { display: inline-block; border: 1px solid var(--line-strong); border-radius
.settings-tab small { display: none; }
.settings-pane { flex: 1 1 auto; padding: 24px 16px; }
.settings-field-row { grid-template-columns: 1fr; }
.settings-range-grid { grid-template-columns: 1fr; gap: 18px; }
.settings-dialog-footer { padding: 0 12px; }
.panel { padding: 0; }
.panel-heading { flex-direction: column; }
Expand Down
9 changes: 1 addition & 8 deletions tools/screenshots/capture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,7 @@ await page.getByRole("heading", { name: "Settings", exact: true }).first().waitF
await page.getByRole("button", { name: "AI Model" }).click();
await page.getByLabel("System instructions").waitFor({ state: "visible" });

// The panel is taller than the viewport. Model selection is already visible in
// the composer of the workspace shot, so frame this one on the lower half --
// the generation parameters, system instructions, and the system-prompt bypass.
await page.evaluate(() => {
const pane = document.querySelector(".settings-pane");
if (pane) pane.scrollTop = pane.scrollHeight;
});
await page.waitForTimeout(400);
await page.waitForTimeout(300);
await shot("settings");

// --- 3. Command palette over the workspace -----------------------------------
Expand Down
Loading