diff --git a/.env.example b/.env.example index 3e6995f..45397d4 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,6 @@ BACKEND_URL=http://localhost:8000 -NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com \ No newline at end of file + +GUARDRAILS_URL = http://localhost:8001 +GUARDRAILS_TOKEN = + +NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com diff --git a/app/(main)/coming-soon/guardrails/page.tsx b/app/(main)/coming-soon/guardrails/page.tsx deleted file mode 100644 index 771d966..0000000 --- a/app/(main)/coming-soon/guardrails/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Guardrails - Coming Soon Page - */ - -import ComingSoon from "@/app/components/ComingSoon"; - -export default function GuardrailsPage() { - return ( - - ); -} diff --git a/app/(main)/configurations/prompt-editor/page.tsx b/app/(main)/configurations/prompt-editor/page.tsx index 7e4b4e0..8f350be 100644 --- a/app/(main)/configurations/prompt-editor/page.tsx +++ b/app/(main)/configurations/prompt-editor/page.tsx @@ -294,6 +294,12 @@ function PromptEditorContent() { }), }, }, + ...(currentConfigBlob.input_guardrails?.length && { + input_guardrails: currentConfigBlob.input_guardrails, + }), + ...(currentConfigBlob.output_guardrails?.length && { + output_guardrails: currentConfigBlob.output_guardrails, + }), }; const existingConfigMeta = allConfigMeta.find( @@ -502,6 +508,7 @@ function PromptEditorContent() { isSaving={isSaving} collapsed={!showConfigPane} onToggle={() => setShowConfigPane(!showConfigPane)} + apiKey={activeKey?.key ?? ""} /> diff --git a/app/(main)/guardrails/page.tsx b/app/(main)/guardrails/page.tsx new file mode 100644 index 0000000..7ade59a --- /dev/null +++ b/app/(main)/guardrails/page.tsx @@ -0,0 +1,522 @@ +/** + * Guardrails — 2-panel layout: + * [LEFT: Config Form] | [RIGHT: Saved Configs List] + */ + +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import Sidebar from "@/app/components/Sidebar"; +import { colors } from "@/app/lib/colors"; +import { useApp } from "@/app/lib/context/AppContext"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import { useToast } from "@/app/components/Toast"; +import { apiFetch } from "@/app/lib/apiClient"; +import PageHeader from "@/app/components/PageHeader"; +import { + Validator, + SavedValidatorConfig, + formatValidatorName, +} from "@/app/components/guardrails/types"; +import ValidatorConfigPanel from "@/app/components/guardrails/ValidatorConfigPanel"; +import { TrashIcon } from "@/app/components/icons"; +import validatorMeta from "@/app/components/guardrails/validators.json"; + +interface OrgContext { + organization_id: number; + project_id: number; +} + +interface ValidatorMeta { + validator_type: string; + validator_name: string; + description: string; +} + +const metaMap: Record = ( + validatorMeta as ValidatorMeta[] +).reduce((acc, v) => ({ ...acc, [v.validator_type]: v }), {}); + +export default function GuardrailsPage() { + const { sidebarCollapsed } = useApp(); + const { activeKey, isHydrated } = useAuth(); + const toast = useToast(); + + // Org/project context from API key verification + const [orgContext, setOrgContext] = useState(null); + + // Available validators from API + const [validators, setValidators] = useState([]); + const [validatorsLoading, setValidatorsLoading] = useState(true); + + // Saved configs from API + const [savedConfigs, setSavedConfigs] = useState([]); + const [savedConfigsLoading, setSavedConfigsLoading] = useState(true); + + // Form state + const [selectedValidatorType, setSelectedValidatorType] = useState< + string | null + >(null); + const [selectedSavedConfig, setSelectedSavedConfig] = + useState(null); + const [isSaving, setIsSaving] = useState(false); + + // Step 1: verify API key → get org/project IDs + useEffect(() => { + if (!isHydrated) return; + if (!activeKey?.key) { + toast.error( + "No API key found. Please add your Kaapi API key in the Keystore.", + ); + return; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + apiFetch("/api/apikeys/verify", activeKey.key) + .then((data) => { + const org_id = data?.data?.organization_id; + const proj_id = data?.data?.project_id; + if (org_id != null && proj_id != null) { + setOrgContext({ organization_id: org_id, project_id: proj_id }); + } else { + toast.error("Could not determine organization/project from API key"); + } + }) + .catch((e: Error) => + toast.error( + e.message || + "API key verification failed — check your key in the Keystore", + ), + ); + }, [isHydrated, activeKey?.key]); + + // Step 2: fetch available validators (no auth needed for catalog) + useEffect(() => { + setValidatorsLoading(true); + fetch("/api/guardrails") + .then((r) => r.json()) + .then((data) => { + const list: Validator[] = Array.isArray(data?.validators) + ? data.validators + : []; + setValidators(list); + }) + .catch(() => toast.error("Failed to load validators")) + .finally(() => setValidatorsLoading(false)); + }, []); + + // Step 3: fetch saved configs once we have org/project context + const configsQueryString = orgContext + ? `?organization_id=${parseInt(String(orgContext.organization_id), 10)}&project_id=${parseInt(String(orgContext.project_id), 10)}` + : null; + + const fetchSavedConfigs = useCallback(() => { + if (!configsQueryString) return; + setSavedConfigsLoading(true); + fetch(`/api/guardrails/validators/configs${configsQueryString}`) + .then((r) => r.json()) + .then((data) => { + const list: SavedValidatorConfig[] = Array.isArray(data?.data?.configs) + ? data.data.configs + : Array.isArray(data?.data) + ? data.data + : Array.isArray(data?.configs) + ? data.configs + : Array.isArray(data) + ? data + : []; + setSavedConfigs(list); + }) + .catch(() => toast.error("Failed to load saved configs")) + .finally(() => setSavedConfigsLoading(false)); + }, [configsQueryString]); + + useEffect(() => { + fetchSavedConfigs(); + }, [fetchSavedConfigs]); + + // Load a saved config into the form + const handleSelectSavedConfig = (cfg: SavedValidatorConfig) => { + setSelectedSavedConfig(cfg); + setSelectedValidatorType(cfg.type); + }; + + // Reset the form + const handleClearForm = () => { + setSelectedValidatorType(null); + setSelectedSavedConfig(null); + }; + + const handleDeleteConfig = async (configId: string) => { + if (!configsQueryString) return; + try { + const res = await fetch( + `/api/guardrails/validators/configs/${configId}${configsQueryString}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Delete failed"); + 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()) { + toast.error("Please enter a config name"); + return; + } + if (!configsQueryString) { + toast.error("API key not verified yet"); + return; + } + setIsSaving(true); + try { + const isUpdate = !!selectedSavedConfig; + const url = isUpdate + ? `/api/guardrails/validators/configs/${selectedSavedConfig!.id}${configsQueryString}` + : `/api/guardrails/validators/configs${configsQueryString}`; + + // PATCH only accepts these five fields — strip everything else + const body = isUpdate + ? { + name: configValues.name, + type: configValues.type, + stage: configValues.stage, + on_fail_action: configValues.on_fail_action, + is_enabled: configValues.is_enabled, + } + : configValues; + + const res = await fetch(url, { + method: isUpdate ? "PATCH" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error( + err?.error ?? (isUpdate ? "Update failed" : "Save failed"), + ); + } + toast.success( + isUpdate ? `Config "${name}" updated` : `Config "${name}" saved`, + ); + fetchSavedConfigs(); + setSelectedSavedConfig(null); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to save config"); + } finally { + setIsSaving(false); + } + }; + + const existingValues = selectedSavedConfig + ? { + ...(selectedSavedConfig.config ?? {}), + stage: selectedSavedConfig.stage, + on_fail_action: selectedSavedConfig.on_fail_action, + is_enabled: selectedSavedConfig.is_enabled, + } + : null; + + return ( +
+ + +
+ {/* Page header */} + + + {/* 2-panel body */} +
+ {/* LEFT: Config form panel */} +
+ +
+ + {/* RIGHT: Saved configs list */} +
+ +
+
+
+
+ ); +} + +// ─── Saved Configs List Panel ──────────────────────────────────────────────── + +interface SavedConfigsListProps { + configs: SavedValidatorConfig[]; + isLoading: boolean; + selectedConfigId: string | null; + onSelectConfig: (cfg: SavedValidatorConfig) => void; + onDeleteConfig: (id: string) => void; + onNewConfig: () => void; +} + +function SavedConfigsList({ + configs, + isLoading, + selectedConfigId, + onSelectConfig, + onDeleteConfig, + onNewConfig, +}: SavedConfigsListProps) { + return ( +
+ {/* Panel header */} +
+
+
+ Saved Configurations +
+ {!isLoading && ( +
+ {configs.length} config{configs.length !== 1 ? "s" : ""} +
+ )} +
+ +
+ + {/* List */} +
+ {isLoading ? ( +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+ ) : configs.length === 0 ? ( +
+ + + +

+ No saved configurations yet +

+

+ Select a validator type on the left and save your first config +

+
+ ) : ( +
+ {configs.map((cfg) => { + const isSelected = selectedConfigId === cfg.id; + const displayName = + metaMap[cfg.type]?.validator_name ?? + formatValidatorName(cfg.type); + return ( +
onSelectConfig(cfg)} + > +
+ {/* Text + badges */} +
+
+ {cfg.name} +
+
+ + {displayName} + + {cfg.stage && ( + + {cfg.stage} + + )} + {cfg.on_fail_action && ( + + on fail: {cfg.on_fail_action} + + )} + {cfg.is_enabled === false && ( + + disabled + + )} +
+
+ + {/* Action buttons */} +
+ + +
+
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/app/api/apikeys/verify/route.ts b/app/api/apikeys/verify/route.ts new file mode 100644 index 0000000..faf3ee5 --- /dev/null +++ b/app/api/apikeys/verify/route.ts @@ -0,0 +1,14 @@ +import { apiClient } from "@/app/lib/apiClient"; +import { NextResponse, NextRequest } from "next/server"; + +export async function GET(request: NextRequest) { + try { + const { status, data } = await apiClient(request, "/api/v1/apikeys/verify"); + 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/api/guardrails/ban_lists/[ban_list_id]/route.ts b/app/api/guardrails/ban_lists/[ban_list_id]/route.ts new file mode 100644 index 0000000..18cf090 --- /dev/null +++ b/app/api/guardrails/ban_lists/[ban_list_id]/route.ts @@ -0,0 +1,67 @@ +import { guardrailsClient } from "@/app/lib/apiClient"; +import { NextResponse, NextRequest } from "next/server"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ ban_list_id: string }> }, +) { + try { + const { ban_list_id } = await params; + const { status, data } = await guardrailsClient( + request, + `/api/v1/guardrails/ban_lists/${ban_list_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 PUT( + request: NextRequest, + { params }: { params: Promise<{ ban_list_id: string }> }, +) { + try { + const { ban_list_id } = await params; + const body = await request.json(); + const { status, data } = await guardrailsClient( + request, + `/api/v1/guardrails/ban_lists/${ban_list_id}`, + { + method: "PUT", + 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<{ ban_list_id: string }> }, +) { + try { + const { ban_list_id } = await params; + const { status, data } = await guardrailsClient( + request, + `/api/v1/guardrails/ban_lists/${ban_list_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/api/guardrails/ban_lists/route.ts b/app/api/guardrails/ban_lists/route.ts new file mode 100644 index 0000000..4aa7124 --- /dev/null +++ b/app/api/guardrails/ban_lists/route.ts @@ -0,0 +1,37 @@ +import { guardrailsClient } from "@/app/lib/apiClient"; +import { NextResponse, NextRequest } from "next/server"; + +export async function GET(request: NextRequest) { + try { + const { status, data } = await guardrailsClient( + request, + "/api/v1/guardrails/ban_lists", + ); + return NextResponse.json(data, { status }); + } catch (e: unknown) { + return NextResponse.json( + { error: e instanceof Error ? e.message : String(e) }, + { status: 500 }, + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { status, data } = await guardrailsClient( + request, + "/api/v1/guardrails/ban_lists", + { + method: "POST", + 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 }, + ); + } +} diff --git a/app/api/guardrails/route.ts b/app/api/guardrails/route.ts new file mode 100644 index 0000000..51e6c76 --- /dev/null +++ b/app/api/guardrails/route.ts @@ -0,0 +1,30 @@ +import { guardrailsClient } from "@/app/lib/apiClient"; +import { NextResponse, NextRequest } from "next/server"; + +function getAuthHeader(): string | undefined { + const token = process.env.GUARDRAILS_TOKEN; + return token ? `Bearer ${token}` : undefined; +} + +export async function GET(request: NextRequest) { + const authHeader = getAuthHeader(); + if (!authHeader) { + return NextResponse.json( + { error: "Missing GUARDRAILS_TOKEN environment variable" }, + { status: 500 }, + ); + } + try { + const { status, data } = await guardrailsClient( + request, + "/api/v1/guardrails/", + { authHeader }, + ); + 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/api/guardrails/validators/configs/[config_id]/route.ts b/app/api/guardrails/validators/configs/[config_id]/route.ts new file mode 100644 index 0000000..358c500 --- /dev/null +++ b/app/api/guardrails/validators/configs/[config_id]/route.ts @@ -0,0 +1,107 @@ +import { guardrailsClient } from "@/app/lib/apiClient"; +import { NextResponse, NextRequest } from "next/server"; + +function getAuthHeader(): string | undefined { + const token = process.env.GUARDRAILS_TOKEN; + return token ? `Bearer ${token}` : undefined; +} + +function buildEndpoint(request: NextRequest, config_id: string): string { + const { searchParams } = new URL(request.url); + const params = new URLSearchParams(); + const organizationId = searchParams.get("organization_id"); + const projectId = searchParams.get("project_id"); + if (organizationId) params.append("organization_id", organizationId); + if (projectId) params.append("project_id", projectId); + const qs = params.toString(); + return `/api/v1/guardrails/validators/configs/${config_id}${qs ? `?${qs}` : ""}`; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ config_id: string }> }, +) { + const authHeader = getAuthHeader(); + if (!authHeader) { + return NextResponse.json( + { error: "Missing GUARDRAILS_TOKEN environment variable" }, + { status: 500 }, + ); + } + try { + const { config_id } = await params; + const { status, data } = await guardrailsClient( + request, + buildEndpoint(request, config_id), + { authHeader }, + ); + 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 }> }, +) { + const authHeader = getAuthHeader(); + if (!authHeader) { + return NextResponse.json( + { error: "Missing GUARDRAILS_TOKEN environment variable" }, + { status: 500 }, + ); + } + try { + const { config_id } = await params; + const body = await request.json(); + const { status, data } = await guardrailsClient( + request, + buildEndpoint(request, config_id), + { + method: "PATCH", + body: JSON.stringify(body), + authHeader, + }, + ); + 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 }> }, +) { + const authHeader = getAuthHeader(); + if (!authHeader) { + return NextResponse.json( + { error: "Missing GUARDRAILS_TOKEN environment variable" }, + { status: 500 }, + ); + } + try { + const { config_id } = await params; + const { status, data } = await guardrailsClient( + request, + buildEndpoint(request, config_id), + { + method: "DELETE", + authHeader, + }, + ); + 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/api/guardrails/validators/configs/route.ts b/app/api/guardrails/validators/configs/route.ts new file mode 100644 index 0000000..4b41529 --- /dev/null +++ b/app/api/guardrails/validators/configs/route.ts @@ -0,0 +1,69 @@ +import { guardrailsClient } from "@/app/lib/apiClient"; +import { NextResponse, NextRequest } from "next/server"; + +function getAuthHeader(): string | undefined { + const token = process.env.GUARDRAILS_TOKEN; + return token ? `Bearer ${token}` : undefined; +} + +function buildEndpoint(request: NextRequest): string { + const { searchParams } = new URL(request.url); + const params = new URLSearchParams(); + const organizationId = searchParams.get("organization_id"); + const projectId = searchParams.get("project_id"); + if (organizationId) params.append("organization_id", organizationId); + if (projectId) params.append("project_id", projectId); + const qs = params.toString(); + return `/api/v1/guardrails/validators/configs${qs ? `?${qs}` : ""}`; +} + +export async function GET(request: NextRequest) { + const authHeader = getAuthHeader(); + if (!authHeader) { + return NextResponse.json( + { error: "Missing GUARDRAILS_TOKEN environment variable" }, + { status: 500 }, + ); + } + try { + const { status, data } = await guardrailsClient( + request, + buildEndpoint(request), + { authHeader }, + ); + return NextResponse.json(data, { status }); + } catch (e: unknown) { + return NextResponse.json( + { error: e instanceof Error ? e.message : String(e) }, + { status: 500 }, + ); + } +} + +export async function POST(request: NextRequest) { + const authHeader = getAuthHeader(); + if (!authHeader) { + return NextResponse.json( + { error: "Missing GUARDRAILS_TOKEN environment variable" }, + { status: 500 }, + ); + } + try { + const body = await request.json(); + const { status, data } = await guardrailsClient( + request, + buildEndpoint(request), + { + method: "POST", + body: JSON.stringify(body), + authHeader, + }, + ); + 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/InfoTooltip.tsx b/app/components/InfoTooltip.tsx new file mode 100644 index 0000000..7a5af45 --- /dev/null +++ b/app/components/InfoTooltip.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useState } from "react"; +import { colors } from "@/app/lib/colors"; + +interface InfoTooltipProps { + text: string; +} + +export default function InfoTooltip({ text }: InfoTooltipProps) { + const [visible, setVisible] = useState(false); + return ( + + + {visible && ( +
+ {text} +
+ )} +
+ ); +} diff --git a/app/components/MultiSelect.tsx b/app/components/MultiSelect.tsx new file mode 100644 index 0000000..9d94f14 --- /dev/null +++ b/app/components/MultiSelect.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { colors } from "@/app/lib/colors"; + +interface MultiSelectProps { + options: string[]; + value: string[]; + onChange: (value: string[]) => void; + placeholder?: string; +} + +export default function MultiSelect({ + options, + value, + onChange, + placeholder, +}: MultiSelectProps) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const toggle = (opt: string) => { + if (value.includes(opt)) { + onChange(value.filter((v) => v !== opt)); + } else { + onChange([...value, opt]); + } + }; + + const remove = (opt: string, e: React.MouseEvent) => { + e.stopPropagation(); + onChange(value.filter((v) => v !== opt)); + }; + + const unselected = options.filter((o) => !value.includes(o)); + + return ( +
+ {/* Input box with tags */} +
setOpen((v) => !v)} + > + {/* Selected tags */} + {value.map((v) => ( + e.stopPropagation()} + > + {v} + + + ))} + + {/* Placeholder when nothing selected */} + {value.length === 0 && ( + + {placeholder ?? "Select options…"} + + )} + + {/* Chevron */} + + + +
+ + {/* Dropdown list */} + {open && ( +
+ {unselected.length === 0 ? ( +

+ All options selected +

+ ) : ( + unselected.map((opt) => ( + + )) + )} +
+ )} +
+ ); +} diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index e97d71c..41167ac 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -14,6 +14,8 @@ import { DocumentFileIcon, BookOpenIcon, GearIcon, + SlidersIcon, + ShieldCheckIcon, ChevronRightIcon, } from "@/app/components/icons"; import { LoginModal } from "@/app/components/auth"; @@ -74,6 +76,45 @@ export default function Sidebar({ localStorage.setItem("sidebar-expanded-menus", JSON.stringify(newState)); }; + const navItems: MenuItem[] = [ + { + name: "Evaluations", + icon: , + submenu: [ + { name: "Text", route: "/evaluations" }, + { name: "Speech-to-Text", route: "/speech-to-text" }, + { name: "Text-to-Speech", route: "/text-to-speech" }, + ], + }, + { + name: "Documents", + route: "/document", + icon: , + }, + { + name: "Knowledge Base", + route: "/knowledge-base", + icon: , + }, + { + name: "Configurations", + icon: , + submenu: [ + { name: "Library", route: "/configurations" }, + { name: "Prompt Editor", route: "/configurations/prompt-editor" }, + ], + }, + { + name: "Guardrails", + route: "/guardrails", + icon: , + }, + { + name: "Settings", + route: "/settings/credentials", + icon: , + }, + ]; const handleGateEnter = useCallback((name: string, el: HTMLElement) => { if (gateTimeoutRef.current) clearTimeout(gateTimeoutRef.current); setHoveredGate(name); diff --git a/app/components/guardrails/BanListModal.tsx b/app/components/guardrails/BanListModal.tsx new file mode 100644 index 0000000..d4fb7f9 --- /dev/null +++ b/app/components/guardrails/BanListModal.tsx @@ -0,0 +1,252 @@ +import { useState } from "react"; +import { colors } from "@/app/lib/colors"; + +interface BanListModalProps { + onClose: () => void; + onCreated: (banList: { id: string; name: string }) => void; + apiKey: string; +} + +export default function BanListModal({ + onClose, + onCreated, + apiKey, +}: BanListModalProps) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [bannedWords, setBannedWords] = useState(""); + const [domain, setDomain] = useState(""); + const [isPublic, setIsPublic] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(""); + + const handleCreate = async () => { + if (!name.trim()) { + setError("Name is required"); + return; + } + if (!bannedWords.trim()) { + setError("At least one banned word is required"); + return; + } + + setError(""); + setIsSaving(true); + try { + const body = { + name: name.trim(), + description: description.trim(), + banned_words: bannedWords + .split(",") + .map((w) => w.trim()) + .filter(Boolean), + domain: domain.trim(), + is_public: isPublic, + }; + const res = await fetch("/api/guardrails/ban_lists", { + method: "POST", + headers: { "Content-Type": "application/json", "X-API-KEY": apiKey }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error ?? "Failed to create ban list"); + } + const data = await res.json(); + onCreated({ id: data.id, name: data.name ?? name.trim() }); + } catch (e) { + setError(e instanceof Error ? e.message : "Something went wrong"); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+

+ Create Ban List +

+

+ Define a list of words to ban from outputs +

+
+ +
+ + {/* Body */} +
+ {error && ( +

+ {error} +

+ )} + +
+ + setName(e.target.value)} + placeholder="e.g. profanity-list" + className="w-full text-sm rounded-md border px-2.5 py-1.5 outline-none focus:ring-1" + style={{ + borderColor: colors.border, + backgroundColor: colors.bg.primary, + color: colors.text.primary, + }} + /> +
+ +
+ +