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.
249 lines
8.5 KiB
TypeScript
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>
|
|
);
|
|
}
|