diff --git a/app/(main)/guardrails/page.tsx b/app/(main)/guardrails/page.tsx index f731de71..dd4ec8c0 100644 --- a/app/(main)/guardrails/page.tsx +++ b/app/(main)/guardrails/page.tsx @@ -17,10 +17,8 @@ import { SavedValidatorConfig, OrgContext, } from "@/app/lib/types/guardrails"; -import { buildValidatorUpdatePayload } from "@/app/lib/utils/guardrails"; import ValidatorConfigPanel from "@/app/components/guardrails/ValidatorConfigPanel"; import SavedConfigsList from "@/app/components/guardrails/SavedConfigsList"; -import DeleteConfigModal from "@/app/components/guardrails/DeleteConfigModal"; export default function GuardrailsPage() { const { sidebarCollapsed } = useApp(); @@ -38,8 +36,6 @@ export default function GuardrailsPage() { const [selectedSavedConfig, setSelectedSavedConfig] = useState(null); const [isSaving, setIsSaving] = useState(false); - const [configPendingDelete, setConfigPendingDelete] = - useState(null); useEffect(() => { if (!isHydrated) return; @@ -122,40 +118,11 @@ export default function GuardrailsPage() { setSelectedSavedConfig(null); }; - const handleRequestDeleteConfig = (configId: string) => { - const cfg = savedConfigs.find((c) => c.id === configId); - if (cfg) setConfigPendingDelete(cfg); - }; - - const handleConfirmDeleteConfig = async () => { - if (!configPendingDelete) return; - if (!configsQueryString) { - setConfigPendingDelete(null); - return; - } - const configId = configPendingDelete.id; - setConfigPendingDelete(null); - try { - await guardrailsFetch( - `/api/guardrails/validators/configs/${configId}${configsQueryString}`, - apiKey, - { method: "DELETE" }, - ); - toast.success("Config deleted"); - if (selectedSavedConfig?.id === configId) { - handleClearForm(); - } - fetchSavedConfigs(); - } catch { - toast.error("Failed to delete config"); - } - }; - - const handleSaveConfig = async ( - name: string, - configValues: Record, - ) => { - if (!name.trim()) { + const handleSaveConfig = async (configValues: Record) => { + if (selectedSavedConfig) return; + const name = + typeof configValues.name === "string" ? configValues.name.trim() : ""; + if (!name) { toast.error("Please enter a config name"); return; } @@ -165,33 +132,18 @@ export default function GuardrailsPage() { } setIsSaving(true); try { - const isUpdate = !!selectedSavedConfig; - const base = `/api/guardrails/validators/configs`; - const url = isUpdate - ? `${base}/${selectedSavedConfig!.id}${configsQueryString}` - : `${base}${configsQueryString}`; - - const body = isUpdate - ? buildValidatorUpdatePayload(configValues) - : configValues; - - await guardrailsFetch(url, apiKey, { - method: isUpdate ? "PATCH" : "POST", - body: JSON.stringify(body), - }); - toast.success( - isUpdate ? `Config "${name}" updated` : `Config "${name}" saved`, + await guardrailsFetch( + `/api/guardrails/validators/configs${configsQueryString}`, + apiKey, + { + method: "POST", + body: JSON.stringify(configValues), + }, ); - const savedConfigId = selectedSavedConfig?.id; - const freshList = await fetchSavedConfigs(); - if (isUpdate && savedConfigId) { - setSelectedSavedConfig( - freshList.find((c) => c.id === savedConfigId) ?? null, - ); - } else { - setSelectedSavedConfig(null); - setSelectedValidatorType(null); - } + toast.success(`Config "${name}" saved`); + await fetchSavedConfigs(); + setSelectedSavedConfig(null); + setSelectedValidatorType(null); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to save config"); } finally { @@ -234,7 +186,6 @@ export default function GuardrailsPage() { isLoading={savedConfigsLoading} selectedConfigId={selectedSavedConfig?.id ?? null} onSelectConfig={handleSelectSavedConfig} - onDeleteConfig={handleRequestDeleteConfig} onNewConfig={handleClearForm} /> @@ -250,17 +201,11 @@ export default function GuardrailsPage() { isSaving={isSaving} onSave={handleSaveConfig} onClear={handleClearForm} + readOnly={!!selectedSavedConfig} /> - - setConfigPendingDelete(null)} - onConfirm={handleConfirmDeleteConfig} - /> ); } diff --git a/app/api/guardrails/validators/configs/[config_id]/route.ts b/app/api/guardrails/validators/configs/[config_id]/route.ts deleted file mode 100644 index 2fb1aa02..00000000 --- a/app/api/guardrails/validators/configs/[config_id]/route.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { guardrailsClient } from "@/app/lib/guardrailsClient"; -import { buildValidatorConfigEndpoint } from "@/app/lib/utils/guardrails"; -import { NextResponse, NextRequest } from "next/server"; - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ config_id: string }> }, -) { - try { - const { config_id } = await params; - const { status, data } = await guardrailsClient( - request, - buildValidatorConfigEndpoint(request, config_id), - ); - return NextResponse.json(data, { status }); - } catch (e: unknown) { - return NextResponse.json( - { error: e instanceof Error ? e.message : String(e) }, - { status: 500 }, - ); - } -} - -export async function PATCH( - request: NextRequest, - { params }: { params: Promise<{ config_id: string }> }, -) { - try { - const { config_id } = await params; - const body = await request.json(); - const { status, data } = await guardrailsClient( - request, - buildValidatorConfigEndpoint(request, config_id), - { - method: "PATCH", - body: JSON.stringify(body), - }, - ); - return NextResponse.json(data, { status }); - } catch (e: unknown) { - return NextResponse.json( - { error: e instanceof Error ? e.message : String(e) }, - { status: 500 }, - ); - } -} - -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ config_id: string }> }, -) { - try { - const { config_id } = await params; - const { status, data } = await guardrailsClient( - request, - buildValidatorConfigEndpoint(request, config_id), - { - method: "DELETE", - }, - ); - return NextResponse.json(data, { status }); - } catch (e: unknown) { - return NextResponse.json( - { error: e instanceof Error ? e.message : String(e) }, - { status: 500 }, - ); - } -} diff --git a/app/components/guardrails/BanListField.tsx b/app/components/guardrails/BanListField.tsx index 9b2642f6..7a07e62a 100644 --- a/app/components/guardrails/BanListField.tsx +++ b/app/components/guardrails/BanListField.tsx @@ -1,18 +1,13 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import BanListModal from "./BanListModal"; import { guardrailsFetch } from "@/app/lib/guardrailsClient"; import { useAuth } from "@/app/lib/context/AuthContext"; import { Loader, Select } from "@/app/components/ui"; +import { CheckLineIcon, CopyIcon } from "@/app/components/icons"; +import { BanList, BanListFieldProps } from "@/app/lib/types/guardrails"; -interface BanList { - id: string; - name: string; -} - -interface BanListFieldProps { - value: string | null; - onChange: (id: string | null) => void; -} +const WORDS_MIN_PX = 44; +const WORDS_MAX_PX = 220; export default function BanListField({ value, onChange }: BanListFieldProps) { const { activeKey } = useAuth(); @@ -24,6 +19,31 @@ export default function BanListField({ value, onChange }: BanListFieldProps) { const [bannedWords, setBannedWords] = useState([]); const [wordsLoading, setWordsLoading] = useState(false); const [wordsError, setWordsError] = useState(null); + const [copied, setCopied] = useState(false); + const wordsRef = useRef(null); + + const autoSize = ( + el: HTMLTextAreaElement | null, + minPx: number, + maxPx: number, + ) => { + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(Math.max(el.scrollHeight, minPx), maxPx)}px`; + }; + + const wordsAsText = bannedWords.join(", "); + + const handleCopy = async () => { + if (!wordsAsText) return; + try { + await navigator.clipboard.writeText(wordsAsText); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // ignore + } + }; const fetchBanLists = () => { setLoading(true); @@ -87,6 +107,10 @@ export default function BanListField({ value, onChange }: BanListFieldProps) { } }, [value]); + useEffect(() => { + autoSize(wordsRef.current, WORDS_MIN_PX, WORDS_MAX_PX); + }, [wordsAsText, wordsLoading]); + const handleChange = (e: React.ChangeEvent) => { onChange(e.target.value || null); }; @@ -168,15 +192,43 @@ export default function BanListField({ value, onChange }: BanListFieldProps) { } if (bannedWords.length > 0) { return ( -
- {bannedWords.map((word) => ( +
+
+ + {bannedWords.length} word{bannedWords.length !== 1 ? "s" : ""} · + select text to copy, or use the copy button + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleCopy(); + } + }} + aria-label={copied ? "Copied" : "Copy banned words"} + title={copied ? "Copied" : "Copy comma-separated list"} + className="shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium text-accent-primary bg-accent-primary/5 hover:bg-accent-primary/10 transition-colors cursor-pointer" > - {word} + {copied ? ( + + ) : ( + + )} + {copied ? "Copied" : "Copy"} - ))} +
+