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
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
`/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
// 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
// again. The page keeps nothing you typed and no result, and posts nothing to
// the forge; the only thing the browser remembers is the result table's
// column filters, as for every table in the UI.
// again. Apart from the settings, the page keeps nothing you typed and no
// result, and posts nothing to the forge; the only thing the browser remembers
// is the result table's column filters, as for every table in the 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";
@ -24,6 +27,7 @@ import { Button } from "../../ui/button/Button.js";
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { Panel } from "../../ui/panel/Panel.js";
import { TextField } from "../../ui/text-field/TextField.js";
import { ClassifierSettings } from "../review-settings/ClassifierSettings.js";
import { ClassificationEditor } from "./ClassificationEditor.js";
import { TryOutResult } from "./TryOutResult.js";
import {
@ -399,6 +403,7 @@ export function ReviewTryOutPage() {
{result?.state === "done" ? <TryOutResult result={result} /> : null}
</Panel>
) : null}
<ClassifierSettings />
</div>
);
}