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.
127 lines
4 KiB
TypeScript
127 lines
4 KiB
TypeScript
// <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>
|
|
);
|
|
}
|