swarm-ui: set the review classifier's keys from the classifier settings
The classifier's Anthropic and ACP provider keys were written by hand in the
secret store's UI. The controller now stores them: GET
/api/review-classifier/keys says whether each is set and when it changed, and
PUT /api/review-classifier/keys/{runtime} writes one. No route answers a key.
The controller's store policy gains create and update on the two leaves with
no read of their values, and a metadata read for the set state. The swarm UI's
classifier settings card gets a key form beside the settings, saved apart
because it goes to the secret store.
This commit is contained in:
parent
c5d601e3a2
commit
6875a4f0f8
1 changed files with 628 additions and 20 deletions
|
|
@ -0,0 +1,127 @@
|
|||
// <ClassifierKey> — the key the chosen runtime runs on, set from here instead
|
||||
// of in the bao UI (`GET /api/review-classifier/keys`, `PUT .../keys/{runtime}`).
|
||||
// The page can say whether a key is stored and when it changed, never what it
|
||||
// is: the field takes a new key and is cleared once it's saved. A key is
|
||||
// saved apart from the settings, because it goes to the secret store and not
|
||||
// into the settings document.
|
||||
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 { ActionRow } from "../../ui/action-row/ActionRow.js";
|
||||
import { Button } from "../../ui/button/Button.js";
|
||||
import { Hint } from "../../ui/hint/Hint.js";
|
||||
import { TextField } from "../../ui/text-field/TextField.js";
|
||||
import { keyOf, keyProblem, keyStatus, type KeysBody } from "./KeyTypes.js";
|
||||
import type { Runtime } from "./SettingsTypes.js";
|
||||
|
||||
const KEYS = "/api/review-classifier/keys";
|
||||
|
||||
export function ClassifierKey({ runtime }: { runtime: Runtime }) {
|
||||
const [keys, setKeys] = useState<KeysBody | null>(null);
|
||||
const [loadError, setLoadError] = useState<ProblemDetails | null>(null);
|
||||
const [saveError, setSaveError] = useState<ProblemDetails | null>(null);
|
||||
// The key as typed. Never kept after a save.
|
||||
const [text, setText] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(KEYS);
|
||||
if (cancelled) return;
|
||||
if (!res.ok) {
|
||||
setLoadError(await readApiError(res));
|
||||
return;
|
||||
}
|
||||
const body = (await res.json()) as KeysBody;
|
||||
if (!cancelled) setKeys(body);
|
||||
} catch (e: unknown) {
|
||||
if (!cancelled) setLoadError({ detail: String(e) });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// A key typed for one runtime must not be saved to the other.
|
||||
useEffect(() => {
|
||||
setText("");
|
||||
setSaveError(null);
|
||||
setJustSaved(false);
|
||||
}, [runtime]);
|
||||
|
||||
const problem = keyProblem(text);
|
||||
|
||||
async function save(e: Event) {
|
||||
e.preventDefault();
|
||||
if (problem !== null) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
setJustSaved(false);
|
||||
try {
|
||||
const res = await fetch(`${KEYS}/${runtime}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ api_key: text.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setSaveError(await readApiError(res));
|
||||
return;
|
||||
}
|
||||
setKeys((await res.json()) as KeysBody);
|
||||
setText("");
|
||||
setJustSaved(true);
|
||||
} catch (err: unknown) {
|
||||
setSaveError({ detail: String(err) });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const label = runtime === "acp" ? "provider key" : "Anthropic API key";
|
||||
return (
|
||||
<form class="ui-classifier-settings" onSubmit={(e) => void save(e)}>
|
||||
{loadError ? (
|
||||
<ApiErrorPanel
|
||||
context="the key could not be read"
|
||||
problem={loadError}
|
||||
/>
|
||||
) : null}
|
||||
<TextField
|
||||
id="classifier-key"
|
||||
label={`${label}${keys ? ` (${keyStatus(keyOf(keys, runtime))})` : ""}`}
|
||||
type="password"
|
||||
value={text}
|
||||
placeholder={
|
||||
keys && keyOf(keys, runtime).set
|
||||
? "paste a new key to replace it"
|
||||
: "paste the key"
|
||||
}
|
||||
onInput={(t) => {
|
||||
setText(t);
|
||||
setJustSaved(false);
|
||||
}}
|
||||
/>
|
||||
{saveError ? (
|
||||
<ApiErrorPanel context="the key was not saved" problem={saveError} />
|
||||
) : null}
|
||||
<ActionRow>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={saving || problem !== null}
|
||||
>
|
||||
{saving ? "saving…" : "save key"}
|
||||
</Button>
|
||||
{justSaved ? (
|
||||
<Hint as="span" role="status">
|
||||
Saved. The next run uses it.
|
||||
</Hint>
|
||||
) : null}
|
||||
</ActionRow>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue