diff --git a/.env.example b/.env.example index 3e6995f5..cf493c5d 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,4 @@ 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 771d9666..00000000 --- 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 3c51ce68..f4596aea 100644 --- a/app/(main)/configurations/prompt-editor/page.tsx +++ b/app/(main)/configurations/prompt-editor/page.tsx @@ -108,6 +108,12 @@ function PromptEditorContent() { tools: config.tools || [], }, }, + ...(config.input_guardrails?.length && { + input_guardrails: config.input_guardrails, + }), + ...(config.output_guardrails?.length && { + output_guardrails: config.output_guardrails, + }), }); setProvider(config.provider); setTemperature(config.temperature); @@ -294,6 +300,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( @@ -491,6 +503,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 00000000..0eb337a6 --- /dev/null +++ b/app/(main)/guardrails/page.tsx @@ -0,0 +1,225 @@ +/** + * 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 { useApp } from "@/app/lib/context/AppContext"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import { useToast } from "@/app/components/Toast"; +import { guardrailsFetch } from "@/app/lib/guardrailsClient"; +import PageHeader from "@/app/components/PageHeader"; +import { + Validator, + SavedValidatorConfig, + OrgContext, +} from "@/app/lib/types/guardrails"; +import ValidatorConfigPanel from "@/app/components/guardrails/ValidatorConfigPanel"; +import SavedConfigsList from "@/app/components/guardrails/SavedConfigsList"; + +export default function GuardrailsPage() { + const { sidebarCollapsed } = useApp(); + const { isHydrated } = useAuth(); + const toast = useToast(); + const [orgContext, setOrgContext] = useState(null); + const [validators, setValidators] = useState([]); + const [validatorsLoading, setValidatorsLoading] = useState(true); + const [savedConfigs, setSavedConfigs] = useState([]); + const [savedConfigsLoading, setSavedConfigsLoading] = useState(true); + const [selectedValidatorType, setSelectedValidatorType] = useState< + string | null + >(null); + const [selectedSavedConfig, setSelectedSavedConfig] = + useState(null); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + if (!isHydrated) return; + guardrailsFetch<{ data?: { organization_id: number; project_id: number } }>( + "/api/apikeys/verify", //need to change this in backend to /auth/verify + ) + .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 session"); + } + }) + .catch((e: Error) => + toast.error(e.message || "Session verification failed"), + ); + }, [isHydrated]); + + useEffect(() => { + setValidatorsLoading(true); + guardrailsFetch<{ validators?: Validator[] }>("/api/guardrails") + .then((data) => { + const list: Validator[] = Array.isArray(data?.validators) + ? data.validators + : []; + setValidators(list); + }) + .catch(() => toast.error("Failed to load validators")) + .finally(() => setValidatorsLoading(false)); + }, []); + + 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); + guardrailsFetch<{ + data?: { configs?: SavedValidatorConfig[] } | SavedValidatorConfig[]; + configs?: SavedValidatorConfig[]; + }>(`/api/guardrails/validators/configs${configsQueryString}`) + .then((data) => { + const nested = data?.data; + const list: SavedValidatorConfig[] = Array.isArray( + (nested as { configs?: SavedValidatorConfig[] })?.configs, + ) + ? (nested as { configs: SavedValidatorConfig[] }).configs + : Array.isArray(nested) + ? (nested as SavedValidatorConfig[]) + : Array.isArray(data?.configs) + ? data.configs! + : []; + setSavedConfigs(list); + }) + .catch(() => toast.error("Failed to load saved configs")) + .finally(() => setSavedConfigsLoading(false)); + }, [configsQueryString]); + + useEffect(() => { + fetchSavedConfigs(); + }, [fetchSavedConfigs]); + + const handleSelectSavedConfig = (cfg: SavedValidatorConfig) => { + setSelectedSavedConfig(cfg); + setSelectedValidatorType(cfg.type); + }; + + const handleClearForm = () => { + setSelectedValidatorType(null); + setSelectedSavedConfig(null); + }; + + const handleDeleteConfig = async (configId: string) => { + if (!configsQueryString) return; + try { + await guardrailsFetch( + `/api/guardrails/validators/configs/${configId}${configsQueryString}`, + { 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()) { + 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 base = `/api/guardrails/validators/configs`; + const url = isUpdate + ? `${base}/${selectedSavedConfig!.id}${configsQueryString}` + : `${base}${configsQueryString}`; + + const body = configValues; + + await guardrailsFetch(url, { + method: isUpdate ? "PATCH" : "POST", + body: JSON.stringify(body), + }); + 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 + ? (() => { + const { + id: _id, + name: _name, + type: _type, + config: _config, + created_at: _ca, + updated_at: _ua, + organization_id: _oid, + project_id: _pid, + ...rest + } = selectedSavedConfig as SavedValidatorConfig & + Record; + return rest; + })() + : null; + + return ( +
+ + +
+ + +
+
+ +
+ +
+ +
+
+
+
+ ); +} diff --git a/app/api/apikeys/verify/route.ts b/app/api/apikeys/verify/route.ts new file mode 100644 index 00000000..faf3ee54 --- /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 00000000..0db4a45b --- /dev/null +++ b/app/api/guardrails/ban_lists/[ban_list_id]/route.ts @@ -0,0 +1,67 @@ +import { guardrailsUserClient } from "@/app/lib/guardrailsClient"; +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 guardrailsUserClient( + 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 guardrailsUserClient( + 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 guardrailsUserClient( + 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 00000000..e73450dd --- /dev/null +++ b/app/api/guardrails/ban_lists/route.ts @@ -0,0 +1,37 @@ +import { guardrailsUserClient } from "@/app/lib/guardrailsClient"; +import { NextResponse, NextRequest } from "next/server"; + +export async function GET(request: NextRequest) { + try { + const { status, data } = await guardrailsUserClient( + 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 guardrailsUserClient( + 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 00000000..1323e0a4 --- /dev/null +++ b/app/api/guardrails/route.ts @@ -0,0 +1,17 @@ +import { guardrailsClient } from "@/app/lib/guardrailsClient"; +import { NextResponse, NextRequest } from "next/server"; + +export async function GET(request: NextRequest) { + try { + const { status, data } = await guardrailsClient( + request, + "/api/v1/guardrails/", + ); + 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 00000000..2fb1aa02 --- /dev/null +++ b/app/api/guardrails/validators/configs/[config_id]/route.ts @@ -0,0 +1,68 @@ +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/api/guardrails/validators/configs/route.ts b/app/api/guardrails/validators/configs/route.ts new file mode 100644 index 00000000..489f233c --- /dev/null +++ b/app/api/guardrails/validators/configs/route.ts @@ -0,0 +1,38 @@ +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) { + try { + const { status, data } = await guardrailsClient( + request, + buildValidatorConfigEndpoint(request), + ); + 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, + buildValidatorConfigEndpoint(request), + { + 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/components/InfoTooltip.tsx b/app/components/InfoTooltip.tsx new file mode 100644 index 00000000..b8601fdc --- /dev/null +++ b/app/components/InfoTooltip.tsx @@ -0,0 +1,24 @@ +"use client"; + +interface InfoTooltipProps { + text: string; +} + +export default function InfoTooltip({ text }: InfoTooltipProps) { + return ( + + +
+ {text} +
+
+ ); +} diff --git a/app/components/MultiSelect.tsx b/app/components/MultiSelect.tsx new file mode 100644 index 00000000..ddf52e58 --- /dev/null +++ b/app/components/MultiSelect.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; +import { ChevronDownIcon, ChevronUpIcon } from "@/app/components/icons"; + +interface MultiSelectProps { + options: string[]; + value: string[]; + onChange: (value: string[]) => void; + placeholder?: string; +} + +export default function MultiSelect({ + options, + value, + onChange, + placeholder, +}: MultiSelectProps) { + const listboxId = useId(); + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const listRef = useRef(null); + const triggerRef = 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 | React.KeyboardEvent) => { + e.stopPropagation(); + onChange(value.filter((v) => v !== opt)); + }; + + const handleTriggerKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setOpen((v) => !v); + } else if (e.key === "Escape") { + setOpen(false); + } else if (e.key === "ArrowDown" && !open) { + e.preventDefault(); + setOpen(true); + } else if (e.key === "ArrowDown" && open) { + e.preventDefault(); + const first = listRef.current?.querySelector("button"); + first?.focus(); + } + }; + + const handleOptionKeyDown = ( + e: React.KeyboardEvent, + opt: string, + ) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggle(opt); + } else if (e.key === "Escape") { + setOpen(false); + triggerRef.current?.focus(); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + (e.currentTarget.nextElementSibling as HTMLButtonElement | null)?.focus(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + const prev = e.currentTarget + .previousElementSibling as HTMLButtonElement | null; + if (prev) { + prev.focus(); + } else { + setOpen(false); + triggerRef.current?.focus(); + } + } + }; + + const unselected = options.filter((o) => !value.includes(o)); + + return ( +
+ + + {open && ( +
+ {unselected.length === 0 ? ( +

+ All options selected +

+ ) : ( + unselected.map((opt) => ( + + )) + )} +
+ )} +
+ ); +} diff --git a/app/components/Select.tsx b/app/components/Select.tsx new file mode 100644 index 00000000..861aa8be --- /dev/null +++ b/app/components/Select.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { SelectHTMLAttributes } from "react"; + +export interface SelectOption { + value: string; + label: string; +} + +interface SelectProps extends SelectHTMLAttributes { + options: SelectOption[]; + placeholder?: string; +} + +export default function Select({ + options, + placeholder, + ...props +}: SelectProps) { + return ( + + ); +} diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index e97d71c8..9fcd585d 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"; @@ -111,6 +113,8 @@ export default function Sidebar({ document: , book: , gear: , + shield: , + sliders: , }; const navItems: MenuItem[] = NAV_ITEMS.filter( diff --git a/app/components/guardrails/BanListField.tsx b/app/components/guardrails/BanListField.tsx new file mode 100644 index 00000000..4765385d --- /dev/null +++ b/app/components/guardrails/BanListField.tsx @@ -0,0 +1,171 @@ +import React, { useEffect, useState } from "react"; +import BanListModal from "./BanListModal"; +import { guardrailsFetch } from "@/app/lib/guardrailsClient"; +import Select from "@/app/components/Select"; + +interface BanList { + id: string; + name: string; +} + +interface BanListFieldProps { + value: string | null; + onChange: (id: string | null) => void; +} + +export default function BanListField({ value, onChange }: BanListFieldProps) { + const [banLists, setBanLists] = useState([]); + const [loading, setLoading] = useState(true); + const [fetchError, setFetchError] = useState(null); + const [showModal, setShowModal] = useState(false); + const [bannedWords, setBannedWords] = useState([]); + const [wordsLoading, setWordsLoading] = useState(false); + const [wordsError, setWordsError] = useState(null); + + const fetchBanLists = () => { + setLoading(true); + setFetchError(null); + + guardrailsFetch<{ + data?: { ban_lists?: BanList[] } | BanList[]; + ban_lists?: BanList[]; + }>("/api/guardrails/ban_lists") + .then((data) => { + const nested = data?.data; + const list: BanList[] = Array.isArray( + (nested as { ban_lists?: BanList[] })?.ban_lists, + ) + ? (nested as { ban_lists: BanList[] }).ban_lists + : Array.isArray(nested) + ? (nested as BanList[]) + : Array.isArray(data?.ban_lists) + ? data.ban_lists! + : []; + setBanLists(list); + }) + .catch((e: Error) => + setFetchError(e.message || "Failed to load ban lists"), + ) + .finally(() => setLoading(false)); + }; + + const fetchBannedWords = (id: string) => { + setWordsLoading(true); + setBannedWords([]); + setWordsError(null); + + guardrailsFetch<{ + banned_words?: string[]; + data?: { banned_words?: string[] }; + }>(`/api/guardrails/ban_lists/${id}`) + .then((data) => { + const words: string[] = Array.isArray(data?.banned_words) + ? data.banned_words! + : Array.isArray(data?.data?.banned_words) + ? data.data!.banned_words! + : []; + setBannedWords(words); + }) + .catch((e: Error) => + setWordsError(e.message || "Failed to load banned words"), + ) + .finally(() => setWordsLoading(false)); + }; + + useEffect(() => { + fetchBanLists(); + }, []); + + useEffect(() => { + if (value) { + fetchBannedWords(value); + } else { + setBannedWords([]); + } + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + if (e.target.value === "__create__") { + setShowModal(true); + } else { + onChange(e.target.value || null); + } + }; + + function renderSelect() { + if (fetchError) { + return ( +

+ {fetchError} +

+ ); + } + if (loading) { + return
; + } + const options = [ + ...(banLists.length === 0 + ? [{ value: "", label: "No ban lists yet" }] + : [{ value: "", label: "Select a ban list…" }]), + { value: "__create__", label: "+ Create Ban List" }, + ...banLists.map((bl) => ({ value: bl.id, label: bl.name })), + ]; + return ( +