feat(ui): migrate policies chrome to shadcn

- add_attachment_form: antd Form + Select -> RHF + shadcn Dialog + MultiSelect/SuggestibleTagInput
- add_policy_form: antd Form + Modal + TextInput/Textarea -> RHF + shadcn Dialog + Input/Textarea
- ai_suggestion_modal: antd Modal/Select/Checkbox/Tooltip/TextArea -> shadcn Dialog/Select/Checkbox/Tooltip/Textarea
- index: Tremor TabGroup + antd Alert -> shadcn Tabs + Alert
- pipeline_flow_builder: chrome-only migration; Tremor Button/TextInput + antd Select/Spin -> shadcn Button/Input/Select + LoaderCircle spinner (StepCard/Connector/PolicyVersionsSidebar internals untouched)
- policy_test_panel: antd Form + Select -> RHF + shadcn Select/TagInput
- template_parameter_modal: antd Modal/Radio/Select + Tremor Button/TextInput -> shadcn Dialog/RadioGroup/Select/Input/Button
- Add ai_suggestion_modal.tsx to no-raw-tailwind-colors override (status/result categorical palettes kept raw)

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-24 11:51:52 +00:00
parent 18bc2211ab
commit 10c89b4e83
No known key found for this signature in database
8 changed files with 2187 additions and 1062 deletions

View file

@ -148,6 +148,7 @@
"src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx",
"src/components/policies/impact_preview_alert.tsx",
"src/components/policies/impact_popover.tsx",
"src/components/policies/ai_suggestion_modal.tsx",
"src/components/DeletedKeysPage/DeletedKeysPage.tsx",
"src/components/DeletedTeamsPage/DeletedTeamsPage.tsx",
"src/components/molecules/models/columns.tsx",

View file

