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:
parent
37c58e79dc
commit
baf282131f
5 changed files with 293 additions and 3 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue