mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
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:
parent
18bc2211ab
commit
10c89b4e83
8 changed files with 2187 additions and 1062 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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'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. "all keys tagged <code>healthcare</code> get HIPAA guardrails." 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'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. "all keys tagged{" "}
|
||||
<code>healthcare</code> get HIPAA guardrails." 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}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue