Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: the review try-out page shows and changes the review classifier's settings

A classifier settings panel under the try-out form reads and saves the model,
effort and how many requested reviews run at once through the settings route.
A save replaces all three; a refused one shows the route's reason and leaves
the form as typed. There is no API key field.
This commit is contained in:
iris 2026-10-12 20:04:15 +02:00 • committed by mara
commit baf282131f
5 changed files with 293 additions and 3 deletions

View file

@ -116,6 +116,20 @@ again. The page saves no classification and no result, and posts nothing to the
forge; like every table in the UI, the result table remembers its column forge; like every table in the UI, the result table remembers its column
filters in the browser. It shows no token counts, cache counts or cost. filters in the browser. It shows no token counts, cache counts or cost.
### Classifier settings
Under the try-out form, **classifier settings** shows the model and effort the
[review classifier](review-classifier.md) runs with and how many requested
reviews run at once, and lets you change them. The model is free text, one word
as claude's `--model` takes it, such as `haiku` or `sonnet`. The effort is one
of low, medium, high, xhigh and max, or claude's own default. Concurrent
reviews is a whole number from 1 to 8; try-outs don't count toward it.
**save** is on once something changed and the fields are valid. A save replaces
all three settings. If the controller refuses it, the page shows the reason and
leaves the form as you typed it. A change takes effect from the next run, with
no restart. The page has no API key field: admins set the key in the bao UI.
### Scheduled prompts ### Scheduled prompts
`/schedules` has one card per scheduled prompt. The **name** is the card's `/schedules` has one card per scheduled prompt. The **name** is the card's

View file

@ -0,0 +1,25 @@
/* The settings form: the three fields on one line, then the save action. */
.ui-classifier-settings {
display: flex;
flex-direction: column;
gap: 1em;
margin-top: 1em;
}
.ui-classifier-settings-fields {
display: flex;
flex-wrap: wrap;
gap: 0.75em;
}
.ui-classifier-settings-fields > * {
flex: 1 1 14em;
}
.ui-classifier-settings-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75em;
}
.ui-classifier-settings-note {
margin: 0;
color: var(--muted);
}

View file

@ -0,0 +1,167 @@
// <ClassifierSettings> — the review classifier's model, effort and how many
// requested reviews run at once, shown and saved here
// (`GET`/`PUT /api/review-classifier/settings`). A PUT replaces the stored
// settings whole, so the form sends all three fields. A refused save shows the
// route's own reason and leaves the form as typed. There is no API key field:
// admins set the key in the bao UI.
import { useEffect, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Button } from "../../ui/button/Button.js";
import { Panel } from "../../ui/panel/Panel.js";
import { SelectField } from "../../ui/select-field/SelectField.js";
import { TextField } from "../../ui/text-field/TextField.js";
import {
EFFORTS,
MAX_REVIEWS,
MIN_REVIEWS,
changed,
draftOf,
settingsProblem,
toSettings,
type ClassifierSettingsBody,
type SettingsDraft,
} from "./SettingsTypes.js";
import "./ClassifierSettings.css";
const SETTINGS = "/api/review-classifier/settings";
const EFFORT_OPTIONS = [
{ value: "", label: "claude's default" },
...EFFORTS.map((e) => ({ value: e, label: e })),
];
export function ClassifierSettings() {
// The settings as the controller holds them; null until the first read.
const [saved, setSaved] = useState<ClassifierSettingsBody | null>(null);
const [draft, setDraft] = useState<SettingsDraft | null>(null);
const [loadError, setLoadError] = useState<ProblemDetails | null>(null);
const [saveError, setSaveError] = useState<ProblemDetails | null>(null);
const [saving, setSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const res = await fetch(SETTINGS);
if (cancelled) return;
if (!res.ok) {
setLoadError(await readApiError(res));
return;
}
const body = (await res.json()) as ClassifierSettingsBody;
if (cancelled) return;
setSaved(body);
setDraft(draftOf(body));
} catch (e: unknown) {
if (!cancelled) setLoadError({ detail: String(e) });
}
})();
return () => {
cancelled = true;
};
}, []);
function edit(next: SettingsDraft) {
setDraft(next);
setJustSaved(false);
}
async function save(e: Event) {
e.preventDefault();
if (draft === null || settingsProblem(draft) !== null) return;
setSaving(true);
setSaveError(null);
setJustSaved(false);
try {
const res = await fetch(SETTINGS, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(toSettings(draft)),
});
if (!res.ok) {
// The form stays as typed, so the operator can fix it and save again.
setSaveError(await readApiError(res));
return;
}
const body = (await res.json()) as ClassifierSettingsBody;
setSaved(body);
setDraft(draftOf(body));
setJustSaved(true);
} catch (err: unknown) {
setSaveError({ detail: String(err) });
} finally {
setSaving(false);
}
}
const problem = draft === null ? null : settingsProblem(draft);
return (
<Panel title="classifier settings" icon="⚙️">
<p>
The model and effort the review classifier runs with, and how many
requested reviews run at once. A change takes effect from the next run,
with no restart. Try-outs don't count toward the limit. The API key is
set in the bao UI, not here.
</p>
{loadError ? (
<ApiErrorPanel
context="the settings could not be loaded"
problem={loadError}
/>
) : null}
{draft ? (
<form class="ui-classifier-settings" onSubmit={(e) => void save(e)}>
<div class="ui-classifier-settings-fields">
<TextField
id="classifier-model"
label="model"
value={draft.model}
placeholder="haiku"
onInput={(model) => edit({ ...draft, model })}
/>
<SelectField
id="classifier-effort"
label="effort"
value={draft.effort}
options={EFFORT_OPTIONS}
onChange={(effort) => edit({ ...draft, effort })}
/>
<TextField
id="classifier-max"
label={`concurrent reviews (${MIN_REVIEWS} to ${MAX_REVIEWS})`}
value={draft.max}
pattern="[0-9]+"
onInput={(max) => edit({ ...draft, max })}
/>
</div>
{saveError ? (
<ApiErrorPanel
context="the settings were not saved"
problem={saveError}
/>
) : null}
<div class="ui-classifier-settings-actions">
<Button
variant="primary"
type="submit"
disabled={saving || problem !== null || !changed(draft, saved)}
>
{saving ? "saving…" : "save"}
</Button>
{justSaved ? (
<span class="ui-classifier-settings-note" role="status">
Saved.
</span>
) : null}
</div>
{problem !== null ? (
<p class="ui-classifier-settings-note">{problem}</p>
) : null}
</form>
) : null}
</Panel>
);
}

View file

@ -0,0 +1,79 @@
// Wire shape of the review classifier's settings
// (`GET`/`PUT /api/review-classifier/settings`), and the checks the form
// makes before a save is worth sending. The backend stays the real gate:
// these only keep an obviously bad form from a round trip.
export const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
export type Effort = (typeof EFFORTS)[number];
/** The settings in force. A PUT replaces them whole. */
export interface ClassifierSettingsBody {
model: string;
/** `null` is claude's own default, which passes no `--effort`. */
effort: Effort | null;
max_concurrent_reviews: number;
}
/** The form's fields as the user typed them. */
export interface SettingsDraft {
model: string;
/** An effort, or `""` for claude's default. */
effort: string;
max: string;
}
export const MIN_REVIEWS = 1;
export const MAX_REVIEWS = 8;
export function draftOf(s: ClassifierSettingsBody): SettingsDraft {
return {
model: s.model,
effort: s.effort ?? "",
max: String(s.max_concurrent_reviews),
};
}
/** The reviews limit as a number, or null when it is not a whole number in
* range. */
export function maxValue(text: string): number | null {
const t = text.trim();
if (!/^\d+$/.test(t)) return null;
const n = Number(t);
return n >= MIN_REVIEWS && n <= MAX_REVIEWS ? n : null;
}
/** Why the draft is not ready to save, or null when it is. */
export function settingsProblem(d: SettingsDraft): string | null {
const model = d.model.trim();
if (model === "") return "give a model";
if (/\s/.test(model) || model.startsWith("-")) {
return "a model is one word, as claude's --model takes it, and does not start with -";
}
if (maxValue(d.max) === null) {
return `concurrent reviews is a whole number from ${MIN_REVIEWS} to ${MAX_REVIEWS}`;
}
return null;
}
/** The draft as the request body. Call only when `settingsProblem` is null. */
export function toSettings(d: SettingsDraft): ClassifierSettingsBody {
return {
model: d.model.trim(),
effort: d.effort === "" ? null : (d.effort as Effort),
max_concurrent_reviews: maxValue(d.max) ?? MIN_REVIEWS,
};
}
/** Whether `d` would save something other than `saved`. */
export function changed(
d: SettingsDraft,
saved: ClassifierSettingsBody | null,
): boolean {
if (saved === null) return true;
const now = draftOf(saved);
return (
d.model.trim() !== now.model ||
d.effort !== now.effort ||
d.max.trim() !== now.max
);
}

View file

@ -13,10 +13,13 @@
// **load the repo's rules** fills the rows from the rules on the repo's default // **load the repo's rules** fills the rows from the rules on the repo's default
// branch (`GET /api/review-classifier/rules`), to run as they are or to edit. // branch (`GET /api/review-classifier/rules`), to run as they are or to edit.
// //
// Under the form, the classifier's own settings: its model, effort and review
// concurrency (`ClassifierSettings`).
//
// The inputs stay as typed after a run, so the operator can edit and run // The inputs stay as typed after a run, so the operator can edit and run
// again. The page keeps nothing you typed and no result, and posts nothing to // again. Apart from the settings, the page keeps nothing you typed and no
// the forge; the only thing the browser remembers is the result table's // result, and posts nothing to the forge; the only thing the browser remembers
// column filters, as for every table in the UI. // is the result table's column filters, as for every table in the UI.
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
@ -24,6 +27,7 @@ import { Button } from "../../ui/button/Button.js";
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js"; import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { Panel } from "../../ui/panel/Panel.js"; import { Panel } from "../../ui/panel/Panel.js";
import { TextField } from "../../ui/text-field/TextField.js"; import { TextField } from "../../ui/text-field/TextField.js";
import { ClassifierSettings } from "../review-settings/ClassifierSettings.js";
import { ClassificationEditor } from "./ClassificationEditor.js"; import { ClassificationEditor } from "./ClassificationEditor.js";
import { TryOutResult } from "./TryOutResult.js"; import { TryOutResult } from "./TryOutResult.js";
import { import {
@ -399,6 +403,7 @@ export function ReviewTryOutPage() {
{result?.state === "done" ? <TryOutResult result={result} /> : null} {result?.state === "done" ? <TryOutResult result={result} /> : null}
</Panel> </Panel>
) : null} ) : null}
<ClassifierSettings />
</div> </div>
); );
} }