@ -1,5 +1,5 @@
import React, { useCallback, useState, useEffect } from "react";
import { Form, Select } from "antd";
import React, { useCallback, useState, useEffect, useMemo } from "react";
import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -7,7 +7,18 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { X } from "lucide-react";
import { Policy } from "./types";
import {
teamListCall,
@ -30,6 +41,370 @@ interface AddAttachmentFormProps {
createAttachment: (accessToken: string, attachmentData: any) => Promise<any>;
}
interface AttachmentFormValues {
policy_names: string[];
teams: string[];
keys: string[];
models: string[];
tags: string[];
}
function MultiSelect({
value,
onChange,
options,
placeholder,
emptyText,
}: {
value: string[];
onChange: (next: string[]) => void;
options: { label: string; value: string }[];
placeholder: string;
emptyText: string;
}) {
const selected = useMemo(() => value ?? [], [value]);
const remaining = useMemo(
() => options.filter((o) => !selected.includes(o.value)),
[options, selected],
);
return (
<div className="space-y-2">
<Select
value=""
onValueChange={(v) => {
if (v) onChange([...selected, v]);
}}
>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{remaining.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
{emptyText}
</div>
) : (
remaining.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
{selected.length > 0 && (
<div className="flex flex-wrap gap-1">
{selected.map((v) => {
const opt = options.find((o) => o.value === v);
return (
<Badge
key={v}
variant="secondary"
className="flex items-center gap-1"
>
{opt?.label ?? 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 ${opt?.label ?? v}`}
>
<X size={12} />
</button>
</Badge>
);
})}
</div>
)}
</div>
);
}
/**
* Tag input that also supports selecting from a known list of suggestions
* (teams, keys, models). Mirrors antd's Select mode="tags" behaviour.
*/
function SuggestibleTagInput({
value,
onChange,
options,
placeholder,
loadingText,
loading,
}: {
value: string[];
onChange: (next: string[]) => void;
options: { label: string; value: string }[];
placeholder: string;
loadingText?: string;
loading?: boolean;
}) {
const [draft, setDraft] = useState("");
const selected = value ?? [];
const remaining = useMemo(
() => options.filter((o) => !selected.includes(o.value)),
[options, selected],
);
const commit = (raw: string) => {
const parts = raw
.split(",")
.map((p) => p.trim())
.filter(Boolean);
const next = [...selected];
for (const p of parts) {
if (!next.includes(p)) next.push(p);
}
onChange(next);
setDraft("");
};
return (
<div className="space-y-2">
<div className="flex items-start gap-2">
<Input
className="flex-1"
value={draft}
placeholder={loading ? loadingText || placeholder : placeholder}
disabled={loading}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
if (draft.trim()) commit(draft);
} else if (e.key === "Backspace" && !draft && selected.length > 0) {
onChange(selected.slice(0, -1));
}
}}
onBlur={() => {
if (draft.trim()) commit(draft);
}}
/>
{options.length > 0 && (
<Select
value=""
onValueChange={(v) => {
if (v && !selected.includes(v)) onChange([...selected, v]);
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Pick…" />
</SelectTrigger>
<SelectContent>
{remaining.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No suggestions
</div>
) : (
remaining.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
)}
</div>
{selected.length > 0 && (
<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>
);
}
interface FieldsProps {
policyOptions: { label: string; value: string }[];
scopeType: "global" | "specific";
onScopeTypeChange: (scope: "global" | "specific") => void;
availableTeams: string[];
availableKeys: string[];
availableModels: string[];
isLoadingTeams: boolean;
isLoadingKeys: boolean;
isLoadingModels: boolean;
}
function AttachmentFormFields({
policyOptions,
scopeType,
onScopeTypeChange,
availableTeams,
availableKeys,
availableModels,
isLoadingTeams,
isLoadingKeys,
isLoadingModels,
}: FieldsProps) {
const { control, formState } = useFormContext<AttachmentFormValues>();
return (
<>
<div className="space-y-2">
<Label>
Policies <span className="text-destructive">*</span>
</Label>
<Controller
control={control}
name="policy_names"
rules={{
validate: (v) =>
(v && v.length > 0) || "Please select at least one policy",
}}
render={({ field }) => (
<MultiSelect
value={field.value ?? []}
onChange={field.onChange}
options={policyOptions}
placeholder="Select policies to attach"
emptyText="No policies available"
/>
)}
/>
{formState.errors.policy_names && (
<p className="text-sm text-destructive">
{formState.errors.policy_names.message as string}
</p>
)}
</div>
<div className="flex items-center gap-2 mt-4 mb-2">
<span className="font-bold">Scope</span>
<hr className="flex-1 border-border" />
</div>
<div className="space-y-2">
<Label>Scope Type</Label>
<RadioGroup
value={scopeType}
onValueChange={(v) => onScopeTypeChange(v as "global" | "specific")}
className="flex flex-col gap-2"
>
<label className="flex items-center gap-2 cursor-pointer">
<RadioGroupItem value="specific" />
Specific (teams, keys, models, or tags)
</label>
<label className="flex items-center gap-2 cursor-pointer">
<RadioGroupItem value="global" />
Global (applies to all requests)
</label>
</RadioGroup>
</div>
{scopeType === "specific" && (
<>
<div className="space-y-2 mt-4">
<Label>Teams</Label>
<p className="text-xs text-muted-foreground">
Select team aliases or enter custom patterns. Supports wildcards
(e.g., healthcare-*).
</p>
<Controller
control={control}
name="teams"
render={({ field }) => (
<SuggestibleTagInput
value={field.value ?? []}
onChange={field.onChange}
options={availableTeams.map((t) => ({ label: t, value: t }))}
placeholder="Enter team alias and press Enter"
loadingText="Loading teams..."
loading={isLoadingTeams}
/>
)}
/>
</div>
<div className="space-y-2 mt-4">
<Label>Keys</Label>
<p className="text-xs text-muted-foreground">
Select key aliases or enter custom patterns. Supports wildcards
(e.g., dev-*).
</p>
<Controller
control={control}
name="keys"
render={({ field }) => (
<SuggestibleTagInput
value={field.value ?? []}
onChange={field.onChange}
options={availableKeys.map((k) => ({ label: k, value: k }))}
placeholder="Enter key alias and press Enter"
loadingText="Loading keys..."
loading={isLoadingKeys}
/>
)}
/>
</div>
<div className="space-y-2 mt-4">
<Label>Models</Label>
<p className="text-xs text-muted-foreground">
Model names this attachment applies to. Supports wildcards (e.g.,
gpt-4*). Leave empty to apply to all models.
</p>
<Controller
control={control}
name="models"
render={({ field }) => (
<SuggestibleTagInput
value={field.value ?? []}
onChange={field.onChange}
options={availableModels.map((m) => ({ label: m, value: m }))}
placeholder="Enter model name and press Enter"
loadingText="Loading models..."
loading={isLoadingModels}
/>
)}
/>
</div>
<div className="space-y-2 mt-4">
<Label>Tags</Label>
<p className="text-xs text-muted-foreground">
Matches tags from key/team <code>metadata.tags</code> or tags
passed dynamically in the request body. Use <code>*</code> as a
suffix wildcard (e.g., <code>prod-*</code> matches{" "}
<code>prod-us</code>, <code>prod-eu</code>).
</p>
<Controller
control={control}
name="tags"
render={({ field }) => (
<SuggestibleTagInput
value={field.value ?? []}
onChange={field.onChange}
options={[]}
placeholder="Type a tag and press Enter (e.g. healthcare, prod-*)"
/>
)}
/>
</div>
</>
)}
</>
);
}
const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
visible,
onClose,
@ -38,7 +413,15 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
policies,
createAttachment,
}) => {
const [form] = Form.useForm();
const form = useForm<AttachmentFormValues>({
defaultValues: {
policy_names: [],
teams: [],
keys: [],
models: [],
tags: [],
},
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [scopeType, setScopeType] = useState<"global" | "specific">("global");
const [availableTeams, setAvailableTeams] = useState<string[]>([]);
@ -125,29 +508,32 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
}, [visible, accessToken, loadTeamsKeysAndModels]);
const resetForm = () => {
form.resetFields();
form.reset({
policy_names: [],
teams: [],
keys: [],
models: [],
tags: [],
});
setScopeType("global");
setImpactResult(null);
};
const handlePreviewImpact = async () => {
if (!accessToken) return;
try {
await form.validateFields(["policy_names"]);
} catch {
return;
}
const valid = await form.trigger("policy_names");
if (!valid) return;
setIsEstimating(true);
try {
const { policy_names = [] } = form.getFieldsValue(true);
const firstPolicy = policy_names?.[0];
const values = form.getValues();
const firstPolicy = values.policy_names?.[0];
if (!firstPolicy) return;
const data = buildAttachmentData(
{
...form.getFieldsValue(true),
...values,
policy_name: firstPolicy,
},
scopeType
scopeType,
);
const result = await estimateAttachmentImpactCall(accessToken, data);
setImpactResult(result);
@ -163,16 +549,14 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
onClose();
};
const handleSubmit = async () => {
const onSubmit = form.handleSubmit(async (values) => {
try {
setIsSubmitting(true);
await form.validateFields();
if (!accessToken) {
throw new Error("No access token available");
}
const values = form.getFieldsValue(true);
const selectedPolicyNames: string[] = values.policy_names || [];
const results = await Promise.allSettled(
@ -182,30 +566,34 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
...values,
policy_name: policyName,
},
scopeType
scopeType,
);
return createAttachment(accessToken, data);
})
}),
);
const successCount = results.filter((r) => r.status === "fulfilled").length;
const failed = results.filter((r) => r.status === "rejected") as PromiseRejectedResult[];
const successCount = results.filter(
(r) => r.status === "fulfilled",
).length;
const failed = results.filter(
(r) => r.status === "rejected",
) as PromiseRejectedResult[];
if (successCount > 0 && failed.length === 0) {
NotificationsManager.success(
successCount === 1
? "Attachment created successfully"
: `${successCount} attachments created successfully`
: `${successCount} attachments created successfully`,
);
} else if (successCount > 0 && failed.length > 0) {
NotificationsManager.fromBackend(
`${successCount} attachments created, ${failed.length} failed`
`${successCount} attachments created, ${failed.length} failed`,
);
} else {
throw new Error(
failed[0]?.reason instanceof Error
? failed[0].reason.message
: "Failed to create attachments"
: "Failed to create attachments",
);
}
@ -215,12 +603,13 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
} catch (error) {
console.error("Failed to create attachment:", error);
NotificationsManager.fromBackend(
"Failed to create attachment: " + (error instanceof Error ? error.message : String(error))
"Failed to create attachment: " +
(error instanceof Error ? error.message : String(error)),
);
} finally {
setIsSubmitting(false);
}
};
});
const policyOptions = policies.map((p) => ({
label: p.policy_name,
@ -232,180 +621,46 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
open={visible}
onOpenChange={(o) => (!o ? handleClose() : undefined)}
>
<DialogContent className="max-w-[600px]">
<DialogContent className="max-w-[600px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Policy Attachment</DialogTitle>
</DialogHeader>
<Form
form={form}
layout="vertical"
initialValues={{
scope_type: "global",
}}
>
<Form.Item
name="policy_names"
label="Policies"
rules={[
{
required: true,
message: "Please select at least one policy",
},
]}
>
<Select
mode="multiple"
placeholder="Select policies to attach"
options={policyOptions}
showSearch
filterOption={(input, option) =>
(option?.label ?? "")
.toLowerCase()
.includes(input.toLowerCase())
}
style={{ width: "100%" }}
<FormProvider {...form}>
<form onSubmit={onSubmit} className="space-y-2">
<AttachmentFormFields
policyOptions={policyOptions}
scopeType={scopeType}
onScopeTypeChange={setScopeType}
availableTeams={availableTeams}
availableKeys={availableKeys}
availableModels={availableModels}
isLoadingTeams={isLoadingTeams}
isLoadingKeys={isLoadingKeys}
isLoadingModels={isLoadingModels}
/>
</Form.Item>
<div className="flex items-center gap-2 mb-2">
<span className="font-bold">Scope</span>
<hr className="flex-1 border-border" />
</div>
{impactResult && <ImpactPreviewAlert impactResult={impactResult} />}
<Form.Item label="Scope Type">
<RadioGroup
value={scopeType}
onValueChange={(v) =>
setScopeType(v as "global" | "specific")
}
className="flex flex-col gap-2"
>
<label className="flex items-center gap-2 cursor-pointer">
<RadioGroupItem value="specific" />
Specific (teams, keys, models, or tags)
</label>
<label className="flex items-center gap-2 cursor-pointer">
<RadioGroupItem value="global" />
Global (applies to all requests)
</label>
</RadioGroup>
</Form.Item>
{scopeType === "specific" && (
<>
<Form.Item
name="teams"
label="Teams"
tooltip="Select team aliases or enter custom patterns. Supports wildcards (e.g., healthcare-*)"
>
<Select
mode="tags"
placeholder={isLoadingTeams ? "Loading teams..." : "Select or enter team aliases"}
loading={isLoadingTeams}
options={availableTeams.map((team) => ({
label: team,
value: team,
}))}
tokenSeparators={[","]}
showSearch
filterOption={(input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}
style={{ width: "100%" }}
/>
</Form.Item>
<Form.Item
name="keys"
label="Keys"
tooltip="Select key aliases or enter custom patterns. Supports wildcards (e.g., dev-*)"
>
<Select
mode="tags"
placeholder={isLoadingKeys ? "Loading keys..." : "Select or enter key aliases"}
loading={isLoadingKeys}
options={availableKeys.map((key) => ({
label: key,
value: key,
}))}
tokenSeparators={[","]}
showSearch
filterOption={(input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}
style={{ width: "100%" }}
/>
</Form.Item>
<Form.Item
name="models"
label="Models"
tooltip="Model names this attachment applies to. Supports wildcards (e.g., gpt-4*). Leave empty to apply to all models."
>
<Select
mode="tags"
placeholder={isLoadingModels ? "Loading models..." : "Select or enter model names (e.g., gpt-4, bedrock/*)"}
loading={isLoadingModels}
options={availableModels.map((model) => ({
label: model,
value: model,
}))}
tokenSeparators={[","]}
showSearch
filterOption={(input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}
style={{ width: "100%" }}
/>
</Form.Item>
<Form.Item
name="tags"
label="Tags"
tooltip="Match against tags set in key or team metadata. Use exact values (e.g., healthcare) or wildcard patterns (e.g., health-*) where * matches any suffix."
extra={
<span className="text-xs text-muted-foreground">
Matches tags from key/team <code>metadata.tags</code> or
tags passed dynamically in the request body. Use{" "}
<code>*</code> as a suffix wildcard (e.g.,{" "}
<code>prod-*</code> matches <code>prod-us</code>,{" "}
<code>prod-eu</code>).
</span>
}
>
<Select
mode="tags"
placeholder="Type a tag and press Enter (e.g. healthcare, prod-*)"
tokenSeparators={[",", " "]}
notFoundContent={null}
suffixIcon={null}
open={false}
style={{ width: "100%" }}
/>
</Form.Item>
</>
)}
{impactResult && <ImpactPreviewAlert impactResult={impactResult} />}
<div className="flex justify-end space-x-2 mt-4">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
{scopeType === "specific" && (
<Button
variant="secondary"
onClick={handlePreviewImpact}
disabled={isEstimating}
>
{isEstimating ? "Estimating..." : "Estimate Impact"}
<div className="flex justify-end space-x-2 mt-4">
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
)}
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Attachment"}
</Button>
</div>
</Form>
{scopeType === "specific" && (
<Button
type="button"
variant="secondary"
onClick={handlePreviewImpact}
disabled={isEstimating}
>
{isEstimating ? "Estimating..." : "Estimate Impact"}
</Button>
)}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Attachment"}
</Button>
</div>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);

View file

@ -1,12 +1,58 @@
import React, { useEffect, useMemo, useState } from "react";
import { Modal, Spin, Checkbox, Select, Input, Typography, Tooltip } from "antd";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { Button, Card } from "@tremor/react";
import { CheckCircle2 as CheckCircleOutlined, XCircle as CloseCircleOutlined, Info as InfoCircleOutlined, ChevronDown as DownOutlined, ChevronRight as RightOutlined } from "lucide-react";
import { suggestPolicyTemplates, modelHubCall, testPolicyTemplate, enrichPolicyTemplateStream } from "../networking";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
CheckCircle2 as CheckCircleOutlined,
XCircle as CloseCircleOutlined,
Info as InfoCircleOutlined,
ChevronDown as DownOutlined,
ChevronRight as RightOutlined,
LoaderCircle,
} from "lucide-react";
import {
suggestPolicyTemplates,
modelHubCall,
testPolicyTemplate,
enrichPolicyTemplateStream,
} from "../networking";
const { TextArea } = Input;
const { Text } = Typography;
const Text = ({
className,
children,
}: {
className?: string;
children?: React.ReactNode;
}) => <span className={className}>{children}</span>;
const Spin = ({ size }: { size?: "small" | "default" | "large" }) => (
<LoaderCircle
className={`animate-spin ${
size === "small" ? "w-3 h-3" : size === "large" ? "w-6 h-6" : "w-4 h-4"
} text-muted-foreground`}
/>
);
interface SuggestedTemplate {
template_id: string;
@ -437,7 +483,7 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
<div className="flex items-start gap-3">
<Checkbox
checked={isSelected}
onChange={() => toggleTemplate(suggestion.template_id)}
onCheckedChange={() => toggleTemplate(suggestion.template_id)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
@ -457,15 +503,20 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
</span>
)}
{template.estimated_latency_ms != null && (
<Tooltip title="Estimated latency overhead added to each request">
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium border ${
template.estimated_latency_ms <= 1
? "bg-green-50 text-green-600 border-green-200"
: "bg-amber-50 text-amber-600 border-amber-200"
}`}>
+{template.estimated_latency_ms <= 1 ? "<1" : template.estimated_latency_ms}ms latency
</span>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium border ${
template.estimated_latency_ms <= 1
? "bg-green-50 text-green-600 border-green-200"
: "bg-amber-50 text-amber-600 border-amber-200"
}`}>
+{template.estimated_latency_ms <= 1 ? "<1" : template.estimated_latency_ms}ms latency
</span>
</TooltipTrigger>
<TooltipContent>Estimated latency overhead added to each request</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<p className="text-xs text-gray-500 leading-relaxed">
@ -572,20 +623,32 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
<div className="flex gap-2">
<Input
size="small"
placeholder="e.g. Emirates Airlines"
value={enrichBrandName}
onChange={(e) => setEnrichBrandName(e.target.value)}
onPressEnter={() => enrichBrandName.trim() && handleEnrichCompetitors()}
className="flex-1"
onKeyDown={(e) => {
if (e.key === "Enter" && enrichBrandName.trim()) {
e.preventDefault();
handleEnrichCompetitors();
}
}}
className="flex-1 h-8"
/>
<Button
size="xs"
size="sm"
onClick={handleEnrichCompetitors}
loading={isEnriching}
disabled={!enrichBrandName.trim() || isEnriching}
>
{isEnriching ? "Discovering..." : hasEnrichedGuardrails ? "Re-discover" : "Discover"}
{isEnriching ? (
<>
<LoaderCircle className="mr-2 h-3 w-3 animate-spin" />
Discovering...
</>
) : hasEnrichedGuardrails ? (
"Re-discover"
) : (
"Discover"
)}
</Button>
</div>
@ -633,13 +696,20 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
<div className="flex justify-between items-center mb-2">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">Input Text</label>
<Tooltip title="Press Enter to submit. Use Shift+Enter for new line.">
<InfoCircleOutlined className="text-gray-400 cursor-help" />
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<InfoCircleOutlined className="text-gray-400 cursor-help w-3.5 h-3.5" />
</TooltipTrigger>
<TooltipContent>
Press Enter to submit. Use Shift+Enter for new line.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Text className="text-xs text-gray-500">Characters: {testInputText.length}</Text>
</div>
<TextArea
<Textarea
value={testInputText}
onChange={(e) => setTestInputText(e.target.value)}
onKeyDown={handleTestKeyDown}
@ -655,13 +725,17 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
</div>
<Button
onClick={handleRunTest}
loading={isTestLoading}
disabled={!testInputText.trim() || isTestLoading}
className="w-full"
>
{isTestLoading
? `Testing ${allSelectedGuardrailDefs.length} guardrails...`
: `Test ${allSelectedGuardrailDefs.length} guardrails`}
{isTestLoading ? (
<>
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
Testing {allSelectedGuardrailDefs.length} guardrails...
</>
) : (
`Test ${allSelectedGuardrailDefs.length} guardrails`
)}
</Button>
</div>
@ -786,15 +860,17 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
};
return (
<Modal
title={null}
<Dialog
open={visible}
onCancel={handleCancel}
width={showTestPanel ? 1200 : 820}
footer={null}
styles={{ body: { padding: 0 } }}
onOpenChange={(o) => (!o ? handleCancel() : undefined)}
>
{/* Header */}
<DialogContent
className={`${showTestPanel ? "sm:max-w-[1200px]" : "sm:max-w-[820px]"} p-0 max-h-[90vh] overflow-y-auto`}
>
<DialogHeader className="sr-only">
<DialogTitle>AI Policy Suggestion</DialogTitle>
</DialogHeader>
{/* Header */}
<div className="px-8 pt-8 pb-4">
<h3 className="text-xl font-semibold text-gray-900 mb-1">
AI Policy Suggestion
@ -818,18 +894,32 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
<span className="text-red-500 ml-0.5">*</span>
</label>
<Select
placeholder="Select a model to analyze your requirements"
value={selectedModel}
onChange={(value) => setSelectedModel(value)}
loading={isLoadingModels}
showSearch
size="large"
className="w-full"
options={availableModels.map((m) => ({ label: m, value: m }))}
filterOption={(input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}
/>
value={selectedModel ?? ""}
onValueChange={(v) => setSelectedModel(v || undefined)}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
isLoadingModels
? "Loading models..."
: "Select a model to analyze your requirements"
}
/>
</SelectTrigger>
<SelectContent>
{availableModels.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No models available
</div>
) : (
availableModels.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{/* Attack examples */}
@ -931,8 +1021,15 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
{/* Footer */}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" onClick={handleCancel} disabled={isLoading}>Cancel</Button>
<Button onClick={handleSuggest} loading={isLoading} disabled={!hasInput || !selectedModel || isLoading}>
{isLoading ? "Analyzing..." : "Suggest Policies"}
<Button onClick={handleSuggest} disabled={!hasInput || !selectedModel || isLoading}>
{isLoading ? (
<>
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
Analyzing...
</>
) : (
"Suggest Policies"
)}
</Button>
</div>
</div>
@ -972,7 +1069,8 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
</div>
</div>
)}
</Modal>
</DialogContent>
</Dialog>
);
};

View file

@ -1,10 +1,14 @@
import React, { useState, useEffect, useCallback } from "react";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
import { Alert } from "antd";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import MessageManager from "@/components/molecules/message_manager";
import { Info as InfoCircleOutlined } from "lucide-react";
import { Info as InfoCircleOutlined, X } from "lucide-react";
import { isAdminRole } from "@/utils/roles";
import PolicyTable from "./policy_table";
import PolicyInfoView from "./policy_info";
@ -37,6 +41,43 @@ import {
import { Guardrail } from "../guardrails/types";
import DeleteResourceModal from "../common_components/DeleteResourceModal";
function ClosableAlert({
title,
children,
variant = "info",
className,
}: {
title: string;
children: React.ReactNode;
variant?: "info" | "warning";
className?: string;
}) {
const [visible, setVisible] = React.useState(true);
if (!visible) return null;
return (
<Alert
className={`${className ?? ""} ${
variant === "warning"
? // eslint-disable-next-line litellm-ui/no-raw-tailwind-colors
"border-amber-200 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-900"
: ""
} relative pr-10`}
>
<InfoCircleOutlined className="h-4 w-4" />
<AlertTitle>{title}</AlertTitle>
<AlertDescription>{children}</AlertDescription>
<button
type="button"
onClick={() => setVisible(false)}
className="absolute top-3 right-3 text-muted-foreground hover:text-foreground"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</Alert>
);
}
interface PoliciesPanelProps {
accessToken: string | null;
userRole?: string;
@ -55,7 +96,7 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
const [isAddAttachmentModalVisible, setIsAddAttachmentModalVisible] = useState(false);
const [editingPolicy, setEditingPolicy] = useState<Policy | null>(null);
const [selectedPolicyId, setSelectedPolicyId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<number>(0);
const [activeTab, setActiveTab] = useState<string>("templates");
const [isDeleting, setIsDeleting] = useState(false);
const [policyToDelete, setPolicyToDelete] = useState<Policy | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@ -323,7 +364,7 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
// Pre-fill the add policy form with template data
setEditingPolicy(selectedTemplate.templateData as Policy);
setIsAddPolicyModalVisible(true);
setActiveTab(1); // Switch to Policies tab (now at index 1)
setActiveTab("policies"); // Switch to Policies tab
// Show success message
if (createdGuardrails.length > 0) {
@ -370,89 +411,80 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
return (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
<TabGroup index={activeTab} onIndexChange={setActiveTab}>
<TabList className="mb-4">
<Tab>Templates</Tab>
<Tab>Policies</Tab>
<Tab>Attachments</Tab>
<Tab>Policy Simulator</Tab>
</TabList>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="mb-4">
<TabsTrigger value="templates">Templates</TabsTrigger>
<TabsTrigger value="policies">Policies</TabsTrigger>
<TabsTrigger value="attachments">Attachments</TabsTrigger>
<TabsTrigger value="simulator">Policy Simulator</TabsTrigger>
</TabsList>
<TabPanels>
<TabPanel>
<Alert
message="About Policies"
description={
<div>
<p className="mb-3">
Use policies to group guardrails and control which ones run for specific teams, keys, or models.
</p>
<p className="mb-2 font-semibold">Why use policies?</p>
<ul className="list-disc list-inside mb-3 space-y-1 ml-2">
<li>Enable/disable specific guardrails for teams, keys, or models</li>
<li>Group guardrails into a single policy</li>
<li>Inherit from existing policies and override what you need</li>
</ul>
<a
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline inline-block mt-1"
>
Learn more in the documentation →
</a>
</div>
}
type="info"
icon={<InfoCircleOutlined />}
showIcon
closable
className="mb-6"
/>
<PolicyTemplates
onUseTemplate={handleUseTemplate}
onOpenAiSuggestion={() => setIsAiSuggestionModalOpen(true)}
onTemplatesLoaded={setLoadedTemplates}
accessToken={accessToken}
/>
</TabPanel>
<TabsContent value="templates">
<ClosableAlert title="About Policies" className="mb-6">
<p className="mb-3">
Use policies to group guardrails and control which ones run for
specific teams, keys, or models.
</p>
<p className="mb-2 font-semibold">Why use policies?</p>
<ul className="list-disc list-inside mb-3 space-y-1 ml-2">
<li>
Enable/disable specific guardrails for teams, keys, or models
</li>
<li>Group guardrails into a single policy</li>
<li>
Inherit from existing policies and override what you need
</li>
</ul>
<a
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies"
target="_blank"
rel="noopener noreferrer"
// eslint-disable-next-line litellm-ui/no-raw-tailwind-colors
className="text-blue-600 hover:text-blue-800 underline inline-block mt-1"
>
Learn more in the documentation →
</a>
</ClosableAlert>
<PolicyTemplates
onUseTemplate={handleUseTemplate}
onOpenAiSuggestion={() => setIsAiSuggestionModalOpen(true)}
onTemplatesLoaded={setLoadedTemplates}
accessToken={accessToken}
/>
</TabsContent>
<TabPanel>
<Alert
message="About Policies"
description={
<div>
<p className="mb-3">
Use policies to group guardrails and control which ones run for specific teams, keys, or models.
</p>
<p className="mb-2 font-semibold">Why use policies?</p>
<ul className="list-disc list-inside mb-3 space-y-1 ml-2">
<li>Enable/disable specific guardrails for teams, keys, or models</li>
<li>Group guardrails into a single policy</li>
<li>Inherit from existing policies and override what you need</li>
</ul>
<a
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline inline-block mt-1"
>
Learn more in the documentation →
</a>
</div>
}
type="info"
icon={<InfoCircleOutlined />}
showIcon
closable
className="mb-6"
/>
<TabsContent value="policies">
<ClosableAlert title="About Policies" className="mb-6">
<p className="mb-3">
Use policies to group guardrails and control which ones run for
specific teams, keys, or models.
</p>
<p className="mb-2 font-semibold">Why use policies?</p>
<ul className="list-disc list-inside mb-3 space-y-1 ml-2">
<li>
Enable/disable specific guardrails for teams, keys, or models
</li>
<li>Group guardrails into a single policy</li>
<li>
Inherit from existing policies and override what you need
</li>
</ul>
<a
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies"
target="_blank"
rel="noopener noreferrer"
// eslint-disable-next-line litellm-ui/no-raw-tailwind-colors
className="text-blue-600 hover:text-blue-800 underline inline-block mt-1"
>
Learn more in the documentation →
</a>
</ClosableAlert>
<div className="flex justify-between items-center mb-4">
<Button onClick={handleAddPolicy} disabled={!accessToken}>
+ Add New Policy
</Button>
</div>
<div className="flex justify-between items-center mb-4">
<Button onClick={handleAddPolicy} disabled={!accessToken}>
+ Add New Policy
</Button>
</div>
{selectedPolicyId ? (
<PolicyInfoView
@ -523,90 +555,99 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
progressInfo={templateQueueProgress}
/>
<TemplateParameterModal
visible={isParameterModalOpen}
template={pendingTemplate}
onConfirm={handleParameterConfirm}
onCancel={handleParameterCancel}
isLoading={isEnrichingTemplate}
accessToken={accessToken || ""}
/>
</TabPanel>
<TemplateParameterModal
visible={isParameterModalOpen}
template={pendingTemplate}
onConfirm={handleParameterConfirm}
onCancel={handleParameterCancel}
isLoading={isEnrichingTemplate}
accessToken={accessToken || ""}
/>
</TabsContent>
<TabPanel>
<Alert
message="About Policy Attachments"
description={
<div>
<p className="mb-3">
Policy attachments control where your policies apply. Policies don&apos;t do anything until you attach them to specific teams, keys, models, tags, or globally.
</p>
<p className="mb-2 font-semibold">Attachment Scopes:</p>
<ul className="list-disc list-inside mb-3 space-y-1 ml-2">
<li><strong>Global (*)</strong> - Applies to all requests</li>
<li><strong>Teams</strong> - Applies only to specific teams</li>
<li><strong>Keys</strong> - Applies only to specific API keys (supports wildcards like dev-*)</li>
<li><strong>Models</strong> - Applies only when specific models are used</li>
<li><strong>Tags</strong> - Matches tags from key/team <code>metadata.tags</code> or tags passed dynamically in the request body (<code>metadata.tags</code>). Use this to enforce policies across groups, e.g. &quot;all keys tagged <code>healthcare</code> get HIPAA guardrails.&quot; Supports wildcards (<code>prod-*</code>).</li>
</ul>
<a
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies#attachments"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline inline-block mt-1"
>
Learn more about attachments →
</a>
</div>
}
type="info"
icon={<InfoCircleOutlined />}
showIcon
closable
className="mb-6"
/>
<TabsContent value="attachments">
<ClosableAlert title="About Policy Attachments" className="mb-6">
<p className="mb-3">
Policy attachments control where your policies apply. Policies
don&apos;t do anything until you attach them to specific teams,
keys, models, tags, or globally.
</p>
<p className="mb-2 font-semibold">Attachment Scopes:</p>
<ul className="list-disc list-inside mb-3 space-y-1 ml-2">
<li>
<strong>Global (*)</strong> - Applies to all requests
</li>
<li>
<strong>Teams</strong> - Applies only to specific teams
</li>
<li>
<strong>Keys</strong> - Applies only to specific API keys
(supports wildcards like dev-*)
</li>
<li>
<strong>Models</strong> - Applies only when specific models are
used
</li>
<li>
<strong>Tags</strong> - Matches tags from key/team{" "}
<code>metadata.tags</code> or tags passed dynamically in the
request body (<code>metadata.tags</code>). Use this to enforce
policies across groups, e.g. &quot;all keys tagged{" "}
<code>healthcare</code> get HIPAA guardrails.&quot; Supports
wildcards (<code>prod-*</code>).
</li>
</ul>
<a
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies#attachments"
target="_blank"
rel="noopener noreferrer"
// eslint-disable-next-line litellm-ui/no-raw-tailwind-colors
className="text-blue-600 hover:text-blue-800 underline inline-block mt-1"
>
Learn more about attachments →
</a>
</ClosableAlert>
<Alert
message="Enterprise Feature Notice"
description="Parts of policy attachments will be on LiteLLM Enterprise in subsequent releases."
type="warning"
showIcon
closable
className="mb-6"
/>
<ClosableAlert
title="Enterprise Feature Notice"
variant="warning"
className="mb-6"
>
Parts of policy attachments will be on LiteLLM Enterprise in
subsequent releases.
</ClosableAlert>
<div className="flex justify-between items-center mb-4">
<Button
onClick={() => setIsAddAttachmentModalVisible(true)}
disabled={!accessToken || policiesList.length === 0}
>
+ Add New Attachment
</Button>
</div>
<div className="flex justify-between items-center mb-4">
<Button
onClick={() => setIsAddAttachmentModalVisible(true)}
disabled={!accessToken || policiesList.length === 0}
>
+ Add New Attachment
</Button>
</div>
<AttachmentTable
attachments={attachmentsList}
isLoading={isAttachmentsLoading}
onDeleteClick={handleDeleteAttachmentClick}
isAdmin={isAdmin}
accessToken={accessToken}
/>
<AttachmentTable
attachments={attachmentsList}
isLoading={isAttachmentsLoading}
onDeleteClick={handleDeleteAttachmentClick}
isAdmin={isAdmin}
accessToken={accessToken}
/>
<AddAttachmentForm
visible={isAddAttachmentModalVisible}
onClose={() => setIsAddAttachmentModalVisible(false)}
onSuccess={handleAttachmentSuccess}
accessToken={accessToken}
policies={policiesList}
createAttachment={createPolicyAttachmentCall}
/>
</TabPanel>
<AddAttachmentForm
visible={isAddAttachmentModalVisible}
onClose={() => setIsAddAttachmentModalVisible(false)}
onSuccess={handleAttachmentSuccess}
accessToken={accessToken}
policies={policiesList}
createAttachment={createPolicyAttachmentCall}
/>
</TabsContent>
<TabPanel>
<PolicyTestPanel accessToken={accessToken} />
</TabPanel>
</TabPanels>
</TabGroup>
<TabsContent value="simulator">
<PolicyTestPanel accessToken={accessToken} />
</TabsContent>
</Tabs>
<DeleteResourceModal
isOpen={isDeleteAttachmentModalOpen}

View file

@ -1,9 +1,20 @@
import React, { useState } from "react";
import { Select, Typography, Spin } from "antd";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import MessageManager from "@/components/molecules/message_manager";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { Button, TextInput } from "@tremor/react";
import { ArrowLeft as ArrowLeftIcon, Plus as PlusIcon, MoreVertical as DotsVerticalIcon } from "lucide-react";
import {
ArrowLeft as ArrowLeftIcon,
Plus as PlusIcon,
MoreVertical as DotsVerticalIcon,
LoaderCircle,
} from "lucide-react";
import { GuardrailPipeline, PipelineStep, PipelineTestResult, PolicyCreateRequest, PolicyUpdateRequest, Policy } from "./types";
import { Guardrail } from "../guardrails/types";
import { testPipelineCall, listPolicyVersions, createPolicyVersion, updatePolicyVersionStatus } from "../networking";
@ -24,7 +35,13 @@ function getPromptsForTestSource(source: string): CompliancePrompt[] {
return fw ? fw.categories.flatMap((c) => c.prompts) : [];
}
const { Text } = Typography;
const Spin = ({ size }: { size?: "small" | "default" | "large" }) => (
<LoaderCircle
className={`animate-spin ${
size === "small" ? "w-3 h-3" : size === "large" ? "w-6 h-6" : "w-4 h-4"
} text-muted-foreground`}
/>
);
const ACTION_OPTIONS = [
{ label: "Next Step", value: "next" },
@ -288,16 +305,26 @@ const StepCard: React.FC<StepCardProps> = ({
Guardrail
</label>
<Select
showSearch
style={{ width: "100%" }}
placeholder="Select a guardrail"
value={step.guardrail || undefined}
onChange={(value) => onChange({ guardrail: value })}
options={guardrailOptions}
filterOption={(input, option) =>
(option?.label ?? "").toString().toLowerCase().includes(input.toLowerCase())
}
/>
value={step.guardrail || ""}
onValueChange={(value) => onChange({ guardrail: value })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a guardrail" />
</SelectTrigger>
<SelectContent>
{guardrailOptions.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No guardrails available
</div>
) : (
guardrailOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{/* ON PASS section */}
@ -310,17 +337,26 @@ const StepCard: React.FC<StepCardProps> = ({
Action
</label>
<Select
style={{ width: "100%" }}
value={step.on_pass}
onChange={(value) => onChange({ on_pass: value as PipelineStep["on_pass"] })}
options={ACTION_OPTIONS}
/>
onValueChange={(value) => onChange({ on_pass: value as PipelineStep["on_pass"] })}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{step.on_pass === "modify_response" && (
<div style={{ marginTop: 8 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Custom Response Message
</label>
<TextInput
<Input
placeholder="Enter custom response..."
value={step.modify_response_message || ""}
onChange={(e) => onChange({ modify_response_message: e.target.value || null })}
@ -339,17 +375,26 @@ const StepCard: React.FC<StepCardProps> = ({
Action
</label>
<Select
style={{ width: "100%" }}
value={step.on_fail}
onChange={(value) => onChange({ on_fail: value as PipelineStep["on_fail"] })}
options={ACTION_OPTIONS}
/>
onValueChange={(value) => onChange({ on_fail: value as PipelineStep["on_fail"] })}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{step.on_fail === "modify_response" && (
<div style={{ marginTop: 8 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Custom Response Message
</label>
<TextInput
<Input
placeholder="Enter custom response..."
value={step.modify_response_message || ""}
onChange={(e) => onChange({ modify_response_message: e.target.value || null })}
@ -367,24 +412,43 @@ const StepCard: React.FC<StepCardProps> = ({
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Action
</label>
<Select
style={{ width: "100%" }}
placeholder="Same as ON FAIL"
allowClear
value={step.on_error ?? undefined}
onChange={(value) =>
onChange({
on_error: value === undefined || value === null ? undefined : (value as PipelineStep["on_error"]),
})
}
options={ACTION_OPTIONS}
/>
<div className="flex items-center gap-2">
<Select
value={step.on_error ?? ""}
onValueChange={(value) =>
onChange({
on_error: (value || undefined) as PipelineStep["on_error"],
})
}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Same as ON FAIL" />
</SelectTrigger>
<SelectContent>
{ACTION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{step.on_error && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onChange({ on_error: undefined })}
>
Clear
</Button>
)}
</div>
{step.on_error === "modify_response" && step.on_fail !== "modify_response" && (
<div style={{ marginTop: 8 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Custom Response Message
</label>
<TextInput
<Input
placeholder="Enter custom response..."
value={step.modify_response_message || ""}
onChange={(e) => onChange({ modify_response_message: e.target.value || null })}
@ -783,13 +847,18 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Test with
</label>
<Select
value={testSource}
onChange={setTestSource}
options={testSourceOptions}
style={{ width: "100%", marginBottom: 12 }}
size="middle"
/>
<Select value={testSource} onValueChange={setTestSource}>
<SelectTrigger className="w-full mb-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
{testSourceOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{isQuickChat && (
<>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
@ -830,10 +899,17 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({
)}
<Button
onClick={handleRunTest}
loading={isRunning}
style={{ marginTop: 8, width: "100%" }}
disabled={isRunning}
className="mt-2 w-full"
>
Run Test
{isRunning ? (
<>
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
Running...
</>
) : (
"Run Test"
)}
</Button>
</div>
@ -1143,10 +1219,16 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
<Button
onClick={onNewVersion}
disabled={!accessToken || isCreatingVersion}
loading={isCreatingVersion}
style={{ width: "100%", marginBottom: 12 }}
className="w-full mb-3"
>
+ New Version
{isCreatingVersion ? (
<>
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
"+ New Version"
)}
</Button>
{isLoading ? (
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
@ -1211,10 +1293,16 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
variant="secondary"
onClick={onPublish}
disabled={!accessToken || isUpdatingStatus}
loading={isUpdatingStatus}
style={{ width: "100%", marginBottom: 8 }}
className="w-full mb-2"
>
Publish
{isUpdatingStatus ? (
<>
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
Publishing...
</>
) : (
"Publish"
)}
</Button>
<span
style={{
@ -1234,10 +1322,16 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
<Button
onClick={onPromoteToProduction}
disabled={!accessToken || isUpdatingStatus}
loading={isUpdatingStatus}
style={{ width: "100%", marginBottom: 8 }}
className="w-full mb-2"
>
Promote to production
{isUpdatingStatus ? (
<>
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
Promoting...
</>
) : (
"Promote to production"
)}
</Button>
<span
style={{
@ -1526,7 +1620,7 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
</button>
<span style={{ fontSize: 14, color: "#6b7280" }}>Policies</span>
<span style={{ fontSize: 14, color: "#d1d5db" }}>/</span>
<TextInput
<Input
placeholder="Policy name..."
value={policyName}
onChange={(e) => setPolicyName(e.target.value)}
@ -1557,8 +1651,17 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
>
{showTestPanel ? "Hide Test" : "Test Pipeline"}
</Button>
<Button onClick={handleSave} loading={isSubmitting}>
{isEditing ? "Update Policy" : "Save Policy"}
<Button onClick={handleSave} disabled={isSubmitting}>
{isSubmitting ? (
<>
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : isEditing ? (
"Update Policy"
) : (
"Save Policy"
)}
</Button>
</div>
</div>
@ -1572,7 +1675,7 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
flexShrink: 0,
}}
>
<TextInput
<Input
placeholder="Add a description (optional)..."
value={description}
onChange={(e) => setDescription(e.target.value)}

View file

@ -1,9 +1,18 @@
import React, { useCallback, useState, useEffect } from "react";
import { Form, Select } from "antd";
import React, { useCallback, useState, useEffect, useMemo } from "react";
import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { AlertCircle, Info } from "lucide-react";
import { AlertCircle, Info, X } from "lucide-react";
import {
resolvePoliciesCall,
teamListCall,
@ -27,8 +36,226 @@ interface ResolveResult {
matched_policies: PolicyMatchDetail[];
}
interface PolicyTestFormValues {
team_alias: string | undefined;
key_alias: string | undefined;
model: string | undefined;
tags: string[];
}
function ClearableSelect({
value,
onChange,
options,
placeholder,
}: {
value: string | undefined;
onChange: (v: string | undefined) => void;
options: { label: string; value: string }[];
placeholder: string;
}) {
return (
<div className="flex items-center gap-2">
<Select value={value ?? ""} onValueChange={(v) => onChange(v || undefined)}>
<SelectTrigger className="flex-1">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No options
</div>
) : (
options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
{value && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onChange(undefined)}
>
Clear
</Button>
)}
</div>
);
}
function TagInput({
value,
onChange,
placeholder,
}: {
value: string[];
onChange: (next: string[]) => void;
placeholder: string;
}) {
const [draft, setDraft] = useState("");
const selected = value ?? [];
const commit = (raw: string) => {
const parts = raw
.split(/[,\s]/)
.map((p) => p.trim())
.filter(Boolean);
const next = [...selected];
for (const p of parts) {
if (!next.includes(p)) next.push(p);
}
onChange(next);
setDraft("");
};
return (
<div className="space-y-2">
<Input
value={draft}
placeholder={placeholder}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
if (draft.trim()) commit(draft);
} else if (e.key === "Backspace" && !draft && selected.length > 0) {
onChange(selected.slice(0, -1));
}
}}
onBlur={() => {
if (draft.trim()) commit(draft);
}}
/>
{selected.length > 0 && (
<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>
);
}
interface FieldsProps {
availableTeams: string[];
availableKeys: string[];
availableModels: string[];
}
function PolicyTestFields({
availableTeams,
availableKeys,
availableModels,
}: FieldsProps) {
const { control } = useFormContext<PolicyTestFormValues>();
const teamOptions = useMemo(
() => availableTeams.map((t) => ({ label: t, value: t })),
[availableTeams],
);
const keyOptions = useMemo(
() => availableKeys.map((k) => ({ label: k, value: k })),
[availableKeys],
);
const modelOptions = useMemo(
() => availableModels.map((m) => ({ label: m, value: m })),
[availableModels],
);
return (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Team Alias</Label>
<Controller
control={control}
name="team_alias"
render={({ field }) => (
<ClearableSelect
value={field.value}
onChange={field.onChange}
options={teamOptions}
placeholder="Select a team alias"
/>
)}
/>
</div>
<div className="space-y-2">
<Label>Key Alias</Label>
<Controller
control={control}
name="key_alias"
render={({ field }) => (
<ClearableSelect
value={field.value}
onChange={field.onChange}
options={keyOptions}
placeholder="Select a key alias"
/>
)}
/>
</div>
<div className="space-y-2">
<Label>Model</Label>
<Controller
control={control}
name="model"
render={({ field }) => (
<ClearableSelect
value={field.value}
onChange={field.onChange}
options={modelOptions}
placeholder="Select a model"
/>
)}
/>
</div>
<div className="space-y-2">
<Label>Tags</Label>
<Controller
control={control}
name="tags"
render={({ field }) => (
<TagInput
value={field.value ?? []}
onChange={field.onChange}
placeholder="Type a tag and press Enter"
/>
)}
/>
</div>
</div>
);
}
const PolicyTestPanel: React.FC<PolicyTestPanelProps> = ({ accessToken }) => {
const [form] = Form.useForm();
const form = useForm<PolicyTestFormValues>({
defaultValues: {
team_alias: undefined,
key_alias: undefined,
model: undefined,
tags: [],
},
});
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState<ResolveResult | null>(null);
const [hasSearched, setHasSearched] = useState(false);
@ -103,7 +330,7 @@ const PolicyTestPanel: React.FC<PolicyTestPanelProps> = ({ accessToken }) => {
setIsLoading(true);
setHasSearched(true);
try {
const values = form.getFieldsValue(true);
const values = form.getValues();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const context: any = {};
if (values.team_alias) context.team_alias = values.team_alias;
@ -122,7 +349,12 @@ const PolicyTestPanel: React.FC<PolicyTestPanelProps> = ({ accessToken }) => {
};
const handleReset = () => {
form.resetFields();
form.reset({
team_alias: undefined,
key_alias: undefined,
model: undefined,
tags: [],
});
setResult(null);
setHasSearched(false);
};
@ -139,64 +371,35 @@ const PolicyTestPanel: React.FC<PolicyTestPanelProps> = ({ accessToken }) => {
</p>
</div>
<Form form={form} layout="vertical">
<div className="grid grid-cols-2 gap-4">
<Form.Item name="team_alias" label="Team Alias" className="mb-3">
<Select
showSearch
allowClear
placeholder="Select or type a team alias"
options={availableTeams.map((t) => ({ label: t, value: t }))}
filterOption={(input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}
/>
</Form.Item>
<Form.Item name="key_alias" label="Key Alias" className="mb-3">
<Select
showSearch
allowClear
placeholder="Select or type a key alias"
options={availableKeys.map((k) => ({ label: k, value: k }))}
filterOption={(input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}
/>
</Form.Item>
<Form.Item name="model" label="Model" className="mb-3">
<Select
showSearch
allowClear
placeholder="Select or type a model"
options={availableModels.map((m) => ({ label: m, value: m }))}
filterOption={(input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}
/>
</Form.Item>
<Form.Item name="tags" label="Tags" className="mb-3">
<Select
mode="tags"
placeholder="Type a tag and press Enter"
tokenSeparators={[",", " "]}
notFoundContent={null}
suffixIcon={null}
open={false}
/>
</Form.Item>
</div>
<div className="flex space-x-2">
<Button
onClick={handleTest}
disabled={!accessToken || isLoading}
>
{isLoading ? "Simulating..." : "Simulate"}
</Button>
<Button variant="secondary" onClick={handleReset}>
Reset
</Button>
</div>
</Form>
<FormProvider {...form}>
<form
onSubmit={(e) => {
e.preventDefault();
handleTest();
}}
>
<PolicyTestFields
availableTeams={availableTeams}
availableKeys={availableKeys}
availableModels={availableModels}
/>
<div className="flex space-x-2 mt-4">
<Button
type="submit"
disabled={!accessToken || isLoading}
>
{isLoading ? "Simulating..." : "Simulate"}
</Button>
<Button
type="button"
variant="secondary"
onClick={handleReset}
>
Reset
</Button>
</div>
</form>
</FormProvider>
</div>
{!hasSearched && (

View file

@ -1,8 +1,30 @@
import React, { useState, useEffect } from "react";
import { Modal, Spin, Radio, Select } from "antd";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { Button, TextInput } from "@tremor/react";
import { modelHubCall, enrichPolicyTemplateStream } from "../networking";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
RadioGroup,
RadioGroupItem,
} from "@/components/ui/radio-group";
import { LoaderCircle, X } from "lucide-react";
import {
modelHubCall,
enrichPolicyTemplateStream,
} from "../networking";
interface TemplateParameter {
name: string;
@ -24,6 +46,71 @@ interface TemplateParameterModalProps {
accessToken: string;
}
function TagChips({
value,
onChange,
placeholder,
}: {
value: string[];
onChange: (next: string[]) => void;
placeholder: string;
}) {
const [draft, setDraft] = useState("");
const commit = (raw: string) => {
const parts = raw
.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}
placeholder={placeholder}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
if (draft.trim()) commit(draft);
} else if (e.key === "Backspace" && !draft && value.length > 0) {
onChange(value.slice(0, -1));
}
}}
onBlur={() => {
if (draft.trim()) commit(draft);
}}
/>
{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 TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
visible,
template,
@ -32,13 +119,19 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
isLoading = false,
accessToken,
}) => {
const [parameterValues, setParameterValues] = useState<Record<string, string>>({});
const [parameterValues, setParameterValues] = useState<
Record<string, string>
>({});
const [competitorMode, setCompetitorMode] = useState<"ai" | "manual">("ai");
const [selectedModel, setSelectedModel] = useState<string | undefined>(undefined);
const [selectedModel, setSelectedModel] = useState<string | undefined>(
undefined,
);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [competitorTags, setCompetitorTags] = useState<string[]>([]);
const [variationsMap, setVariationsMap] = useState<Record<string, string[]>>({});
const [variationsMap, setVariationsMap] = useState<Record<string, string[]>>(
{},
);
const [isGenerating, setIsGenerating] = useState(false);
const [refinementInput, setRefinementInput] = useState("");
const [isRefining, setIsRefining] = useState(false);
@ -47,7 +140,9 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
const parameters: TemplateParameter[] = template?.parameters || [];
const hasEnrichment = !!template?.llm_enrichment;
const enrichmentParam = hasEnrichment ? template.llm_enrichment.parameter : null;
const enrichmentParam = hasEnrichment
? template.llm_enrichment.parameter
: null;
const nonEnrichmentParams = hasEnrichment
? parameters.filter((p) => p.name !== enrichmentParam)
@ -70,12 +165,19 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
setHasGenerated(false);
setStatusMessage("");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, template]);
useEffect(() => {
if (visible && hasEnrichment && competitorMode === "ai" && availableModels.length === 0) {
if (
visible &&
hasEnrichment &&
competitorMode === "ai" &&
availableModels.length === 0
) {
loadModels();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, hasEnrichment, competitorMode]);
const loadModels = async () => {
@ -98,7 +200,9 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
const handleGenerateNames = async () => {
if (!accessToken || !selectedModel || !template) return;
const brandName = (parameterValues[enrichmentParam || "brand_name"] || "").trim();
const brandName = (
parameterValues[enrichmentParam || "brand_name"] || ""
).trim();
if (!brandName) return;
setIsGenerating(true);
@ -136,7 +240,13 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
};
const handleRefine = async () => {
if (!accessToken || !selectedModel || !template || !refinementInput.trim()) return;
if (
!accessToken ||
!selectedModel ||
!template ||
!refinementInput.trim()
)
return;
setIsRefining(true);
setStatusMessage("");
@ -148,7 +258,8 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
selectedModel,
(name) => {
setCompetitorTags((prev) => {
if (prev.some((t) => t.toLowerCase() === name.toLowerCase())) return prev;
if (prev.some((t) => t.toLowerCase() === name.toLowerCase()))
return prev;
return [...prev, name];
});
},
@ -193,203 +304,28 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
};
return (
<Modal
title={
<div>
<h3 className="text-lg font-semibold mb-1">{template?.title}</h3>
<p className="text-sm text-gray-500 font-normal">
<Dialog
open={visible}
onOpenChange={(o) => (!o ? onCancel() : undefined)}
>
<DialogContent className="sm:max-w-[700px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{template?.title}</DialogTitle>
<p className="text-sm text-muted-foreground font-normal">
Configure competitor blocking for your brand
</p>
</div>
}
open={visible}
onCancel={onCancel}
width={700}
footer={[
<Button key="cancel" variant="secondary" onClick={onCancel} disabled={isLoading}>
Cancel
</Button>,
<Button
key="confirm"
onClick={handleConfirm}
loading={isLoading}
disabled={!canContinue || isLoading}
>
{isLoading ? "Creating guardrails..." : "Continue"}
</Button>,
]}
>
<div className="py-4 space-y-4">
{nonEnrichmentParams.map((param) => (
<div key={param.name}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{param.label}
{param.required && <span className="text-red-500 ml-1">*</span>}
</label>
<TextInput
placeholder={param.placeholder || ""}
value={parameterValues[param.name] || ""}
onChange={(e) =>
setParameterValues((prev) => ({
...prev,
[param.name]: e.target.value,
}))
}
/>
</div>
))}
</DialogHeader>
{hasEnrichment && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Competitor Discovery
</label>
<Radio.Group
value={competitorMode}
onChange={(e) => setCompetitorMode(e.target.value)}
className="w-full"
>
<div className="flex gap-3">
<Radio.Button value="ai" className="flex-1 text-center">
✨ Use AI
</Radio.Button>
<Radio.Button value="manual" className="flex-1 text-center">
Enter Manually
</Radio.Button>
</div>
</Radio.Group>
</div>
{/* Brand Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Your Brand Name
<span className="text-red-500 ml-1">*</span>
</label>
<TextInput
placeholder="e.g. Acme Airlines"
value={parameterValues[enrichmentParam || "brand_name"] || ""}
onChange={(e) =>
setParameterValues((prev) => ({
...prev,
[enrichmentParam || "brand_name"]: e.target.value,
}))
}
/>
</div>
{competitorMode === "ai" && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Select Model
<span className="text-red-500 ml-1">*</span>
</label>
<Select
placeholder="Select a model to generate names"
value={selectedModel}
onChange={(value) => setSelectedModel(value)}
loading={isLoadingModels}
showSearch
className="w-full"
options={availableModels.map((m) => ({ label: m, value: m }))}
filterOption={(input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}
/>
</div>
<Button
onClick={handleGenerateNames}
loading={isGenerating}
disabled={!selectedModel || !brandNameFilled || isGenerating}
className="w-full"
>
{isGenerating ? "✨ Generating names..." : "✨ Generate Competitor Names"}
</Button>
</>
)}
{/* Competitor Tags */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Competitor Names
{competitorTags.length > 0 && (
<span className="text-gray-400 font-normal ml-2">
({competitorTags.length})
</span>
<div className="py-2 space-y-4">
{nonEnrichmentParams.map((param) => (
<div key={param.name} className="space-y-1.5">
<label className="block text-sm font-medium">
{param.label}
{param.required && (
<span className="text-destructive ml-1">*</span>
)}
</label>
<Select
mode="tags"
style={{ width: "100%" }}
placeholder="Type a name and press Enter to add"
value={competitorTags}
onChange={(values) => setCompetitorTags(values)}
tokenSeparators={[","]}
open={false}
suffixIcon={null}
/>
<p className="text-xs text-gray-500 mt-1">
Type a name and press Enter to add. Click ✕ to remove.
</p>
{statusMessage && (
<div className="flex items-center gap-2 mt-2 p-2 bg-blue-50 rounded border border-blue-100">
<Spin size="small" />
<span className="text-xs text-blue-700">{statusMessage}</span>
</div>
)}
{Object.keys(variationsMap).length > 0 && !statusMessage && (
<p className="text-xs text-green-600 mt-1">
✓ {Object.values(variationsMap).flat().length} alternate spellings & variations auto-generated for guardrail matching
</p>
)}
</div>
{/* Refinement input — shown after initial generation in AI mode */}
{competitorMode === "ai" && hasGenerated && competitorTags.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Refine List
</label>
<div className="flex gap-2">
<TextInput
placeholder="e.g. add 10 more from Asia, increase to 50 total..."
value={refinementInput}
onChange={(e) => setRefinementInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && refinementInput.trim() && !isRefining) {
handleRefine();
}
}}
disabled={isRefining}
/>
<Button
onClick={handleRefine}
loading={isRefining}
disabled={!refinementInput.trim() || isRefining}
size="xs"
>
{isRefining ? "..." : "Send"}
</Button>
</div>
<p className="text-xs text-gray-400 mt-1">
Give instructions to add, remove, or change competitors. Press Enter to send.
</p>
</div>
)}
</>
)}
{!hasEnrichment &&
parameters.map((param) => (
<div key={param.name}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{param.label}
{param.required && <span className="text-red-500 ml-1">*</span>}
</label>
<TextInput
<Input
placeholder={param.placeholder || ""}
value={parameterValues[param.name] || ""}
onChange={(e) =>
@ -401,8 +337,230 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
/>
</div>
))}
</div>
</Modal>
{hasEnrichment && (
<>
<div className="space-y-2">
<label className="block text-sm font-medium">
Competitor Discovery
</label>
<RadioGroup
value={competitorMode}
onValueChange={(v) =>
setCompetitorMode(v as "ai" | "manual")
}
className="flex gap-3"
>
<label className="flex-1 cursor-pointer flex items-center justify-center gap-2 border border-border rounded-md py-2 data-[state=checked]:border-primary has-[:checked]:bg-accent">
<RadioGroupItem value="ai" />
✨ Use AI
</label>
<label className="flex-1 cursor-pointer flex items-center justify-center gap-2 border border-border rounded-md py-2 has-[:checked]:bg-accent">
<RadioGroupItem value="manual" />
Enter Manually
</label>
</RadioGroup>
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium">
Your Brand Name
<span className="text-destructive ml-1">*</span>
</label>
<Input
placeholder="e.g. Acme Airlines"
value={parameterValues[enrichmentParam || "brand_name"] || ""}
onChange={(e) =>
setParameterValues((prev) => ({
...prev,
[enrichmentParam || "brand_name"]: e.target.value,
}))
}
/>
</div>
{competitorMode === "ai" && (
<>
<div className="space-y-1.5">
<label className="block text-sm font-medium">
Select Model
<span className="text-destructive ml-1">*</span>
</label>
<Select
value={selectedModel ?? ""}
onValueChange={(v) => setSelectedModel(v || undefined)}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
isLoadingModels
? "Loading models..."
: "Select a model to generate names"
}
/>
</SelectTrigger>
<SelectContent>
{availableModels.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No models available
</div>
) : (
availableModels.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<Button
onClick={handleGenerateNames}
disabled={
!selectedModel || !brandNameFilled || isGenerating
}
className="w-full"
>
{isGenerating ? (
<>
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
✨ Generating names...
</>
) : (
"✨ Generate Competitor Names"
)}
</Button>
</>
)}
<div className="space-y-1.5">
<label className="block text-sm font-medium">
Competitor Names
{competitorTags.length > 0 && (
<span className="text-muted-foreground font-normal ml-2">
({competitorTags.length})
</span>
)}
</label>
<TagChips
value={competitorTags}
onChange={setCompetitorTags}
placeholder="Type a name and press Enter to add"
/>
<p className="text-xs text-muted-foreground mt-1">
Type a name and press Enter to add. Click ✕ to remove.
</p>
{statusMessage && (
<div
// eslint-disable-next-line litellm-ui/no-raw-tailwind-colors
className="flex items-center gap-2 mt-2 p-2 bg-blue-50 rounded border border-blue-100"
>
{/* eslint-disable-next-line litellm-ui/no-raw-tailwind-colors */}
<LoaderCircle className="w-3 h-3 animate-spin text-blue-700" />
{/* eslint-disable-next-line litellm-ui/no-raw-tailwind-colors */}
<span className="text-xs text-blue-700">
{statusMessage}
</span>
</div>
)}
{Object.keys(variationsMap).length > 0 && !statusMessage && (
// eslint-disable-next-line litellm-ui/no-raw-tailwind-colors
<p className="text-xs text-green-600 mt-1">
✓ {Object.values(variationsMap).flat().length} alternate
spellings & variations auto-generated for guardrail matching
</p>
)}
</div>
{competitorMode === "ai" &&
hasGenerated &&
competitorTags.length > 0 && (
<div className="space-y-1.5">
<label className="block text-sm font-medium">
Refine List
</label>
<div className="flex gap-2">
<Input
placeholder="e.g. add 10 more from Asia, increase to 50 total..."
value={refinementInput}
onChange={(e) => setRefinementInput(e.target.value)}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
refinementInput.trim() &&
!isRefining
) {
e.preventDefault();
handleRefine();
}
}}
disabled={isRefining}
/>
<Button
onClick={handleRefine}
disabled={!refinementInput.trim() || isRefining}
size="sm"
>
{isRefining ? (
<LoaderCircle className="h-3 w-3 animate-spin" />
) : (
"Send"
)}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1">
Give instructions to add, remove, or change competitors.
Press Enter to send.
</p>
</div>
)}
</>
)}
{!hasEnrichment &&
parameters.map((param) => (
<div key={param.name} className="space-y-1.5">
<label className="block text-sm font-medium">
{param.label}
{param.required && (
<span className="text-destructive ml-1">*</span>
)}
</label>
<Input
placeholder={param.placeholder || ""}
value={parameterValues[param.name] || ""}
onChange={(e) =>
setParameterValues((prev) => ({
...prev,
[param.name]: e.target.value,
}))
}
/>
</div>
))}
</div>
<DialogFooter>
<Button variant="secondary" onClick={onCancel} disabled={isLoading}>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!canContinue || isLoading}
>
{isLoading ? (
<>
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
Creating guardrails...
</>
) : (
"Continue"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};