Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/review-settings/ClassifierSettings.tsx
iris a68d73fa74 swarm ui: one review page
The two review tabs, runs and try-out, are one: /review. It is a column of
three sections: try a classification and the classifier settings, each a
folded bar (the settings' bar says what they are now), then the recent runs,
always open. The settings had been pinned under the try-out form; they are now
a section of their own. A new Disclosure primitive is the folding bar, a
native details wearing the Panel's classes, so the folded and open sections
read as the same surface.

/review-runs and /review-tryout are gone, with no redirect.
2026-10-15 03:38:48 +02:00

249 lines
8.5 KiB
TypeScript

// <ClassifierSettings> — the review classifier's runtime, model, effort and
// how many requested reviews run at once, shown and saved here
// (`GET`/`PUT /api/review-classifier/settings`). A PUT changes only the
// fields it carries: the form sends the runtime, model, effort and limit, and
// the ACP provider only while the runtime is ACP, so the stored provider
// survives a switch back. A refused save shows the route's own reason and
// leaves the form as typed. There is no API key field: admins set each
// runtime's 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 { Disclosure } from "../../ui/disclosure/Disclosure.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,
settingsSummary,
toSettings,
type ClassifierSettingsBody,
type Runtime,
type SettingsDraft,
} from "./SettingsTypes.js";
import "./ClassifierSettings.css";
const SETTINGS = "/api/review-classifier/settings";
const RUNTIME_OPTIONS = [
{ value: "api-key", label: "API key (claude)" },
{ value: "acp", label: "ACP (opencode)" },
];
// The empty effort option is the runtime's own default: claude's on the API
// key runtime, opencode's on ACP.
function effortOptions(runtime: Runtime) {
return [
{
value: "",
label: runtime === "acp" ? "opencode's default" : "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);
// Folded until wanted: the settings are changed now and then, and the
// folded header says what they are.
const [open, setOpen] = 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 (
<Disclosure
title="classifier settings"
icon="⚙️"
hint={saved ? settingsSummary(saved) : undefined}
open={open}
onToggle={setOpen}
>
<p>
The runtime, model and effort the review classifier runs with, and how
many requested reviews run at once. The API key runtime runs claude on
the Anthropic key; the ACP runtime runs opencode on the provider given
here. A change takes effect from the next run, with no restart. Try-outs
don't count toward the limit. Each runtime's 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">
<SelectField
id="classifier-runtime"
label="runtime"
value={draft.runtime}
options={RUNTIME_OPTIONS}
onChange={(runtime) =>
edit({ ...draft, runtime: runtime as Runtime })
}
/>
{draft.runtime === "api-key" ? (
<TextField
id="classifier-model"
label="model"
value={draft.model}
placeholder="haiku"
onInput={(model) => edit({ ...draft, model })}
/>
) : null}
<SelectField
id="classifier-effort"
label="effort"
value={draft.effort}
options={effortOptions(draft.runtime)}
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>
{draft.runtime === "acp" ? (
<div class="ui-classifier-settings-fields">
<TextField
id="classifier-acp-url"
label="provider URL"
value={draft.acpUrl}
placeholder="https://llm.example/v1"
onInput={(acpUrl) => edit({ ...draft, acpUrl })}
/>
<TextField
id="classifier-acp-model"
label="provider model"
value={draft.acpModel}
placeholder="Qwen/Qwen3-35B"
onInput={(acpModel) => edit({ ...draft, acpModel })}
/>
<TextField
id="classifier-acp-price-input"
label="input price (USD per million tokens)"
value={draft.priceInput}
onInput={(priceInput) => edit({ ...draft, priceInput })}
/>
<TextField
id="classifier-acp-price-output"
label="output price (USD per million tokens)"
value={draft.priceOutput}
onInput={(priceOutput) => edit({ ...draft, priceOutput })}
/>
<TextField
id="classifier-acp-price-cache-read"
label="cache read price (optional)"
value={draft.priceCacheRead}
onInput={(priceCacheRead) => edit({ ...draft, priceCacheRead })}
/>
<TextField
id="classifier-acp-price-cache-write"
label="cache write price (optional)"
value={draft.priceCacheWrite}
onInput={(priceCacheWrite) =>
edit({ ...draft, priceCacheWrite })
}
/>
</div>
) : null}
{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}
</Disclosure>
);
}