Spaces:
Runtime error
Runtime error
| "use client"; | |
| import { useCallback, useEffect, useRef, useState } from "react"; | |
| import { Button, Card, Toggle } from "@/shared/components"; | |
| import { useNotificationStore } from "@/store/notificationStore"; | |
| import { useTranslations } from "next-intl"; | |
| type ModelLockoutSettings = { | |
| enabled: boolean; | |
| errorCodes: number[]; | |
| baseCooldownMs: number; | |
| maxCooldownMs: number; | |
| maxBackoffSteps: number; | |
| useExponentialBackoff: boolean; | |
| }; | |
| const DEFAULTS: ModelLockoutSettings = { | |
| enabled: false, | |
| errorCodes: [403, 404, 429, 502, 503, 504], | |
| baseCooldownMs: 120_000, | |
| maxCooldownMs: 1_800_000, | |
| maxBackoffSteps: 10, | |
| useExponentialBackoff: true, | |
| }; | |
| function NumberField({ | |
| label, | |
| value, | |
| suffix, | |
| min = 0, | |
| max, | |
| hint, | |
| onChange, | |
| }: { | |
| label: string; | |
| value: number; | |
| suffix?: string; | |
| min?: number; | |
| max?: number; | |
| hint?: string; | |
| onChange: (value: number) => void; | |
| }) { | |
| return ( | |
| <label className="flex flex-col gap-1"> | |
| <span className="flex items-center justify-between text-xs text-text-muted"> | |
| <span>{label}</span> | |
| {hint ? <span className="text-text-soft">{hint}</span> : null} | |
| </span> | |
| <div className="flex items-center gap-2"> | |
| <input | |
| type="number" | |
| min={min} | |
| max={max} | |
| value={value} | |
| onChange={(event) => { | |
| if (event.target.value === "") return; | |
| const nextValue = Number(event.target.value); | |
| if (Number.isFinite(nextValue)) { | |
| onChange(nextValue); | |
| } | |
| }} | |
| className="w-full rounded-lg border border-border bg-bg-subtle px-3 py-2 text-sm" | |
| /> | |
| {suffix ? <span className="text-xs text-text-muted">{suffix}</span> : null} | |
| </div> | |
| </label> | |
| ); | |
| } | |
| export default function ModelLockoutCard() { | |
| const t = useTranslations("settings"); | |
| const tc = useTranslations("common"); | |
| const notify = useNotificationStore(); | |
| const [data, setData] = useState<ModelLockoutSettings>(DEFAULTS); | |
| const [draft, setDraft] = useState<ModelLockoutSettings>(DEFAULTS); | |
| const [errorCodesInput, setErrorCodesInput] = useState(""); | |
| const [loading, setLoading] = useState(true); | |
| const [saving, setSaving] = useState(false); | |
| useEffect(() => { | |
| let mounted = true; | |
| const load = async () => { | |
| try { | |
| const res = await fetch("/api/settings", { cache: "no-store" }); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| const json = await res.json(); | |
| if (!mounted) return; | |
| const raw = (json as Record<string, unknown>).modelLockout as | |
| | Record<string, unknown> | |
| | undefined; | |
| const parsed: ModelLockoutSettings = { | |
| enabled: | |
| typeof raw?.enabled === "boolean" ? raw.enabled : DEFAULTS.enabled, | |
| errorCodes: Array.isArray(raw?.errorCodes) | |
| ? [...(raw.errorCodes as number[])].sort((a, b) => a - b) | |
| : [...DEFAULTS.errorCodes].sort((a, b) => a - b), | |
| baseCooldownMs: | |
| typeof raw?.baseCooldownMs === "number" | |
| ? raw.baseCooldownMs | |
| : DEFAULTS.baseCooldownMs, | |
| maxCooldownMs: | |
| typeof raw?.maxCooldownMs === "number" | |
| ? raw.maxCooldownMs | |
| : DEFAULTS.maxCooldownMs, | |
| maxBackoffSteps: | |
| typeof raw?.maxBackoffSteps === "number" | |
| ? raw.maxBackoffSteps | |
| : DEFAULTS.maxBackoffSteps, | |
| useExponentialBackoff: | |
| typeof raw?.useExponentialBackoff === "boolean" | |
| ? raw.useExponentialBackoff | |
| : DEFAULTS.useExponentialBackoff, | |
| }; | |
| setData(parsed); | |
| setDraft(parsed); | |
| setErrorCodesInput(""); | |
| } catch (error) { | |
| notify.error( | |
| error instanceof Error | |
| ? error.message | |
| : "Failed to load model lockout settings" | |
| ); | |
| } finally { | |
| if (mounted) setLoading(false); | |
| } | |
| }; | |
| void load(); | |
| return () => { | |
| mounted = false; | |
| }; | |
| }, []); | |
| const hasChanges = | |
| draft.enabled !== data.enabled || | |
| JSON.stringify([...draft.errorCodes].sort((a, b) => a - b)) !== | |
| JSON.stringify([...data.errorCodes].sort((a, b) => a - b)) || | |
| draft.baseCooldownMs !== data.baseCooldownMs || | |
| draft.maxCooldownMs !== data.maxCooldownMs || | |
| draft.useExponentialBackoff !== data.useExponentialBackoff || | |
| draft.maxBackoffSteps !== data.maxBackoffSteps; | |
| function validateDraft(d: ModelLockoutSettings): string | null { | |
| if (d.baseCooldownMs < 5000 || d.baseCooldownMs > 600000) | |
| return `Base Cooldown must be between 5,000ms and 600,000ms`; | |
| if (d.maxCooldownMs < 5000 || d.maxCooldownMs > 3600000) | |
| return `Max Cooldown must be between 5,000ms and 3,600,000ms`; | |
| if (d.maxCooldownMs < d.baseCooldownMs) | |
| return `Max Cooldown must be ≥ Base Cooldown`; | |
| if (d.maxBackoffSteps < 0 || d.maxBackoffSteps > 20) | |
| return `Max Backoff Steps must be between 0 and 20`; | |
| return null; | |
| } | |
| const handleSave = async () => { | |
| const validationError = validateDraft(draft); | |
| if (validationError) { | |
| notify.error(validationError); | |
| return; | |
| } | |
| setSaving(true); | |
| const saveDraft = { ...draft, errorCodes: draft.errorCodes }; | |
| try { | |
| const res = await fetch("/api/settings", { | |
| method: "PATCH", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ modelLockout: saveDraft }), | |
| }); | |
| if (!res.ok) { | |
| const err = await res.json().catch(() => null); | |
| const issues = err?.error?.issues ?? err?.error?.details; | |
| if (Array.isArray(issues) && issues.length > 0) { | |
| const fieldLabels: Record<string, string> = { | |
| "modelLockout.baseCooldownMs": "Base Cooldown", | |
| "modelLockout.maxCooldownMs": "Max Cooldown", | |
| "modelLockout.maxBackoffSteps": "Max Backoff Steps", | |
| "modelLockout.errorCodes": "Error Codes", | |
| }; | |
| const msg = issues | |
| .map( | |
| (d: { path?: (string | number)[]; message?: string }) => | |
| `${fieldLabels[String(d.path?.[0])] || String(d.path?.[0] || "")}: ${d.message}` | |
| ) | |
| .filter(Boolean) | |
| .join("\n"); | |
| if (msg) throw new Error(msg); | |
| } | |
| throw new Error( | |
| err?.error?.message || `HTTP ${res.status}` | |
| ); | |
| } | |
| const json = await res.json(); | |
| const raw = (json as Record<string, unknown>).modelLockout as | |
| | Record<string, unknown> | |
| | undefined; | |
| if (raw) { | |
| setData({ | |
| enabled: | |
| typeof raw.enabled === "boolean" ? raw.enabled : saveDraft.enabled, | |
| errorCodes: Array.isArray(raw.errorCodes) | |
| ? [...(raw.errorCodes as number[])].sort((a, b) => a - b) | |
| : [...saveDraft.errorCodes].sort((a, b) => a - b), | |
| baseCooldownMs: | |
| typeof raw.baseCooldownMs === "number" | |
| ? raw.baseCooldownMs | |
| : saveDraft.baseCooldownMs, | |
| maxCooldownMs: | |
| typeof raw.maxCooldownMs === "number" | |
| ? raw.maxCooldownMs | |
| : saveDraft.maxCooldownMs, | |
| maxBackoffSteps: | |
| typeof raw.maxBackoffSteps === "number" | |
| ? raw.maxBackoffSteps | |
| : saveDraft.maxBackoffSteps, | |
| useExponentialBackoff: | |
| typeof raw.useExponentialBackoff === "boolean" | |
| ? raw.useExponentialBackoff | |
| : saveDraft.useExponentialBackoff, | |
| }); | |
| } else { | |
| setData(saveDraft); | |
| } | |
| setErrorCodesInput(""); | |
| notify.success(t("savedSuccessfully") || "Settings saved successfully"); | |
| } catch (error) { | |
| notify.error( | |
| error instanceof Error | |
| ? error.message | |
| : "Failed to save model lockout settings" | |
| ); | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| const handleReset = () => { | |
| setDraft(data); | |
| setErrorCodesInput(""); | |
| }; | |
| const commitErrorCodes = (inputOverride?: string) => { | |
| const raw = inputOverride ?? errorCodesInput; | |
| const code = Number(raw); | |
| if (!Number.isFinite(code) || code < 100 || code > 599) return; | |
| if (draft.errorCodes.includes(code)) { | |
| setErrorCodesInput(""); | |
| return; | |
| } | |
| setDraft((prev) => ({ ...prev, errorCodes: [...prev.errorCodes, code].sort((a, b) => a - b) })); | |
| setErrorCodesInput(""); | |
| }; | |
| const removeErrorCode = (code: number) => { | |
| setDraft((prev) => ({ ...prev, errorCodes: prev.errorCodes.filter((c) => c !== code) })); | |
| }; | |
| const handleResetDefaults = () => { | |
| setDraft({ | |
| ...DEFAULTS, | |
| errorCodes: [...DEFAULTS.errorCodes].sort((a, b) => a - b), | |
| }); | |
| setErrorCodesInput(""); | |
| }; | |
| const fmt = (ms: number) => { | |
| if (ms >= 60_000) return `${ms / 1000 / 60}m`; | |
| if (ms >= 1_000) return `${ms / 1000}s`; | |
| return `${ms}ms`; | |
| }; | |
| const notifyRef = useRef<HTMLAudioElement | null>(null); | |
| const playNotify = useCallback(() => { | |
| try { | |
| if (notifyRef.current) { | |
| notifyRef.current.pause(); | |
| notifyRef.current.currentTime = 0; | |
| } else { | |
| notifyRef.current = new Audio("/audio/ui-notify.mp3"); | |
| notifyRef.current.volume = 0.3; | |
| } | |
| void notifyRef.current.play(); | |
| } catch { /* audio not available */ } | |
| }, []); | |
| if (loading) { | |
| return ( | |
| <Card className="p-6"> | |
| <div className="flex items-center gap-2 text-sm text-text-muted"> | |
| <span className="material-symbols-outlined animate-spin"> | |
| progress_activity | |
| </span> | |
| Loading model lockout settings... | |
| </div> | |
| </Card> | |
| ); | |
| } | |
| return ( | |
| <Card className="p-6"> | |
| <div className="mb-4 flex items-start justify-between gap-4"> | |
| <div className="space-y-2"> | |
| <div className="flex items-center gap-2"> | |
| <span className="material-symbols-outlined text-xl text-primary"> | |
| gpp_maybe | |
| </span> | |
| <h2 className="text-lg font-bold"> | |
| {t("modelLockout") || "Model Lockout"} | |
| </h2> | |
| </div> | |
| <p className="text-sm text-text-muted"> | |
| {t("modelLockoutPageDescription")} | |
| </p> | |
| </div> | |
| {hasChanges ? ( | |
| <div className="flex flex-wrap gap-2"> | |
| <Button size="sm" variant="secondary" onClick={handleReset}> | |
| {tc("cancel")} | |
| </Button> | |
| <Button | |
| size="sm" | |
| variant="primary" | |
| icon="save" | |
| onClick={handleSave} | |
| disabled={saving} | |
| > | |
| {saving ? tc("saving") || "Saving..." : tc("save")} | |
| </Button> | |
| </div> | |
| ) : ( | |
| <Button | |
| size="sm" | |
| variant="secondary" | |
| onClick={handleResetDefaults} | |
| > | |
| Reset defaults | |
| </Button> | |
| )} | |
| </div> | |
| <div className="space-y-5"> | |
| {/* Master toggle */} | |
| <div className="rounded-lg border border-border bg-bg-subtle px-4 py-3"> | |
| <Toggle | |
| checked={draft.enabled} | |
| onChange={(checked) => { | |
| setDraft((prev) => ({ ...prev, enabled: checked })); | |
| playNotify(); | |
| }} | |
| label={t("modelLockoutEnabled")} | |
| description={t("modelLockoutEnabledDescription")} | |
| /> | |
| </div> | |
| {/* Error codes — tag input */} | |
| <div className="rounded-lg border border-border bg-bg-subtle p-4"> | |
| <label className="flex flex-col gap-1.5"> | |
| <span className="text-sm font-medium text-text-main"> | |
| {t("modelLockoutErrorCodes")} | |
| </span> | |
| <span className="text-xs text-text-muted"> | |
| {t("modelLockoutErrorCodesDescription")} | |
| </span> | |
| </label> | |
| {/* Chips row */} | |
| {draft.errorCodes.length > 0 && ( | |
| <div className="mt-3 flex flex-wrap items-center gap-1.5"> | |
| {draft.errorCodes.map((code) => ( | |
| <span | |
| key={code} | |
| className="inline-flex items-center gap-1 rounded-md bg-primary/10 text-primary border border-primary/20 px-2 py-1 text-sm font-medium" | |
| > | |
| {code} | |
| <button | |
| type="button" | |
| onClick={() => removeErrorCode(code)} | |
| className="inline-flex size-4 items-center justify-center rounded-sm hover:bg-primary/20 transition-colors" | |
| aria-label={`Remove ${code}`} | |
| > | |
| <span className="material-symbols-outlined text-sm leading-none">close</span> | |
| </button> | |
| </span> | |
| ))} | |
| </div> | |
| )} | |
| {/* Input row */} | |
| <div className="mt-3 flex items-center gap-2"> | |
| <input | |
| type="text" | |
| inputMode="numeric" | |
| value={errorCodesInput} | |
| onChange={(e) => { | |
| const raw = e.target.value.replace(/[^0-9]/g, ""); | |
| if (raw.length <= 3) setErrorCodesInput(raw); | |
| }} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter") { | |
| e.preventDefault(); | |
| commitErrorCodes(); | |
| } | |
| }} | |
| placeholder="Add error code..." | |
| className="w-32 rounded-lg border border-border bg-bg px-3 py-2 text-sm outline-none focus:border-primary transition-colors placeholder:text-text-muted/50" | |
| /> | |
| <button | |
| type="button" | |
| onClick={() => commitErrorCodes()} | |
| disabled={!errorCodesInput.trim()} | |
| className="rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-muted hover:text-text-main hover:border-primary/40 transition-colors disabled:opacity-40 disabled:cursor-not-allowed" | |
| > | |
| Add | |
| </button> | |
| </div> | |
| {/* Suggested common codes — chips as clickable suggestions */} | |
| {draft.errorCodes.length === 0 && errorCodesInput === "" && ( | |
| <div className="mt-3 flex flex-wrap items-center gap-1.5"> | |
| <span className="text-xs text-text-muted mr-1">Suggestions:</span> | |
| {[403, 404, 429, 502, 503, 504].map((code) => ( | |
| <button | |
| key={code} | |
| type="button" | |
| onClick={() => commitErrorCodes(String(code))} | |
| className="inline-flex items-center px-2 py-0.5 rounded-full bg-surface text-xs text-text-muted border border-border/40 hover:border-primary/40 hover:text-primary transition-colors" | |
| > | |
| +{code} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| {/* Cooldowns - grid */} | |
| <div className="grid grid-cols-1 gap-4 md:grid-cols-2"> | |
| <div className="rounded-lg border border-border bg-bg-subtle p-4"> | |
| <NumberField | |
| label={t("modelLockoutBaseCooldown")} | |
| value={draft.baseCooldownMs} | |
| min={5000} | |
| max={600000} | |
| suffix="ms" | |
| hint="5,000ms — 600,000ms" | |
| onChange={(baseCooldownMs) => | |
| setDraft((prev) => ({ ...prev, baseCooldownMs })) | |
| } | |
| /> | |
| <p className="mt-1.5 text-xs text-text-muted"> | |
| {t("modelLockoutBaseCooldownDescription")} | |
| </p> | |
| </div> | |
| <div className="rounded-lg border border-border bg-bg-subtle p-4"> | |
| <NumberField | |
| label={t("modelLockoutMaxCooldown")} | |
| value={draft.maxCooldownMs} | |
| min={draft.baseCooldownMs} | |
| max={3600000} | |
| suffix="ms" | |
| hint="≥ Base Cooldown — 3,600,000ms" | |
| onChange={(maxCooldownMs) => | |
| setDraft((prev) => ({ ...prev, maxCooldownMs })) | |
| } | |
| /> | |
| <p className="mt-1.5 text-xs text-text-muted"> | |
| {t("modelLockoutMaxCooldownDescription")} | |
| </p> | |
| </div> | |
| </div> | |
| {/* Exponential backoff */} | |
| <div className="rounded-lg border border-border bg-bg-subtle px-4 py-3"> | |
| <Toggle | |
| checked={draft.useExponentialBackoff} | |
| onChange={(checked) => { | |
| setDraft((prev) => ({ | |
| ...prev, | |
| useExponentialBackoff: checked, | |
| })); | |
| playNotify(); | |
| }} | |
| label={t("modelLockoutExponentialBackoff")} | |
| description={t("modelLockoutExponentialBackoffDescription")} | |
| /> | |
| </div> | |
| {/* Max backoff steps */} | |
| <div className="rounded-lg border border-border bg-bg-subtle p-4"> | |
| <NumberField | |
| label={t("modelLockoutMaxBackoffSteps")} | |
| value={draft.maxBackoffSteps} | |
| min={0} | |
| onChange={(maxBackoffSteps) => | |
| setDraft((prev) => ({ ...prev, maxBackoffSteps })) | |
| } | |
| /> | |
| <p className="mt-1.5 text-xs text-text-muted"> | |
| {t("modelLockoutMaxBackoffStepsDescription")} | |
| </p> | |
| </div> | |
| </div> | |
| </Card> | |
| ); | |
| } | |