mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
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:
parent
c100c04688
commit
cf6493df11
8 changed files with 1653 additions and 1089 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 "Competitor comparison" policy
|
||||
</li>
|
||||
<li>
|
||||
<strong>Medium (≥)</strong>: Treat as possible comparison → uses "Possible competitor comparison" 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 "Competitor comparison" policy
|
||||
</li>
|
||||
<li>
|
||||
<strong>Medium (≥)</strong>: Treat as possible comparison → uses
|
||||
"Possible competitor comparison" 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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue