feat(ui): migrate guardrails content_filter + custom_code + core to shadcn

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-24 08:42:00 +00:00
parent c100c04688
commit cf6493df11
No known key found for this signature in database
8 changed files with 1653 additions and 1089 deletions

View file

@ -104,6 +104,8 @@
"src/components/guardrails/content_filter/PatternTable.tsx",
"src/components/guardrails/content_filter/ContentFilterDisplay.tsx",
"src/components/guardrails/content_filter/CategoryTable.tsx",
"src/components/guardrails/content_filter/ContentCategoryConfiguration.tsx",
"src/components/guardrails/custom_code/CustomCodeModal.tsx",
"src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx",
"src/components/prompts/variable_textarea.tsx",
"src/components/chat/MCPCredentialsTab.tsx",

View file

@ -1,18 +1,19 @@
import React, { useEffect, useState } from "react";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
Typography,
Select,
Switch,
Form,
Space,
InputNumber,
} from "antd";
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { X } from "lucide-react";
import { getMajorAirlines } from "../../networking";
const { Title, Text } = Typography;
const { Option } = Select;
export interface MajorAirline {
id: string;
match: string;
@ -53,6 +54,80 @@ const DEFAULT_CONFIG: CompetitorIntentConfig = {
threshold_low: 0.3,
};
/**
* Simple chip-style tag input used by the competitor-intent form. Mirrors
* the behavior of antd's `Select mode="tags"` but without using antd.
*/
function TagInput({
value,
onChange,
placeholder,
disabled = false,
}: {
value: string[];
onChange: (values: string[]) => void;
placeholder?: string;
disabled?: boolean;
}) {
const [draft, setDraft] = useState("");
const commit = () => {
const trimmed = draft.trim();
if (!trimmed) return;
const parts = trimmed
.split(",")
.map((p) => p.trim())
.filter(Boolean);
const next = [...value];
for (const p of parts) {
if (!next.includes(p)) next.push(p);
}
onChange(next);
setDraft("");
};
return (
<div className="space-y-2">
<Input
value={draft}
disabled={disabled}
placeholder={placeholder}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
commit();
} else if (e.key === "Backspace" && !draft && value.length > 0) {
onChange(value.slice(0, -1));
}
}}
onBlur={commit}
/>
{value.length > 0 && (
<div className="flex flex-wrap gap-1">
{value.map((v) => (
<Badge
key={v}
variant="secondary"
className="flex items-center gap-1"
>
{v}
<button
type="button"
onClick={() => onChange(value.filter((x) => x !== v))}
className="inline-flex items-center justify-center rounded-full hover:bg-muted-foreground/20"
aria-label={`Remove ${v}`}
>
<X size={12} />
</button>
</Badge>
))}
</div>
)}
</div>
);
}
const CompetitorIntentConfiguration: React.FC<
CompetitorIntentConfigurationProps
> = ({ enabled, config, onChange, accessToken }) => {
@ -89,7 +164,10 @@ const CompetitorIntentConfiguration: React.FC<
});
};
const handleNestedArrayChange = (field: "brand_self" | "locations" | "competitors", values: string[]) => {
const handleNestedArrayChange = (
field: "brand_self" | "locations" | "competitors",
values: string[],
) => {
onChange(enabled, { ...effectiveConfig, [field]: values.filter(Boolean) });
};
@ -103,7 +181,10 @@ const CompetitorIntentConfiguration: React.FC<
return primary === v.toLowerCase();
});
if (airline) {
for (const variant of airline.match.split("|").map((s) => s.trim().toLowerCase()).filter(Boolean)) {
for (const variant of airline.match
.split("|")
.map((s) => s.trim().toLowerCase())
.filter(Boolean)) {
if (!seen.has(variant)) {
seen.add(variant);
expanded.push(variant);
@ -117,219 +198,329 @@ const CompetitorIntentConfiguration: React.FC<
onChange(enabled, { ...effectiveConfig, brand_self: expanded });
};
const header = (
<div className="flex justify-between items-center">
<h5 className="text-base font-semibold m-0">Competitor Intent Filter</h5>
<Switch checked={enabled} onCheckedChange={handleEnabledChange} />
</div>
);
if (!enabled) {
return (
<Card
title={
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Title level={5} style={{ margin: 0 }}>
Competitor Intent Filter
</Title>
<Switch checked={false} onChange={handleEnabledChange} />
</div>
}
size="small"
>
<Text type="secondary">
Block or reframe competitor comparison questions. When enabled, airline type
auto-loads competitors from IATA; generic type requires manual competitor list.
</Text>
<Card className="p-4 space-y-3">
{header}
<p className="text-sm text-muted-foreground m-0">
Block or reframe competitor comparison questions. When enabled, airline
type auto-loads competitors from IATA; generic type requires manual
competitor list.
</p>
</Card>
);
}
return (
<Card
title={
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Title level={5} style={{ margin: 0 }}>
Competitor Intent Filter
</Title>
<Switch checked={enabled} onChange={handleEnabledChange} />
</div>
}
size="small"
>
<Text type="secondary" style={{ display: "block", marginBottom: 16 }}>
Block or reframe competitor comparison questions. Airline type uses major airlines
(excluding your brand); generic requires manual competitor list.
</Text>
<Form layout="vertical" size="small">
<Form.Item label="Type">
<Select
value={effectiveConfig.competitor_intent_type}
onChange={(v) => handleConfigChange("competitor_intent_type", v)}
style={{ width: "100%" }}
>
<Option value="airline">Airline (auto-load competitors from IATA)</Option>
<Option value="generic">Generic (specify competitors manually)</Option>
</Select>
</Form.Item>
const isAirline = effectiveConfig.competitor_intent_type === "airline";
const useAirlineSelect = isAirline && airlineOptions.length > 0;
<Form.Item
label="Your Brand (brand_self)"
required
help={
effectiveConfig.competitor_intent_type === "airline"
? "Select your airline from the list (excluded from competitors) or type to add a custom term"
: "Names/codes users use for your brand"
}
return (
<Card className="p-4 space-y-4">
{header}
<p className="text-sm text-muted-foreground m-0">
Block or reframe competitor comparison questions. Airline type uses
major airlines (excluding your brand); generic requires manual
competitor list.
</p>
<div className="space-y-2">
<Label>Type</Label>
<Select
value={effectiveConfig.competitor_intent_type}
onValueChange={(v) => handleConfigChange("competitor_intent_type", v)}
>
<Select
mode="tags"
style={{ width: "100%" }}
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="airline">
Airline (auto-load competitors from IATA)
</SelectItem>
<SelectItem value="generic">
Generic (specify competitors manually)
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>
Your Brand (brand_self) <span className="text-destructive">*</span>
</Label>
{useAirlineSelect ? (
<AirlineBrandSelect
selected={effectiveConfig.brand_self}
options={airlineOptions}
loading={loadingAirlines}
onChange={(values) => handleBrandSelfChange(values)}
/>
) : (
<TagInput
value={effectiveConfig.brand_self ?? []}
onChange={(v) => handleNestedArrayChange("brand_self", v)}
placeholder={
loadingAirlines
? "Loading airlines..."
: effectiveConfig.competitor_intent_type === "airline"
? "Search or select airline, or type to add custom"
: "Type and press Enter to add"
}
value={effectiveConfig.brand_self}
onChange={(v) =>
effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0
? handleBrandSelfChange(v ?? [])
: handleNestedArrayChange("brand_self", v ?? [])
}
tokenSeparators={[","]}
loading={loadingAirlines}
showSearch
filterOption={(input, option) =>
(option?.label?.toString().toLowerCase() ?? "").includes(
input.toLowerCase()
)
}
optionFilterProp="label"
options={
effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0
? airlineOptions.map((a) => {
const primary = a.match.split("|")[0]?.trim() ?? a.id;
const variants = a.match
.split("|")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
return {
value: primary.toLowerCase(),
label: `${primary}${variants.length > 1 ? ` (${variants.slice(1).join(", ")})` : ""}`,
};
})
: undefined
: "Type and press Enter to add"
}
/>
</Form.Item>
{effectiveConfig.competitor_intent_type === "airline" && (
<Form.Item
label="Locations (optional)"
help="Countries, cities, airports for disambiguation (e.g. qatar, doha)"
>
<Select
mode="tags"
style={{ width: "100%" }}
placeholder="Type and press Enter to add"
value={effectiveConfig.locations ?? []}
onChange={(v) => handleNestedArrayChange("locations", v ?? [])}
tokenSeparators={[","]}
/>
</Form.Item>
)}
<p className="text-xs text-muted-foreground">
{isAirline
? "Select your airline from the list (excluded from competitors) or type to add a custom term"
: "Names/codes users use for your brand"}
</p>
</div>
{effectiveConfig.competitor_intent_type === "generic" && (
<Form.Item
label="Competitors"
required
help="Competitor names to detect (required for generic type)"
>
<Select
mode="tags"
style={{ width: "100%" }}
placeholder="Type and press Enter to add"
value={effectiveConfig.competitors ?? []}
onChange={(v) => handleNestedArrayChange("competitors", v ?? [])}
tokenSeparators={[","]}
/>
</Form.Item>
)}
{isAirline && (
<div className="space-y-2">
<Label>Locations (optional)</Label>
<TagInput
value={effectiveConfig.locations ?? []}
onChange={(v) => handleNestedArrayChange("locations", v)}
placeholder="Type and press Enter to add"
/>
<p className="text-xs text-muted-foreground">
Countries, cities, airports for disambiguation (e.g. qatar, doha)
</p>
</div>
)}
<Form.Item label="Policy: Competitor comparison">
<Select
value={effectiveConfig.policy?.competitor_comparison ?? "refuse"}
onChange={(v) => handlePolicyChange("competitor_comparison", v)}
style={{ width: "100%" }}
>
<Option value="refuse">Refuse (block request)</Option>
<Option value="reframe">Reframe (suggest alternative)</Option>
</Select>
</Form.Item>
{effectiveConfig.competitor_intent_type === "generic" && (
<div className="space-y-2">
<Label>
Competitors <span className="text-destructive">*</span>
</Label>
<TagInput
value={effectiveConfig.competitors ?? []}
onChange={(v) => handleNestedArrayChange("competitors", v)}
placeholder="Type and press Enter to add"
/>
<p className="text-xs text-muted-foreground">
Competitor names to detect (required for generic type)
</p>
</div>
)}
<Form.Item label="Policy: Possible competitor comparison">
<Select
value={effectiveConfig.policy?.possible_competitor_comparison ?? "reframe"}
onChange={(v) => handlePolicyChange("possible_competitor_comparison", v)}
style={{ width: "100%" }}
>
<Option value="refuse">Refuse (block request)</Option>
<Option value="reframe">Reframe (suggest alternative to backend LLM)</Option>
</Select>
</Form.Item>
<div className="space-y-2">
<Label>Policy: Competitor comparison</Label>
<Select
value={effectiveConfig.policy?.competitor_comparison ?? "refuse"}
onValueChange={(v) => handlePolicyChange("competitor_comparison", v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="refuse">Refuse (block request)</SelectItem>
<SelectItem value="reframe">Reframe (suggest alternative)</SelectItem>
</SelectContent>
</Select>
</div>
<Form.Item
label="Confidence thresholds"
help={
<>
Classify competitor intent by confidence (0–1). Higher confidence → stronger intent.
<ul style={{ marginBottom: 0, marginTop: 4, paddingLeft: 20 }}>
<li>
<strong>High (≥)</strong>: Treat as full competitor comparison → uses &quot;Competitor comparison&quot; policy
</li>
<li>
<strong>Medium (≥)</strong>: Treat as possible comparison → uses &quot;Possible competitor comparison&quot; policy
</li>
<li>
<strong>Low (≥)</strong>: Log only; allow request. Below Low → allow with no action
</li>
</ul>
Raise thresholds to be more permissive; lower them to be stricter.
</>
<div className="space-y-2">
<Label>Policy: Possible competitor comparison</Label>
<Select
value={
effectiveConfig.policy?.possible_competitor_comparison ?? "reframe"
}
onValueChange={(v) =>
handlePolicyChange("possible_competitor_comparison", v)
}
>
<Space wrap>
<Form.Item label="High" style={{ marginBottom: 0 }} help="e.g. 0.7">
<InputNumber
min={0}
max={1}
step={0.05}
value={effectiveConfig.threshold_high ?? 0.7}
onChange={(v) => handleConfigChange("threshold_high", v ?? 0.7)}
style={{ width: 80 }}
/>
</Form.Item>
<Form.Item label="Medium" style={{ marginBottom: 0 }} help="e.g. 0.45">
<InputNumber
min={0}
max={1}
step={0.05}
value={effectiveConfig.threshold_medium ?? 0.45}
onChange={(v) => handleConfigChange("threshold_medium", v ?? 0.45)}
style={{ width: 80 }}
/>
</Form.Item>
<Form.Item label="Low" style={{ marginBottom: 0 }} help="e.g. 0.3">
<InputNumber
min={0}
max={1}
step={0.05}
value={effectiveConfig.threshold_low ?? 0.3}
onChange={(v) => handleConfigChange("threshold_low", v ?? 0.3)}
style={{ width: 80 }}
/>
</Form.Item>
</Space>
</Form.Item>
</Form>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="refuse">Refuse (block request)</SelectItem>
<SelectItem value="reframe">
Reframe (suggest alternative to backend LLM)
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Confidence thresholds</Label>
<div className="flex flex-wrap items-end gap-4">
<div className="space-y-1">
<Label htmlFor="threshold-high" className="text-xs">
High
</Label>
<Input
id="threshold-high"
type="number"
min={0}
max={1}
step={0.05}
value={effectiveConfig.threshold_high ?? 0.7}
onChange={(e) =>
handleConfigChange(
"threshold_high",
e.target.value === "" ? 0.7 : Number(e.target.value),
)
}
className="w-24"
/>
</div>
<div className="space-y-1">
<Label htmlFor="threshold-medium" className="text-xs">
Medium
</Label>
<Input
id="threshold-medium"
type="number"
min={0}
max={1}
step={0.05}
value={effectiveConfig.threshold_medium ?? 0.45}
onChange={(e) =>
handleConfigChange(
"threshold_medium",
e.target.value === "" ? 0.45 : Number(e.target.value),
)
}
className="w-24"
/>
</div>
<div className="space-y-1">
<Label htmlFor="threshold-low" className="text-xs">
Low
</Label>
<Input
id="threshold-low"
type="number"
min={0}
max={1}
step={0.05}
value={effectiveConfig.threshold_low ?? 0.3}
onChange={(e) =>
handleConfigChange(
"threshold_low",
e.target.value === "" ? 0.3 : Number(e.target.value),
)
}
className="w-24"
/>
</div>
</div>
<div className="text-xs text-muted-foreground">
Classify competitor intent by confidence (0–1). Higher confidence →
stronger intent.
<ul className="mt-1 mb-0 pl-5 list-disc">
<li>
<strong>High (≥)</strong>: Treat as full competitor comparison →
uses &quot;Competitor comparison&quot; policy
</li>
<li>
<strong>Medium (≥)</strong>: Treat as possible comparison → uses
&quot;Possible competitor comparison&quot; policy
</li>
<li>
<strong>Low (≥)</strong>: Log only; allow request. Below Low →
allow with no action
</li>
</ul>
Raise thresholds to be more permissive; lower them to be stricter.
</div>
</div>
</Card>
);
};
/**
* Select + chip display for airlines. Adds on pick; supports custom entries
* via the parallel TagInput below.
*/
function AirlineBrandSelect({
selected,
options,
loading,
onChange,
}: {
selected: string[];
options: MajorAirline[];
loading: boolean;
onChange: (values: string[]) => void;
}) {
const selectedSet = new Set(selected.map((s) => s.toLowerCase()));
const unselected = options.filter((a) => {
const primary = a.match.split("|")[0]?.trim().toLowerCase();
return primary && !selectedSet.has(primary);
});
return (
<div className="space-y-2">
<div className="flex gap-2">
<Select
value=""
onValueChange={(v) => {
if (!v) return;
onChange([...selected, v]);
}}
>
<SelectTrigger>
<SelectValue
placeholder={
loading ? "Loading airlines..." : "Select airline or pick one"
}
/>
</SelectTrigger>
<SelectContent>
{unselected.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No airlines available
</div>
) : (
unselected.map((a) => {
const primary =
a.match.split("|")[0]?.trim().toLowerCase() ?? a.id;
const label = a.match.split("|")[0]?.trim() ?? a.id;
const variants = a.match
.split("|")
.map((s) => s.trim())
.filter(Boolean);
return (
<SelectItem key={a.id} value={primary}>
{label}
{variants.length > 1
? ` (${variants.slice(1).join(", ")})`
: ""}
</SelectItem>
);
})
)}
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap gap-1">
{selected.map((v) => (
<Badge
key={v}
variant="secondary"
className="flex items-center gap-1"
>
{v}
<button
type="button"
onClick={() => onChange(selected.filter((s) => s !== v))}
className="inline-flex items-center justify-center rounded-full hover:bg-muted-foreground/20"
aria-label={`Remove ${v}`}
>
<X size={12} />
</button>
</Badge>
))}
</div>
</div>
);
}
export default CompetitorIntentConfiguration;

View file

@ -1,11 +1,35 @@
import React from "react";
import { Card, Typography, Select, Table, Tag, Collapse, Button } from "antd";
import { Trash2 as DeleteOutlined, Plus as PlusOutlined, FileText as FileTextOutlined } from "lucide-react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
FileText as FileTextOutlined,
Plus as PlusOutlined,
Trash2 as DeleteOutlined,
} from "lucide-react";
import { getCategoryYaml } from "../../networking";
const { Title, Text } = Typography;
const { Option } = Select;
interface ContentCategory {
name: string;
display_name: string;
@ -42,28 +66,40 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
pendingSelection,
onPendingSelectionChange,
}) => {
// Use controlled state if parent provides it, otherwise use local state
const [localSelectedCategoryName, setLocalSelectedCategoryName] = React.useState<string>("");
const selectedCategoryName = pendingSelection !== undefined ? pendingSelection : localSelectedCategoryName;
const setSelectedCategoryName = onPendingSelectionChange || setLocalSelectedCategoryName;
const [categoryYaml, setCategoryYaml] = React.useState<{ [key: string]: string }>({});
const [categoryFileTypes, setCategoryFileTypes] = React.useState<{ [key: string]: string }>({});
const [loadingYaml, setLoadingYaml] = React.useState<{ [key: string]: boolean }>({});
const [expandedYamlCategories, setExpandedYamlCategories] = React.useState<string[]>([]);
const [localSelectedCategoryName, setLocalSelectedCategoryName] =
React.useState<string>("");
const selectedCategoryName =
pendingSelection !== undefined ? pendingSelection : localSelectedCategoryName;
const setSelectedCategoryName =
onPendingSelectionChange || setLocalSelectedCategoryName;
const [categoryYaml, setCategoryYaml] = React.useState<{
[key: string]: string;
}>({});
const [categoryFileTypes, setCategoryFileTypes] = React.useState<{
[key: string]: string;
}>({});
const [loadingYaml, setLoadingYaml] = React.useState<{
[key: string]: boolean;
}>({});
const [expandedYamlCategories, setExpandedYamlCategories] = React.useState<
string[]
>([]);
const [previewYaml, setPreviewYaml] = React.useState<string>("");
const [loadingPreviewYaml, setLoadingPreviewYaml] = React.useState<boolean>(false);
const [loadingPreviewYaml, setLoadingPreviewYaml] =
React.useState<boolean>(false);
const handleAddCategory = () => {
if (!selectedCategoryName) {
return;
}
const category = availableCategories.find((c) => c.name === selectedCategoryName);
const category = availableCategories.find(
(c) => c.name === selectedCategoryName,
);
if (!category) {
return;
}
// Check if already added
if (selectedCategories.some((c) => c.category === selectedCategoryName)) {
return;
}
@ -77,15 +113,14 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
});
setSelectedCategoryName("");
setPreviewYaml(""); // Clear preview when category is added
setPreviewYaml("");
};
const fetchCategoryYaml = async (categoryName: string) => {
if (!accessToken) {
return; // No access token
return;
}
// Check if already loaded
if (categoryYaml[categoryName]) {
return;
}
@ -94,62 +129,71 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
try {
const data = await getCategoryYaml(accessToken, categoryName);
let content = data.yaml_content;
// Format JSON content for better readability
if (data.file_type === 'json') {
if (data.file_type === "json") {
try {
const parsed = JSON.parse(content);
content = JSON.stringify(parsed, null, 2);
} catch (e) {
// If parsing fails, use original content
console.warn(`Failed to format JSON for ${categoryName}:`, e);
}
}
setCategoryYaml((prev) => ({ ...prev, [categoryName]: content }));
setCategoryFileTypes((prev) => ({ ...prev, [categoryName]: data.file_type || 'yaml' }));
setCategoryFileTypes((prev) => ({
...prev,
[categoryName]: data.file_type || "yaml",
}));
} catch (error) {
console.error(`Failed to fetch content for category ${categoryName}:`, error);
console.error(
`Failed to fetch content for category ${categoryName}:`,
error,
);
} finally {
setLoadingYaml((prev) => ({ ...prev, [categoryName]: false }));
}
};
// Fetch preview YAML/JSON when a category is selected in dropdown
React.useEffect(() => {
if (selectedCategoryName && accessToken) {
// Check if we already have this content cached
const cachedContent = categoryYaml[selectedCategoryName];
if (cachedContent) {
setPreviewYaml(cachedContent);
return;
}
// Fetch the content for preview
setLoadingPreviewYaml(true);
console.log(`Fetching content for category: ${selectedCategoryName}`, { accessToken: accessToken ? "present" : "missing" });
getCategoryYaml(accessToken, selectedCategoryName)
.then((data) => {
console.log(`Successfully fetched content for ${selectedCategoryName}:`, data);
let content = data.yaml_content;
// Format JSON content for better readability
if (data.file_type === 'json') {
if (data.file_type === "json") {
try {
const parsed = JSON.parse(content);
content = JSON.stringify(parsed, null, 2);
} catch (e) {
console.warn(`Failed to format JSON for ${selectedCategoryName}:`, e);
console.warn(
`Failed to format JSON for ${selectedCategoryName}:`,
e,
);
}
}
setPreviewYaml(content);
// Also cache it for later use
setCategoryYaml((prev) => ({ ...prev, [selectedCategoryName]: content }));
setCategoryFileTypes((prev) => ({ ...prev, [selectedCategoryName]: data.file_type || 'yaml' }));
setCategoryYaml((prev) => ({
...prev,
[selectedCategoryName]: content,
}));
setCategoryFileTypes((prev) => ({
...prev,
[selectedCategoryName]: data.file_type || "yaml",
}));
})
.catch((error) => {
console.error(`Failed to fetch preview content for category ${selectedCategoryName}:`, error);
console.error(
`Failed to fetch preview content for category ${selectedCategoryName}:`,
error,
);
setPreviewYaml("");
})
.finally(() => {
@ -162,173 +206,81 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCategoryName, accessToken]);
const columns = [
{
title: "Category",
dataIndex: "display_name",
key: "display_name",
render: (text: string, record: SelectedCategory) => {
const category = availableCategories.find((c) => c.name === record.category);
return (
<div>
<div style={{ fontWeight: 500 }}>{text}</div>
{category?.description && (
<div style={{ fontSize: "12px", color: "#888", marginTop: "4px" }}>
{category.description}
</div>
)}
</div>
);
},
},
{
title: "Action",
dataIndex: "action",
key: "action",
width: 150,
render: (action: string, record: SelectedCategory) => (
<Select
value={action}
onChange={(value) => onCategoryUpdate(record.id, "action", value)}
style={{ width: "100%" }}
>
<Option value="BLOCK">
<Tag color="red">BLOCK</Tag>
</Option>
<Option value="MASK">
<Tag color="orange">MASK</Tag>
</Option>
</Select>
),
},
{
title: "Severity Threshold",
dataIndex: "severity_threshold",
key: "severity_threshold",
width: 180,
render: (threshold: string, record: SelectedCategory) => (
<Select
value={threshold}
onChange={(value) => onCategoryUpdate(record.id, "severity_threshold", value)}
style={{ width: "100%" }}
>
<Option value="low">Low</Option>
<Option value="medium">Medium</Option>
<Option value="high">High</Option>
</Select>
),
},
{
title: "",
key: "actions",
width: 80,
render: (_: any, record: SelectedCategory) => (
<Button
icon={<DeleteOutlined />}
onClick={() => onCategoryRemove(record.id)}
size="small"
>
Remove
</Button>
),
},
];
const unselectedCategories = availableCategories.filter(
(cat) => !selectedCategories.some((sel) => sel.category === cat.name)
(cat) => !selectedCategories.some((sel) => sel.category === cat.name),
);
return (
<Card
title={
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
<Title level={5} style={{ margin: 0 }}>
Blocked topics
</Title>
<Text type="secondary" style={{ fontSize: 12, fontWeight: 400 }}>
Select topics to block using keyword and semantic analysis
</Text>
</div>
}
size="small"
>
<div style={{ marginBottom: 16, display: "flex", gap: 8 }}>
<Select
placeholder="Select a content category"
value={selectedCategoryName || undefined}
onChange={setSelectedCategoryName}
style={{ flex: 1 }}
showSearch
optionLabelProp="label"
filterOption={(input, option) =>
(option?.label?.toString().toLowerCase() ?? "").includes(input.toLowerCase())
}
>
{unselectedCategories.map((cat) => (
<Option key={cat.name} value={cat.name} label={cat.display_name}>
<div>
<div style={{ fontWeight: 500 }}>{cat.display_name}</div>
<div style={{ fontSize: "12px", color: "#666", marginTop: "2px" }}>
{cat.description}
<Card className="p-4 space-y-4">
<div className="flex justify-between items-center flex-wrap gap-2">
<h5 className="text-base font-semibold m-0">Blocked topics</h5>
<span className="text-xs text-muted-foreground font-normal">
Select topics to block using keyword and semantic analysis
</span>
</div>
<div className="flex gap-2">
<div className="flex-1">
<Select
value={selectedCategoryName || ""}
onValueChange={(v) => setSelectedCategoryName(v)}
>
<SelectTrigger>
<SelectValue placeholder="Select a content category" />
</SelectTrigger>
<SelectContent>
{unselectedCategories.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No categories available
</div>
</div>
</Option>
))}
</Select>
) : (
unselectedCategories.map((cat) => (
<SelectItem key={cat.name} value={cat.name}>
<div>
<div className="font-medium">{cat.display_name}</div>
<div className="text-xs text-muted-foreground mt-0.5">
{cat.description}
</div>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<Button
type="primary"
onClick={handleAddCategory}
disabled={!selectedCategoryName}
icon={<PlusOutlined />}
>
<PlusOutlined className="h-4 w-4 mr-1" />
Add
</Button>
</div>
{/* Preview box - shown when category is selected but not yet added */}
{selectedCategoryName && (
<div
style={{
marginBottom: 16,
padding: "12px",
background: "#f9f9f9",
border: "1px solid #e0e0e0",
borderRadius: "4px",
}}
>
<div style={{ marginBottom: 8, fontWeight: 500, fontSize: "14px" }}>
Preview: {availableCategories.find((c) => c.name === selectedCategoryName)?.display_name}
<div className="p-3 bg-muted border border-border rounded">
<div className="mb-2 text-sm font-medium">
Preview:{" "}
{
availableCategories.find((c) => c.name === selectedCategoryName)
?.display_name
}
{categoryFileTypes[selectedCategoryName] && (
<span style={{ marginLeft: 8, fontSize: "12px", color: "#888", fontWeight: 400 }}>
<span className="ml-2 text-xs text-muted-foreground font-normal">
({categoryFileTypes[selectedCategoryName]?.toUpperCase()})
</span>
)}
</div>
{loadingPreviewYaml ? (
<div style={{ padding: "16px", textAlign: "center", color: "#888" }}>
<div className="p-4 text-center text-muted-foreground">
Loading content...
</div>
) : previewYaml ? (
<pre
style={{
background: "#fff",
padding: "12px",
borderRadius: "4px",
overflow: "auto",
maxHeight: "300px",
maxWidth: "100%",
fontSize: "12px",
lineHeight: "1.5",
margin: 0,
border: "1px solid #e0e0e0",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
<pre className="bg-background p-3 rounded overflow-auto max-h-[300px] max-w-full text-xs leading-relaxed m-0 border border-border whitespace-pre-wrap break-words">
<code>{previewYaml}</code>
</pre>
) : (
<div style={{ padding: "8px", textAlign: "center", color: "#888", fontSize: "12px" }}>
<div className="p-2 text-center text-muted-foreground text-xs">
Unable to load category content
</div>
)}
@ -337,83 +289,147 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
{selectedCategories.length > 0 ? (
<>
<Table
dataSource={selectedCategories}
columns={columns}
pagination={false}
size="small"
rowKey="id"
/>
<div style={{ marginTop: 16 }}>
<Collapse
activeKey={expandedYamlCategories}
onChange={(keys) => {
const keyArray = Array.isArray(keys) ? keys : keys ? [keys] : [];
const oldExpanded = new Set(expandedYamlCategories);
// Find newly expanded categories and fetch their YAML
keyArray.forEach((key) => {
const categoryName = key as string;
if (!oldExpanded.has(categoryName) && !categoryYaml[categoryName]) {
fetchCategoryYaml(categoryName);
}
});
setExpandedYamlCategories(keyArray as string[]);
}}
ghost
items={selectedCategories.map((category) => {
const fileType = categoryFileTypes[category.category] || 'yaml';
const fileTypeLabel = fileType.toUpperCase();
return {
key: category.category,
label: (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<FileTextOutlined />
<span>View {fileTypeLabel} for {category.display_name}</span>
</div>
),
children: loadingYaml[category.category] ? (
<div style={{ padding: "16px", textAlign: "center", color: "#888" }}>
Loading content...
</div>
) : categoryYaml[category.category] ? (
<pre
style={{
background: "#f5f5f5",
padding: "16px",
borderRadius: "4px",
overflow: "auto",
maxHeight: "400px",
fontSize: "12px",
lineHeight: "1.5",
margin: 0,
}}
>
<code>{categoryYaml[category.category]}</code>
</pre>
) : (
<div style={{ padding: "16px", textAlign: "center", color: "#888" }}>
Content will load when expanded
</div>
),
};
<Table>
<TableHeader>
<TableRow>
<TableHead>Category</TableHead>
<TableHead className="w-[150px]">Action</TableHead>
<TableHead className="w-[180px]">Severity Threshold</TableHead>
<TableHead className="w-[120px]" />
</TableRow>
</TableHeader>
<TableBody>
{selectedCategories.map((record) => {
const category = availableCategories.find(
(c) => c.name === record.category,
);
return (
<TableRow key={record.id}>
<TableCell>
<div>
<div className="font-medium">{record.display_name}</div>
{category?.description && (
<div className="text-xs text-muted-foreground mt-1">
{category.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<Select
value={record.action}
onValueChange={(value) =>
onCategoryUpdate(record.id, "action", value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="BLOCK">
<Badge variant="destructive">BLOCK</Badge>
</SelectItem>
<SelectItem value="MASK">
<Badge
variant="secondary"
className="bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-300"
>
MASK
</Badge>
</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Select
value={record.severity_threshold}
onValueChange={(value) =>
onCategoryUpdate(
record.id,
"severity_threshold",
value,
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => onCategoryRemove(record.id)}
>
<DeleteOutlined className="h-4 w-4 mr-1" />
Remove
</Button>
</TableCell>
</TableRow>
);
})}
/>
</div>
</TableBody>
</Table>
<Accordion
type="multiple"
value={expandedYamlCategories}
onValueChange={(values) => {
const oldExpanded = new Set(expandedYamlCategories);
values.forEach((key) => {
if (!oldExpanded.has(key) && !categoryYaml[key]) {
fetchCategoryYaml(key);
}
});
setExpandedYamlCategories(values);
}}
>
{selectedCategories.map((category) => {
const fileType = categoryFileTypes[category.category] || "yaml";
const fileTypeLabel = fileType.toUpperCase();
return (
<AccordionItem
key={category.category}
value={category.category}
>
<AccordionTrigger>
<div className="flex items-center gap-2">
<FileTextOutlined className="h-4 w-4" />
<span>
View {fileTypeLabel} for {category.display_name}
</span>
</div>
</AccordionTrigger>
<AccordionContent>
{loadingYaml[category.category] ? (
<div className="p-4 text-center text-muted-foreground">
Loading content...
</div>
) : categoryYaml[category.category] ? (
<pre className="bg-muted p-4 rounded overflow-auto max-h-[400px] text-xs leading-relaxed m-0">
<code>{categoryYaml[category.category]}</code>
</pre>
) : (
<div className="p-4 text-center text-muted-foreground">
Content will load when expanded
</div>
)}
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</>
) : (
<div
style={{
textAlign: "center",
padding: "24px",
color: "#888",
border: "1px dashed #d9d9d9",
borderRadius: "4px",
}}
>
No blocked topics selected. Add topics to detect and block harmful content.
<div className="text-center py-6 text-muted-foreground border border-dashed border-border rounded">
No blocked topics selected. Add topics to detect and block harmful
content.
</div>
)}
</Card>
@ -421,4 +437,3 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
};
export default ContentCategoryConfiguration;

View file

@ -1,5 +1,6 @@
import React, { useState } from "react";
import { Typography, Space, Upload, Card, Button } from "antd";
import React, { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Plus as PlusOutlined, Upload as UploadOutlined } from "lucide-react";
import { validateBlockedWordsFile } from "../../networking";
import NotificationsManager from "../../molecules/notifications_manager";
@ -13,8 +14,6 @@ import CompetitorIntentConfiguration, {
CompetitorIntentConfig,
} from "./CompetitorIntentConfiguration";
const { Title, Text } = Typography;
interface PrebuiltPattern {
name: string;
display_name: string;
@ -78,7 +77,7 @@ interface ContentFilterConfigurationProps {
competitorIntentConfig?: CompetitorIntentConfig | null;
onCompetitorIntentChange?: (
enabled: boolean,
config: CompetitorIntentConfig | null
config: CompetitorIntentConfig | null,
) => void;
}
@ -109,17 +108,24 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
}) => {
const [patternModalVisible, setPatternModalVisible] = useState(false);
const [keywordModalVisible, setKeywordModalVisible] = useState(false);
const [customPatternModalVisible, setCustomPatternModalVisible] = useState(false);
const [customPatternModalVisible, setCustomPatternModalVisible] =
useState(false);
const [selectedPatternName, setSelectedPatternName] = useState<string>("");
const [patternAction, setPatternAction] = useState<"BLOCK" | "MASK">("BLOCK");
const [customPatternName, setCustomPatternName] = useState<string>("");
const [customPatternRegex, setCustomPatternRegex] = useState<string>("");
const [customPatternAction, setCustomPatternAction] = useState<"BLOCK" | "MASK">("BLOCK");
const [customPatternAction, setCustomPatternAction] = useState<
"BLOCK" | "MASK"
>("BLOCK");
const [newKeyword, setNewKeyword] = useState<string>("");
const [newKeywordAction, setNewKeywordAction] = useState<"BLOCK" | "MASK">("BLOCK");
const [newKeywordDescription, setNewKeywordDescription] = useState<string>("");
const [newKeywordAction, setNewKeywordAction] = useState<"BLOCK" | "MASK">(
"BLOCK",
);
const [newKeywordDescription, setNewKeywordDescription] =
useState<string>("");
const [uploadValidating, setUploadValidating] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const handleAddPrebuiltPattern = () => {
if (!selectedPatternName) {
@ -127,7 +133,9 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
return;
}
const selectedPattern = prebuiltPatterns.find((p) => p.name === selectedPatternName);
const selectedPattern = prebuiltPatterns.find(
(p) => p.name === selectedPatternName,
);
onPatternAdd({
id: `pattern-${Date.now()}`,
@ -185,16 +193,21 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
setUploadValidating(true);
try {
const content = await file.text();
if (accessToken) {
const result = await validateBlockedWordsFile(accessToken, content);
if (result.valid) {
if (onFileUpload) {
onFileUpload(content);
}
NotificationsManager.success(result.message || "File uploaded successfully");
NotificationsManager.success(
result.message || "File uploaded successfully",
);
} else {
const errorMessage = result.error || (result.errors && result.errors.join(", ")) || "Invalid file";
const errorMessage =
result.error ||
(result.errors && result.errors.join(", ")) ||
"Invalid file";
NotificationsManager.error(`Validation failed: ${errorMessage}`);
}
}
@ -203,48 +216,48 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
} finally {
setUploadValidating(false);
}
return false;
};
const showPatterns = !showStep || showStep === "patterns";
const showKeywords = !showStep || showStep === "keywords";
const showCategories = !showStep || showStep === "categories";
const showCompetitorIntent =
!showStep || showStep === "competitor_intent" || showStep === "categories";
!showStep ||
showStep === "competitor_intent" ||
showStep === "categories";
return (
<div className="space-y-6">
{!showStep && (
<div>
<Text type="secondary">
Configure patterns, keywords, and content categories to detect and filter sensitive information in requests and responses.
</Text>
<p className="text-sm text-muted-foreground m-0">
Configure patterns, keywords, and content categories to detect and
filter sensitive information in requests and responses.
</p>
</div>
)}
{showPatterns && (
<Card
title={
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Title level={5} style={{ margin: 0 }}>
Pattern Detection
</Title>
<Text type="secondary" style={{ fontSize: 14, fontWeight: 400 }}>
Detect sensitive information using regex patterns (SSN, credit cards, API keys, etc.)
</Text>
</div>
}
size="small"
>
<div style={{ marginBottom: 16 }}>
<Space>
<Button type="primary" onClick={() => setPatternModalVisible(true)} icon={<PlusOutlined />}>
Add prebuilt pattern
</Button>
<Button onClick={() => setCustomPatternModalVisible(true)} icon={<PlusOutlined />}>
Add custom regex
</Button>
</Space>
<Card className="p-4 space-y-4">
<div className="flex justify-between items-center">
<h5 className="text-base font-semibold m-0">Pattern Detection</h5>
<span className="text-sm text-muted-foreground font-normal">
Detect sensitive information using regex patterns (SSN, credit
cards, API keys, etc.)
</span>
</div>
<div className="flex gap-2">
<Button onClick={() => setPatternModalVisible(true)}>
<PlusOutlined className="h-4 w-4 mr-1" />
Add prebuilt pattern
</Button>
<Button
variant="outline"
onClick={() => setCustomPatternModalVisible(true)}
>
<PlusOutlined className="h-4 w-4 mr-1" />
Add custom regex
</Button>
</div>
<PatternTable
patterns={selectedPatterns}
@ -255,30 +268,41 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
)}
{showKeywords && (
<Card
title={
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Title level={5} style={{ margin: 0 }}>
Blocked Keywords
</Title>
<Text type="secondary" style={{ fontSize: 14, fontWeight: 400 }}>
Block or mask specific sensitive terms and phrases
</Text>
</div>
}
size="small"
>
<div style={{ marginBottom: 16 }}>
<Space>
<Button type="primary" onClick={() => setKeywordModalVisible(true)} icon={<PlusOutlined />}>
Add keyword
</Button>
<Upload beforeUpload={handleFileUpload} accept=".yaml,.yml" showUploadList={false}>
<Button icon={<UploadOutlined />} loading={uploadValidating}>
Upload YAML file
</Button>
</Upload>
</Space>
<Card className="p-4 space-y-4">
<div className="flex justify-between items-center">
<h5 className="text-base font-semibold m-0">Blocked Keywords</h5>
<span className="text-sm text-muted-foreground font-normal">
Block or mask specific sensitive terms and phrases
</span>
</div>
<div className="flex gap-2">
<Button onClick={() => setKeywordModalVisible(true)}>
<PlusOutlined className="h-4 w-4 mr-1" />
Add keyword
</Button>
<input
ref={fileInputRef}
type="file"
accept=".yaml,.yml"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
void handleFileUpload(file);
}
if (e.target) {
e.target.value = "";
}
}}
/>
<Button
variant="outline"
disabled={uploadValidating}
onClick={() => fileInputRef.current?.click()}
>
<UploadOutlined className="h-4 w-4 mr-1" />
{uploadValidating ? "Uploading..." : "Upload YAML file"}
</Button>
</div>
<KeywordTable
keywords={blockedWords}
@ -288,29 +312,32 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
</Card>
)}
{showCompetitorIntent &&
onCompetitorIntentChange && (
<CompetitorIntentConfiguration
enabled={competitorIntentEnabled}
config={competitorIntentConfig}
onChange={onCompetitorIntentChange}
accessToken={accessToken}
/>
)}
{showCategories && contentCategories.length > 0 && onContentCategoryAdd && onContentCategoryRemove && onContentCategoryUpdate && (
<ContentCategoryConfiguration
availableCategories={contentCategories}
selectedCategories={selectedContentCategories}
onCategoryAdd={onContentCategoryAdd}
onCategoryRemove={onContentCategoryRemove}
onCategoryUpdate={onContentCategoryUpdate}
{showCompetitorIntent && onCompetitorIntentChange && (
<CompetitorIntentConfiguration
enabled={competitorIntentEnabled}
config={competitorIntentConfig}
onChange={onCompetitorIntentChange}
accessToken={accessToken}
pendingSelection={pendingCategorySelection}
onPendingSelectionChange={onPendingCategorySelectionChange}
/>
)}
{showCategories &&
contentCategories.length > 0 &&
onContentCategoryAdd &&
onContentCategoryRemove &&
onContentCategoryUpdate && (
<ContentCategoryConfiguration
availableCategories={contentCategories}
selectedCategories={selectedContentCategories}
onCategoryAdd={onContentCategoryAdd}
onCategoryRemove={onContentCategoryRemove}
onCategoryUpdate={onContentCategoryUpdate}
accessToken={accessToken}
pendingSelection={pendingCategorySelection}
onPendingSelectionChange={onPendingCategorySelectionChange}
/>
)}
<PatternModal
visible={patternModalVisible}
prebuiltPatterns={prebuiltPatterns}
@ -334,7 +361,9 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
patternAction={customPatternAction}
onNameChange={setCustomPatternName}
onRegexChange={setCustomPatternRegex}
onActionChange={(value) => setCustomPatternAction(value as "BLOCK" | "MASK")}
onActionChange={(value) =>
setCustomPatternAction(value as "BLOCK" | "MASK")
}
onAdd={handleAddCustomPattern}
onCancel={() => {
setCustomPatternModalVisible(false);
@ -350,7 +379,9 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
action={newKeywordAction}
description={newKeywordDescription}
onKeywordChange={setNewKeyword}
onActionChange={(value) => setNewKeywordAction(value as "BLOCK" | "MASK")}
onActionChange={(value) =>
setNewKeywordAction(value as "BLOCK" | "MASK")
}
onDescriptionChange={setNewKeywordDescription}
onAdd={handleAddKeyword}
onCancel={() => {

View file

@ -99,26 +99,10 @@ vi.mock("./ContentFilterDisplay", () => ({
),
}));
vi.mock("antd", async (importOriginal) => {
const actual = await importOriginal<typeof import("antd")>();
return {
...actual,
Divider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="divider">{children}</div>
),
Alert: ({
message,
type,
}: {
message: React.ReactNode;
type: string;
}) => (
<div data-testid="unsaved-alert" data-type={type}>
{message}
</div>
),
};
});
// Note: ContentFilterManager was migrated off antd Divider/Alert to a plain
// inline <hr> section divider and a lucide AlertTriangle banner. The tests
// below therefore assert against the semantic text of those elements rather
// than the legacy `data-testid="divider"` / `"unsaved-alert"` hooks.
describe("ContentFilterManager", () => {
beforeEach(() => {
@ -139,9 +123,7 @@ describe("ContentFilterManager", () => {
expect(screen.getByTestId("content-filter-config")).toBeInTheDocument();
});
expect(screen.getByTestId("divider")).toHaveTextContent(
"Content Filter Configuration"
);
expect(screen.getByText("Content Filter Configuration")).toBeInTheDocument();
});
it("should return null when guardrail is not litellm_content_filter", () => {
@ -299,16 +281,20 @@ describe("ContentFilterManager", () => {
expect(screen.getByTestId("content-filter-config")).toBeInTheDocument();
});
expect(screen.queryByTestId("unsaved-alert")).not.toBeInTheDocument();
expect(
screen.queryByText(/You have unsaved changes/i),
).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /add pattern/i }));
await waitFor(() => {
expect(screen.getByTestId("unsaved-alert")).toBeInTheDocument();
expect(
screen.getByText(/You have unsaved changes/i),
).toBeInTheDocument();
});
expect(screen.getByTestId("unsaved-alert")).toHaveTextContent(
/unsaved changes.*Save Changes/i
expect(screen.getByText(/You have unsaved changes/i)).toHaveTextContent(
/unsaved changes.*Save Changes/i,
);
});
@ -399,7 +385,9 @@ describe("ContentFilterManager", () => {
);
await waitFor(() => {
expect(screen.getByTestId("divider")).toBeInTheDocument();
expect(
screen.getByText("Content Filter Configuration"),
).toBeInTheDocument();
});
expect(screen.queryByTestId("content-filter-config")).not.toBeInTheDocument();

View file

@ -16,12 +16,9 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { Ban, Eye, EyeOff, Filter, Info, X } from "lucide-react";
import { Select as AntSelect } from "antd";
import { Ban, EyeOff, Filter, Info, X } from "lucide-react";
import { PiiEntityCategory } from "./types";
const { Option } = AntSelect;
export const formatEntityName = (name: string) => {
return name.replace(/_/g, " ");
};
@ -49,31 +46,65 @@ export const CategoryFilter: React.FC<CategoryFilterProps> = ({
selectedCategories,
onChange,
}) => {
const available = categories.filter(
(cat) => !selectedCategories.includes(cat.category),
);
return (
<div>
<div className="mb-4">
<div className="flex items-center mb-2">
<Filter className="h-3.5 w-3.5 text-muted-foreground mr-1" />
<span className="text-muted-foreground font-medium">
Filter by category
</span>
</div>
<AntSelect
mode="multiple"
placeholder="Select categories to filter by"
style={{ width: "100%" }}
onChange={onChange}
value={selectedCategories}
allowClear
showSearch
optionFilterProp="children"
className="mb-4"
>
{categories.map((cat) => (
<Option key={cat.category} value={cat.category}>
{cat.category}
</Option>
))}
</AntSelect>
<div className="space-y-2">
<Select
value=""
onValueChange={(v) => {
if (v) onChange([...selectedCategories, v]);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select categories to filter by" />
</SelectTrigger>
<SelectContent>
{available.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No more categories
</div>
) : (
available.map((cat) => (
<SelectItem key={cat.category} value={cat.category}>
{cat.category}
</SelectItem>
))
)}
</SelectContent>
</Select>
{selectedCategories.length > 0 && (
<div className="flex flex-wrap gap-1">
{selectedCategories.map((cat) => (
<Badge
key={cat}
variant="secondary"
className="flex items-center gap-1"
>
{cat}
<button
type="button"
onClick={() =>
onChange(selectedCategories.filter((c) => c !== cat))
}
className="inline-flex items-center justify-center rounded-full hover:bg-muted-foreground/20"
aria-label={`Remove ${cat}`}
>
<X size={12} />
</button>
</Badge>
))}
</div>
)}
</div>
</div>
);
};

View file

@ -1,8 +1,29 @@
import React from "react";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { Card, Text } from "@tremor/react";
import { Button, Divider, Empty, Input, Select, Space, Tooltip } from "antd";
import { Info as InfoCircleOutlined, Plus as PlusOutlined, Trash2 as DeleteOutlined } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Info as InfoCircleOutlined,
Plus as PlusOutlined,
Trash2 as DeleteOutlined,
} from "lucide-react";
export type ToolPermissionDecision = "allow" | "deny";
export type ToolPermissionDefaultAction = "allow" | "deny";
export type ToolPermissionOnDisallowedAction = "block" | "rewrite";
@ -56,7 +77,10 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({
onChange?.(nextConfig);
};
const updateRule = (ruleIndex: number, updates: Partial<ToolPermissionRuleConfig>) => {
const updateRule = (
ruleIndex: number,
updates: Partial<ToolPermissionRuleConfig>,
) => {
const nextRules = config.rules.map((rule, index) =>
index === ruleIndex ? { ...rule, ...updates } : rule,
);
@ -91,8 +115,8 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({
const entries = Object.entries(targetRule.allowed_param_patterns || {});
mutate(entries);
const updatedObject: Record<string, string> = {};
entries.forEach(([key, value]) => {
updatedObject[key] = value;
entries.forEach(([key, v]) => {
updatedObject[key] = v;
});
updateRule(ruleIndex, {
allowed_param_patterns:
@ -109,8 +133,8 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({
if (!entries[entryIndex]) {
return;
}
const [, value] = entries[entryIndex];
entries[entryIndex] = [nextPath, value];
const [, v] = entries[entryIndex];
entries[entryIndex] = [nextPath, v];
});
};
@ -128,14 +152,20 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({
});
};
const renderAllowedParamPatterns = (rule: ToolPermissionRuleConfig, index: number) => {
const renderAllowedParamPatterns = (
rule: ToolPermissionRuleConfig,
index: number,
) => {
const entries = Object.entries(rule.allowed_param_patterns || {});
if (entries.length === 0) {
return (
<Button
variant="outline"
size="sm"
disabled={disabled}
size="small"
onClick={() => updateRule(index, { allowed_param_patterns: { "": "" } })}
onClick={() =>
updateRule(index, { allowed_param_patterns: { "": "" } })
}
>
+ Restrict tool arguments (optional)
</Button>
@ -144,36 +174,49 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({
return (
<div className="space-y-2">
<Text className="text-sm text-gray-600">Argument constraints (dot or array paths)</Text>
<span className="text-sm text-muted-foreground">
Argument constraints (dot or array paths)
</span>
{entries.map(([path, pattern], patternIndex) => (
<Space key={`${rule.id || index}-${patternIndex}`} align="start">
<div
key={`${rule.id || index}-${patternIndex}`}
className="flex items-start gap-2"
>
<Input
disabled={disabled}
placeholder="messages[0].content"
value={path}
onChange={(e) => updateAllowedParamPath(index, patternIndex, e.target.value)}
onChange={(e) =>
updateAllowedParamPath(index, patternIndex, e.target.value)
}
/>
<Input
disabled={disabled}
placeholder="^email@.*$"
value={pattern}
onChange={(e) => updateAllowedParamPattern(index, patternIndex, e.target.value)}
onChange={(e) =>
updateAllowedParamPattern(index, patternIndex, e.target.value)
}
/>
<Button
variant="destructive"
size="icon"
disabled={disabled}
icon={<DeleteOutlined />}
danger
onClick={() =>
updateAllowedParamEntries(index, (entries) => {
entries.splice(patternIndex, 1);
})
}
/>
</Space>
aria-label="Remove constraint"
>
<DeleteOutlined className="h-4 w-4" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
disabled={disabled}
size="small"
onClick={() =>
updateRule(index, {
allowed_param_patterns: {
@ -190,66 +233,74 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({
};
return (
<Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<Text className="text-lg font-semibold">LiteLLM Tool Permission Guardrail</Text>
<Text className="text-sm text-gray-500">
Provide regex patterns (e.g., ^mcp__github_.*$) for tool names or types and optionally
constrain payload fields.
</Text>
<span className="text-lg font-semibold">
LiteLLM Tool Permission Guardrail
</span>
<p className="text-sm text-muted-foreground m-0">
Provide regex patterns (e.g., ^mcp__github_.*$) for tool names or
types and optionally constrain payload fields.
</p>
</div>
{!disabled && (
<Button
icon={<PlusOutlined />}
type="primary"
onClick={addRule}
className="!bg-blue-600 !text-white hover:!bg-blue-500"
>
<Button onClick={addRule}>
<PlusOutlined className="h-4 w-4 mr-1" />
Add Rule
</Button>
)}
</div>
<Divider />
<Separator className="my-4" />
{config.rules.length === 0 ? (
<Empty description="No tool rules added yet" />
<div className="py-10 text-center text-muted-foreground">
No tool rules added yet
</div>
) : (
<div className="space-y-4">
{config.rules.map((rule, index) => (
<Card key={rule.id || index} className="bg-gray-50">
<Card key={rule.id || index} className="bg-muted p-4">
<div className="flex items-center justify-between mb-3">
<Text className="font-semibold">Rule {index + 1}</Text>
<span className="font-semibold">Rule {index + 1}</span>
<Button
icon={<DeleteOutlined />}
danger
type="text"
variant="ghost"
size="sm"
disabled={disabled}
onClick={() => removeRule(index)}
className="text-destructive hover:text-destructive"
>
<DeleteOutlined className="h-4 w-4 mr-1" />
Remove
</Button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Text className="text-sm font-medium">Rule ID</Text>
<div className="space-y-2">
<Label className="text-sm font-medium">Rule ID</Label>
<Input
disabled={disabled}
placeholder="unique_rule_id"
value={rule.id}
onChange={(e) => updateRule(index, { id: e.target.value })}
onChange={(e) =>
updateRule(index, { id: e.target.value })
}
/>
</div>
<div>
<Text className="text-sm font-medium">Tool Name (optional)</Text>
<div className="space-y-2">
<Label className="text-sm font-medium">
Tool Name (optional)
</Label>
<Input
disabled={disabled}
placeholder="^mcp__github_.*$"
value={rule.tool_name ?? ""}
onChange={(e) =>
updateRule(index, {
tool_name: e.target.value.trim() === "" ? undefined : e.target.value,
tool_name:
e.target.value.trim() === ""
? undefined
: e.target.value,
})
}
/>
@ -257,15 +308,20 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 mt-4">
<div>
<Text className="text-sm font-medium">Tool Type (optional)</Text>
<div className="space-y-2">
<Label className="text-sm font-medium">
Tool Type (optional)
</Label>
<Input
disabled={disabled}
placeholder="^function$"
value={rule.tool_type ?? ""}
onChange={(e) =>
updateRule(index, {
tool_type: e.target.value.trim() === "" ? undefined : e.target.value,
tool_type:
e.target.value.trim() === ""
? undefined
: e.target.value,
})
}
/>
@ -273,66 +329,108 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({
</div>
<div className="mt-4 flex flex-col gap-2">
<Text className="text-sm font-medium">Decision</Text>
<Label className="text-sm font-medium">Decision</Label>
<Select
disabled={disabled}
value={rule.decision}
style={{ width: 200 }}
onChange={(value) => updateRule(index, { decision: value as ToolPermissionDecision })}
disabled={disabled}
onValueChange={(value) =>
updateRule(index, {
decision: value as ToolPermissionDecision,
})
}
>
<Select.Option value="allow">Allow</Select.Option>
<Select.Option value="deny">Deny</Select.Option>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="allow">Allow</SelectItem>
<SelectItem value="deny">Deny</SelectItem>
</SelectContent>
</Select>
</div>
<div className="mt-4">{renderAllowedParamPatterns(rule, index)}</div>
<div className="mt-4">
{renderAllowedParamPatterns(rule, index)}
</div>
</Card>
))}
</div>
)}
<Divider />
<Separator className="my-4" />
<div className="grid gap-4 md:grid-cols-2">
<div>
<Text className="text-sm font-medium">Default action</Text>
<div className="space-y-2">
<Label className="text-sm font-medium">Default action</Label>
<Select
disabled={disabled}
value={config.default_action}
onChange={(value) => updateConfig({ default_action: value as ToolPermissionDefaultAction })}
>
<Select.Option value="allow">Allow</Select.Option>
<Select.Option value="deny">Deny</Select.Option>
</Select>
</div>
<div>
<Text className="text-sm font-medium flex items-center gap-1">
On disallowed action
<Tooltip title="Block returns an error when a forbidden tool is invoked. Rewrite strips the tool call but lets the rest of the response continue.">
<InfoCircleOutlined />
</Tooltip>
</Text>
<Select
disabled={disabled}
value={config.on_disallowed_action}
onChange={(value) =>
updateConfig({ on_disallowed_action: value as ToolPermissionOnDisallowedAction })
onValueChange={(value) =>
updateConfig({
default_action: value as ToolPermissionDefaultAction,
})
}
>
<Select.Option value="block">Block</Select.Option>
<Select.Option value="rewrite">Rewrite</Select.Option>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="allow">Allow</SelectItem>
<SelectItem value="deny">Deny</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium flex items-center gap-1">
On disallowed action
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<InfoCircleOutlined className="h-3.5 w-3.5 text-muted-foreground" />
</span>
</TooltipTrigger>
<TooltipContent>
Block returns an error when a forbidden tool is invoked.
Rewrite strips the tool call but lets the rest of the
response continue.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<Select
value={config.on_disallowed_action}
disabled={disabled}
onValueChange={(value) =>
updateConfig({
on_disallowed_action:
value as ToolPermissionOnDisallowedAction,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="block">Block</SelectItem>
<SelectItem value="rewrite">Rewrite</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="mt-4">
<Text className="text-sm font-medium">Violation message (optional)</Text>
<Input.TextArea
<div className="mt-4 space-y-2">
<Label className="text-sm font-medium">
Violation message (optional)
</Label>
<Textarea
disabled={disabled}
rows={3}
placeholder="This violates our org policy..."
value={config.violation_message_template}
onChange={(e) => updateConfig({ violation_message_template: e.target.value })}
onChange={(e) =>
updateConfig({ violation_message_template: e.target.value })
}
/>
</div>
</Card>