mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
refactor(ui): migrate guardrail and vector store forms to react-hook-form and shadcn (#37306)
* refactor(ui): migrate guardrail and vector store forms to react-hook-form and shadcn Ports four antd forms in the guardrails and vector stores pages onto react-hook-form with zod resolvers and the shadcn field kit, and takes the files they live in off light-only Tailwind colors VectorStoreForm and vector_store_info now build their payloads from typed form values instead of an antd FormInstance, seeding the edit view through an explicit mapper rather than spreading the whole server record. The submit modal in TeamGuardrailsTab moves to the same shape, and its URL rule is reproduced exactly: src/lib/forms/antdUrl.ts compiles the pattern async-validator uses for `type: "url"`, with a test asserting the compiled source and flags match, so a protocol-less www host keeps passing and a bare domain keeps failing CompetitorIntentConfiguration had no FormInstance at all: its antd Form was a layout wrapper with no named items and no onFinish, so it moves onto the field primitives directly rather than gaining form state it never had. Its tag and threshold controls are replaced by local TagsInput and ThresholdInput components that reproduce what antd did, comma token separators plus commit on blur for tags, and clamp-on-blur with step-precision display for the thresholds, without introducing the native number constraints that would newly block the surrounding guardrail form Every payload is pinned by a characterization test that was proven green against the antd original before the swap and then re-run unedited * fix(ui): keep the vector store edit form saving when the server sends null The proxy returns null for an unset vector_store_name or vector_store_description rather than omitting the key, and both columns are nullable. z.string().optional() accepts undefined but rejects null, so loading any store whose name or description was never set left the edit form stuck on "Invalid input: expected string, received null" and it could not submit at all. nullish() accepts both and forwards null unchanged, which is what the antd version did Pinned by an untouched-save case that seeds both fields null and clicks Save without typing anything. It fails against the optional() schema with zero requests sent, and passes against both the fix and the antd original, sending vector_store_name and vector_store_description as null Also drops the deep import into @rc-component/async-validator, an undeclared transitive dependency that failed the knip gate. The URL parity assertion now compares against a checked-in snapshot of the pattern async-validator 5.1.0 compiles, so it stays an exact-equality check, and removes a comment that only restated the networking layer's error handling
This commit is contained in:
parent
7d97bab405
commit
d6fe9712fa
16 changed files with 2317 additions and 840 deletions
|
|
@ -322,12 +322,6 @@
|
|||
}
|
||||
},
|
||||
"src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -1546,9 +1540,6 @@
|
|||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react/no-unescaped-entities": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
|
|
@ -1565,7 +1556,7 @@
|
|||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
|
|
@ -3240,4 +3231,4 @@
|
|||
"count": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,193 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import { listGuardrailSubmissions } from "@/components/networking";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
import { TeamGuardrailsTab } from "./TeamGuardrailsTab";
|
||||
|
||||
const mutateAsync = vi.fn();
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
listGuardrailSubmissions: vi.fn(),
|
||||
approveGuardrailSubmission: vi.fn(),
|
||||
rejectGuardrailSubmission: vi.fn(),
|
||||
updateGuardrailCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/guardrails/useRegisterGuardrail", () => ({
|
||||
useRegisterGuardrail: () => ({ mutateAsync, isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/common_components/team_dropdown", () => ({
|
||||
default: ({ value, onChange }: { value?: string; onChange?: (value: string) => void }) => (
|
||||
<input aria-label="team" value={value ?? ""} onChange={(event) => onChange?.(event.target.value)} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: vi.fn() }));
|
||||
|
||||
const authorized = {
|
||||
isLoading: false,
|
||||
isAuthorized: true,
|
||||
token: "sk-test",
|
||||
accessToken: "sk-test",
|
||||
userId: "user-1",
|
||||
userEmail: "user@example.com",
|
||||
userRole: "Admin",
|
||||
userRoleLabel: "Admin",
|
||||
isViewOnly: false,
|
||||
premiumUser: false,
|
||||
disabledPersonalKeyCreation: null,
|
||||
showSSOBanner: false,
|
||||
};
|
||||
|
||||
const openSubmitModal = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
renderWithProviders(<TeamGuardrailsTab accessToken="sk-test" />);
|
||||
await user.click(await screen.findByRole("button", { name: /Add Guardrail/ }));
|
||||
await screen.findByText("Submit Guardrail for Review");
|
||||
};
|
||||
|
||||
const fillRequiredFields = async (user: ReturnType<typeof userEvent.setup>, apiBase: string) => {
|
||||
await user.type(screen.getByLabelText("team"), "team-1");
|
||||
await user.type(screen.getByPlaceholderText("e.g. pii-detection"), "pii-detection");
|
||||
await user.type(screen.getByPlaceholderText("https://your-guardrail-api.com/v1/check"), apiBase);
|
||||
};
|
||||
|
||||
const submit = (user: ReturnType<typeof userEvent.setup>) =>
|
||||
user.click(screen.getByRole("button", { name: "Submit for Review" }));
|
||||
|
||||
const registeredPayload = () => mutateAsync.mock.calls[0][0];
|
||||
|
||||
const VALIDATION_MESSAGES = [
|
||||
"Select a team",
|
||||
"Enter a guardrail name",
|
||||
"Enter the API base URL",
|
||||
"Must be a valid URL",
|
||||
"Must be a JSON object",
|
||||
"Invalid JSON",
|
||||
];
|
||||
|
||||
const visibleErrors = () => VALIDATION_MESSAGES.filter((message) => screen.queryByText(message) !== null);
|
||||
|
||||
describe("TeamGuardrailsTab submit payload", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useAuthorized).mockReturnValue(authorized);
|
||||
vi.mocked(listGuardrailSubmissions).mockResolvedValue({
|
||||
submissions: [],
|
||||
summary: { total: 0, pending_review: 0, active: 0, rejected: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the guardrail defaults and leaves guardrail_info undefined when the optional fields are blank", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openSubmitModal(user);
|
||||
|
||||
await fillRequiredFields(user, "https://guard.example.com/v1/check");
|
||||
await submit(user);
|
||||
|
||||
await vi.waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
|
||||
expect(registeredPayload()).toStrictEqual({
|
||||
team_id: "team-1",
|
||||
guardrail_name: "pii-detection",
|
||||
litellm_params: {
|
||||
guardrail: "generic_guardrail_api",
|
||||
mode: "pre_call",
|
||||
api_base: "https://guard.example.com/v1/check",
|
||||
},
|
||||
guardrail_info: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("merges the extra params underneath the form fields so the form's api_base and mode win", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openSubmitModal(user);
|
||||
|
||||
await fillRequiredFields(user, "https://guard.example.com/v1/check");
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('{"forward_api_key": true, "headers": {"X-Custom": "value"}}'),
|
||||
'{{"forward_api_key": true, "api_base": "https://ignored.example.com", "mode": "post_call"}',
|
||||
);
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('{"description": "Detects PII in requests"}'),
|
||||
'{{"description": "Detects PII"}',
|
||||
);
|
||||
await submit(user);
|
||||
|
||||
await vi.waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
|
||||
expect(registeredPayload()).toStrictEqual({
|
||||
team_id: "team-1",
|
||||
guardrail_name: "pii-detection",
|
||||
litellm_params: {
|
||||
forward_api_key: true,
|
||||
api_base: "https://guard.example.com/v1/check",
|
||||
mode: "pre_call",
|
||||
guardrail: "generic_guardrail_api",
|
||||
},
|
||||
guardrail_info: { description: "Detects PII" },
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the mode the user picked", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openSubmitModal(user);
|
||||
|
||||
await fillRequiredFields(user, "https://guard.example.com/v1/check");
|
||||
await user.click(screen.getAllByRole("combobox")[1]);
|
||||
const options = await screen.findAllByText("During Call");
|
||||
await user.click(options[options.length - 1]);
|
||||
await submit(user);
|
||||
|
||||
await vi.waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
|
||||
expect(registeredPayload().litellm_params.mode).toBe("during_call");
|
||||
});
|
||||
|
||||
it("blocks an empty submit and reports every required field", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openSubmitModal(user);
|
||||
|
||||
await submit(user);
|
||||
|
||||
expect(await screen.findByText("Enter a guardrail name")).toBeInTheDocument();
|
||||
expect(visibleErrors()).toStrictEqual(["Select a team", "Enter a guardrail name", "Enter the API base URL"]);
|
||||
expect(mutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a protocol-less www host but rejects a bare domain", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openSubmitModal(user);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("https://your-guardrail-api.com/v1/check"), "www.example.com");
|
||||
await submit(user);
|
||||
await screen.findByText("Enter a guardrail name");
|
||||
expect(visibleErrors()).toStrictEqual(["Select a team", "Enter a guardrail name"]);
|
||||
|
||||
await user.clear(screen.getByPlaceholderText("https://your-guardrail-api.com/v1/check"));
|
||||
await user.type(screen.getByPlaceholderText("https://your-guardrail-api.com/v1/check"), "example.com");
|
||||
await submit(user);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(visibleErrors()).toStrictEqual(["Select a team", "Enter a guardrail name", "Must be a valid URL"]),
|
||||
);
|
||||
expect(mutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a non-object extra params value and unparsable guardrail info", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openSubmitModal(user);
|
||||
|
||||
await fillRequiredFields(user, "https://guard.example.com/v1/check");
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('{"forward_api_key": true, "headers": {"X-Custom": "value"}}'),
|
||||
'"a plain string"',
|
||||
);
|
||||
await user.type(screen.getByPlaceholderText('{"description": "Detects PII in requests"}'), "nope");
|
||||
await submit(user);
|
||||
|
||||
await vi.waitFor(() => expect(visibleErrors()).toStrictEqual(["Must be a JSON object", "Invalid JSON"]));
|
||||
expect(mutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -15,8 +15,10 @@ import {
|
|||
ServerIcon,
|
||||
AlertCircleIcon,
|
||||
InfoIcon,
|
||||
CircleHelp,
|
||||
} from "lucide-react";
|
||||
import { Modal, Form, Input, Select } from "antd";
|
||||
import { Modal } from "antd";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
listGuardrailSubmissions,
|
||||
approveGuardrailSubmission,
|
||||
|
|
@ -29,6 +31,67 @@ import TeamDropdown from "@/components/common_components/team_dropdown";
|
|||
import { useRegisterGuardrail } from "@/app/(dashboard)/hooks/guardrails/useRegisterGuardrail";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { isProxyAdminRole } from "@/utils/roles";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
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 { isAntdUrl } from "@/lib/forms/antdUrl";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
|
||||
const GUARDRAIL_MODES = [
|
||||
{ value: "pre_call", label: "Pre Call" },
|
||||
{ value: "post_call", label: "Post Call" },
|
||||
{ value: "during_call", label: "During Call" },
|
||||
] as const;
|
||||
|
||||
const submitGuardrailSchema = z.object({
|
||||
team_id: z.string().min(1, "Select a team"),
|
||||
guardrail_name: z.string().min(1, "Enter a guardrail name"),
|
||||
mode: z.string().min(1, "Select a mode"),
|
||||
api_base: z.string().min(1, "Enter the API base URL").refine(isAntdUrl, "Must be a valid URL"),
|
||||
extra_litellm_params: z.string().superRefine((value, ctx) => {
|
||||
if (!value) return;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
ctx.addIssue({ code: "custom", message: "Must be a JSON object" });
|
||||
}
|
||||
} catch {
|
||||
ctx.addIssue({ code: "custom", message: "Invalid JSON" });
|
||||
}
|
||||
}),
|
||||
guardrail_info: z.string().superRefine((value, ctx) => {
|
||||
if (!value) return;
|
||||
try {
|
||||
JSON.parse(value);
|
||||
} catch {
|
||||
ctx.addIssue({ code: "custom", message: "Invalid JSON" });
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
type SubmitGuardrailValues = z.output<typeof submitGuardrailSchema>;
|
||||
|
||||
const EMPTY_SUBMIT_VALUES: SubmitGuardrailValues = {
|
||||
team_id: "",
|
||||
guardrail_name: "",
|
||||
mode: "pre_call",
|
||||
api_base: "",
|
||||
extra_litellm_params: "",
|
||||
guardrail_info: "",
|
||||
};
|
||||
|
||||
const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
type GuardrailStatus = "active" | "pending" | "rejected";
|
||||
|
||||
|
|
@ -116,31 +179,31 @@ function submissionToTeamGuardrail(item: GuardrailSubmissionItem): TeamGuardrail
|
|||
const STATUS_CONFIG: Record<GuardrailStatus, { label: string; bg: string; text: string; dot: string }> = {
|
||||
active: {
|
||||
label: "Active",
|
||||
bg: "bg-green-50",
|
||||
text: "text-green-700",
|
||||
bg: "bg-green-50 dark:bg-green-950",
|
||||
text: "text-green-700 dark:text-green-300",
|
||||
dot: "bg-green-500",
|
||||
},
|
||||
pending: {
|
||||
label: "Pending Review",
|
||||
bg: "bg-yellow-50",
|
||||
text: "text-yellow-700",
|
||||
bg: "bg-yellow-50 dark:bg-yellow-950",
|
||||
text: "text-yellow-700 dark:text-yellow-300",
|
||||
dot: "bg-yellow-500",
|
||||
},
|
||||
rejected: {
|
||||
label: "Rejected",
|
||||
bg: "bg-red-50",
|
||||
text: "text-red-700",
|
||||
bg: "bg-red-50 dark:bg-red-950",
|
||||
text: "text-red-700 dark:text-red-300",
|
||||
dot: "bg-red-500",
|
||||
},
|
||||
};
|
||||
|
||||
const TEAM_COLORS: Record<string, string> = {
|
||||
"ML Platform": "bg-purple-100 text-purple-700",
|
||||
"Data Science": "bg-blue-100 text-blue-700",
|
||||
Security: "bg-red-100 text-red-700",
|
||||
"Customer Success": "bg-orange-100 text-orange-700",
|
||||
Legal: "bg-gray-100 text-gray-700",
|
||||
Finance: "bg-green-100 text-green-700",
|
||||
"ML Platform": "bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300",
|
||||
"Data Science": "bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300",
|
||||
Security: "bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300",
|
||||
"Customer Success": "bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-300",
|
||||
Legal: "bg-muted text-foreground",
|
||||
Finance: "bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300",
|
||||
};
|
||||
|
||||
function buildEquivalentConfigYaml(g: TeamGuardrail): string {
|
||||
|
|
@ -183,9 +246,9 @@ function buildEquivalentConfigYaml(g: TeamGuardrail): string {
|
|||
|
||||
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg px-4 py-3">
|
||||
<div className="bg-card border border-border rounded-lg px-4 py-3">
|
||||
<div className={`text-2xl font-bold ${color}`}>{value}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{label}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -207,7 +270,7 @@ function Toggle({
|
|||
aria-checked={enabled}
|
||||
disabled={disabled}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 ${
|
||||
enabled ? "bg-blue-500" : "bg-gray-200"
|
||||
enabled ? "bg-blue-500" : "bg-muted"
|
||||
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<span
|
||||
|
|
@ -243,11 +306,11 @@ function GuardrailCard({
|
|||
onReject,
|
||||
}: GuardrailCardProps) {
|
||||
const status = STATUS_CONFIG[g.status];
|
||||
const teamColor = TEAM_COLORS[g.team] ?? "bg-gray-100 text-gray-700";
|
||||
const teamColor = TEAM_COLORS[g.team] ?? "bg-muted text-foreground";
|
||||
return (
|
||||
<div
|
||||
className={`bg-white border rounded-lg p-4 transition-all ${
|
||||
isSelected ? "border-blue-400 ring-1 ring-blue-200" : "border-gray-200"
|
||||
className={`bg-card border rounded-lg p-4 transition-all ${
|
||||
isSelected ? "border-blue-400 ring-1 ring-blue-200 dark:ring-blue-800" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
|
|
@ -261,31 +324,31 @@ function GuardrailCard({
|
|||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">{g.name}</h3>
|
||||
<p className="text-xs text-gray-500 mb-2 line-clamp-1">{g.description}</p>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-1">{g.name}</h3>
|
||||
<p className="text-xs text-muted-foreground mb-2 line-clamp-1">{g.description}</p>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<ServerIcon className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
<code className="text-xs text-gray-500 font-mono truncate">{g.endpoint}</code>
|
||||
<ServerIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<code className="text-xs text-muted-foreground font-mono truncate">{g.endpoint}</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Model: <span className="font-medium text-gray-700">{g.model}</span>
|
||||
Model: <span className="font-medium text-foreground">{g.model}</span>
|
||||
</span>
|
||||
<span>
|
||||
Submitted: <span className="font-medium text-gray-700">{g.submittedAt}</span>
|
||||
Submitted: <span className="font-medium text-foreground">{g.submittedAt}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 whitespace-nowrap">Forward API Key</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">Forward API Key</span>
|
||||
<Toggle enabled={g.forwardKey} onToggle={onToggleForwardKey} disabled={!isAdmin} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className="text-xs border border-gray-300 text-gray-600 hover:bg-gray-50 px-3 py-1.5 rounded-md transition-colors font-medium"
|
||||
className="text-xs border border-border text-muted-foreground hover:bg-muted px-3 py-1.5 rounded-md transition-colors font-medium"
|
||||
>
|
||||
{isSelected ? "Close" : "Review"}
|
||||
</button>
|
||||
|
|
@ -301,7 +364,7 @@ function GuardrailCard({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onReject}
|
||||
className="text-xs border border-red-300 text-red-600 hover:bg-red-50 px-3 py-1.5 rounded-md transition-colors font-medium"
|
||||
className="text-xs border border-red-300 dark:border-red-800 text-red-600 hover:bg-red-50 dark:hover:bg-red-950 dark:bg-red-950 px-3 py-1.5 rounded-md transition-colors font-medium"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
|
|
@ -310,16 +373,16 @@ function GuardrailCard({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<div className="mt-3 pt-3 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleHeaders}
|
||||
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{isHeadersExpanded ? <ChevronUpIcon className="h-3.5 w-3.5" /> : <ChevronDownIcon className="h-3.5 w-3.5" />}
|
||||
Static headers
|
||||
{g.customHeaders.length > 0 && (
|
||||
<span className="ml-1 bg-gray-100 text-gray-600 rounded-full px-1.5 py-0.5 text-xs">
|
||||
<span className="ml-1 bg-muted text-muted-foreground rounded-full px-1.5 py-0.5 text-xs">
|
||||
{g.customHeaders.length}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -327,16 +390,16 @@ function GuardrailCard({
|
|||
{isHeadersExpanded && (
|
||||
<div className="mt-2">
|
||||
{g.customHeaders.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 italic">No static headers configured.</p>
|
||||
<p className="text-xs text-muted-foreground italic">No static headers configured.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{g.customHeaders.map((h, i) => (
|
||||
<div key={`${h.key}-${i}`} className="flex items-center gap-2 text-xs font-mono">
|
||||
<span className="text-gray-500 bg-gray-50 border border-gray-200 rounded-sm px-2 py-0.5">
|
||||
<span className="text-muted-foreground bg-muted border border-border rounded-sm px-2 py-0.5">
|
||||
{h.key}
|
||||
</span>
|
||||
<span className="text-gray-400">:</span>
|
||||
<span className="text-gray-700 bg-gray-50 border border-gray-200 rounded-sm px-2 py-0.5">
|
||||
<span className="text-muted-foreground">:</span>
|
||||
<span className="text-foreground bg-muted border border-border rounded-sm px-2 py-0.5">
|
||||
{h.value}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -353,7 +416,7 @@ function GuardrailCard({
|
|||
function ConfigRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-500 mb-1">{label}</div>
|
||||
<div className="text-xs font-semibold text-muted-foreground mb-1">{label}</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -385,9 +448,9 @@ function DetailPanel({
|
|||
const [newStaticHeaderKey, setNewStaticHeaderKey] = useState("");
|
||||
const [newStaticHeaderValue, setNewStaticHeaderValue] = useState("");
|
||||
const status = STATUS_CONFIG[g.status];
|
||||
const teamColor = TEAM_COLORS[g.team] ?? "bg-gray-100 text-gray-700";
|
||||
const teamColor = TEAM_COLORS[g.team] ?? "bg-muted text-foreground";
|
||||
return (
|
||||
<div className="w-96 shrink-0 bg-white overflow-auto">
|
||||
<div className="w-96 shrink-0 bg-card overflow-auto">
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
|
|
@ -400,82 +463,82 @@ function DetailPanel({
|
|||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-gray-900">{g.name}</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
<h2 className="text-base font-semibold text-foreground">{g.name}</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Submitted by {g.submittedBy} on {g.submittedAt}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Close detail panel"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-5">{g.description}</p>
|
||||
<p className="text-sm text-muted-foreground mb-5">{g.description}</p>
|
||||
<div className="space-y-4">
|
||||
<ConfigRow label="Endpoint">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<code className="text-xs font-mono text-gray-700 break-all">{g.endpoint}</code>
|
||||
<code className="text-xs font-mono text-foreground break-all">{g.endpoint}</code>
|
||||
<a
|
||||
href={g.endpoint}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-400 hover:text-blue-500 shrink-0"
|
||||
className="text-muted-foreground hover:text-blue-500 shrink-0"
|
||||
>
|
||||
<ExternalLinkIcon className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</ConfigRow>
|
||||
<ConfigRow label="Method">
|
||||
<span className="text-xs font-mono font-medium text-gray-700 bg-gray-100 px-2 py-0.5 rounded-sm">
|
||||
<span className="text-xs font-mono font-medium text-foreground bg-muted px-2 py-0.5 rounded-sm">
|
||||
{g.method}
|
||||
</span>
|
||||
</ConfigRow>
|
||||
<div className="border border-blue-100 bg-blue-50 rounded-lg p-3">
|
||||
<div className="border border-blue-100 dark:border-blue-900 bg-blue-50 dark:bg-blue-950 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<KeyIcon className="h-3.5 w-3.5 text-blue-500" />
|
||||
<span className="text-xs font-semibold text-blue-800">Forward LiteLLM API Key</span>
|
||||
<span className="text-xs font-semibold text-blue-800 dark:text-blue-200">Forward LiteLLM API Key</span>
|
||||
</div>
|
||||
<Toggle enabled={g.forwardKey} onToggle={onToggleForwardKey} disabled={!isAdmin} />
|
||||
</div>
|
||||
<p className="text-xs text-blue-700 leading-relaxed">
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300 leading-relaxed">
|
||||
When enabled, the caller's LiteLLM API key is forwarded as an{" "}
|
||||
<code className="font-mono bg-blue-100 px-1 rounded-sm">Authorization</code> header to your guardrail
|
||||
endpoint. This allows your guardrail to authenticate model calls using the original caller's
|
||||
credentials.
|
||||
<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded-sm">Authorization</code> header to
|
||||
your guardrail endpoint. This allows your guardrail to authenticate model calls using the original
|
||||
caller's credentials.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span className="text-xs font-semibold text-gray-700">Static headers</span>
|
||||
<span className="text-xs font-semibold text-foreground">Static headers</span>
|
||||
{g.customHeaders.length > 0 && (
|
||||
<span className="bg-gray-100 text-gray-600 rounded-full px-1.5 py-0.5 text-xs">
|
||||
<span className="bg-muted text-muted-foreground rounded-full px-1.5 py-0.5 text-xs">
|
||||
{g.customHeaders.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-2">Sent with every request to the guardrail.</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">Sent with every request to the guardrail.</p>
|
||||
{g.customHeaders.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 italic mb-2">No static headers configured.</p>
|
||||
<p className="text-xs text-muted-foreground italic mb-2">No static headers configured.</p>
|
||||
) : (
|
||||
<ul className="list-none space-y-1 mb-2">
|
||||
{g.customHeaders.map((h, i) => (
|
||||
<li
|
||||
key={`${h.key}-${i}`}
|
||||
className="flex items-center justify-between gap-2 text-xs font-mono bg-gray-50 border border-gray-200 rounded-sm px-2 py-1.5"
|
||||
className="flex items-center justify-between gap-2 text-xs font-mono bg-muted border border-border rounded-sm px-2 py-1.5"
|
||||
>
|
||||
<span className="text-gray-700 truncate">
|
||||
<span className="text-foreground truncate">
|
||||
{h.key}: {h.value}
|
||||
</span>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateCustomHeaders(g.customHeaders.filter((_, idx) => idx !== i))}
|
||||
className="text-gray-400 hover:text-red-600 shrink-0"
|
||||
className="text-muted-foreground hover:text-red-600 shrink-0"
|
||||
aria-label={`Remove ${h.key}`}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
|
|
@ -492,7 +555,7 @@ function DetailPanel({
|
|||
value={newStaticHeaderKey}
|
||||
onChange={(e) => setNewStaticHeaderKey(e.target.value)}
|
||||
placeholder="Header name (e.g. X-API-Key)"
|
||||
className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
className="flex-1 min-w-0 text-xs font-mono border border-border rounded-sm px-2 py-1.5 text-foreground placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
|
|
@ -511,7 +574,7 @@ function DetailPanel({
|
|||
value={newStaticHeaderValue}
|
||||
onChange={(e) => setNewStaticHeaderValue(e.target.value)}
|
||||
placeholder="Value"
|
||||
className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
className="flex-1 min-w-0 text-xs font-mono border border-border rounded-sm px-2 py-1.5 text-foreground placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
|
|
@ -536,7 +599,7 @@ function DetailPanel({
|
|||
setNewStaticHeaderValue("");
|
||||
}
|
||||
}}
|
||||
className="text-xs font-medium text-blue-600 hover:text-blue-700 border border-blue-200 bg-blue-50 hover:bg-blue-100 px-2 py-1.5 rounded-sm transition-colors shrink-0"
|
||||
className="text-xs font-medium text-blue-600 hover:text-blue-700 dark:hover:text-blue-300 dark:text-blue-300 border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950 hover:bg-blue-100 dark:hover:bg-blue-900 dark:bg-blue-900 px-2 py-1.5 rounded-sm transition-colors shrink-0"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
|
|
@ -545,31 +608,31 @@ function DetailPanel({
|
|||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span className="text-xs font-semibold text-gray-700">Forward client headers</span>
|
||||
<span className="text-xs font-semibold text-foreground">Forward client headers</span>
|
||||
{g.extraHeaders.length > 0 && (
|
||||
<span className="bg-gray-100 text-gray-600 rounded-full px-1.5 py-0.5 text-xs">
|
||||
<span className="bg-muted text-muted-foreground rounded-full px-1.5 py-0.5 text-xs">
|
||||
{g.extraHeaders.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Allowed header names to forward from the client request to the guardrail (e.g. x-request-id).
|
||||
</p>
|
||||
{g.extraHeaders.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 italic mb-2">No forward client headers configured.</p>
|
||||
<p className="text-xs text-muted-foreground italic mb-2">No forward client headers configured.</p>
|
||||
) : (
|
||||
<ul className="list-none space-y-1 mb-2">
|
||||
{g.extraHeaders.map((name, i) => (
|
||||
<li
|
||||
key={`${name}-${i}`}
|
||||
className="flex items-center justify-between gap-2 text-xs font-mono bg-gray-50 border border-gray-200 rounded-sm px-2 py-1.5"
|
||||
className="flex items-center justify-between gap-2 text-xs font-mono bg-muted border border-border rounded-sm px-2 py-1.5"
|
||||
>
|
||||
<span className="text-gray-700 truncate">{name}</span>
|
||||
<span className="text-foreground truncate">{name}</span>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateExtraHeaders(g.extraHeaders.filter((_, idx) => idx !== i))}
|
||||
className="text-gray-400 hover:text-red-600 shrink-0"
|
||||
className="text-muted-foreground hover:text-red-600 shrink-0"
|
||||
aria-label={`Remove ${name}`}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
|
|
@ -586,7 +649,7 @@ function DetailPanel({
|
|||
value={newExtraHeader}
|
||||
onChange={(e) => setNewExtraHeader(e.target.value)}
|
||||
placeholder="e.g. x-request-id"
|
||||
className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
className="flex-1 min-w-0 text-xs font-mono border border-border rounded-sm px-2 py-1.5 text-foreground placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
|
|
@ -607,35 +670,35 @@ function DetailPanel({
|
|||
setNewExtraHeader("");
|
||||
}
|
||||
}}
|
||||
className="text-xs font-medium text-blue-600 hover:text-blue-700 border border-blue-200 bg-blue-50 hover:bg-blue-100 px-2 py-1.5 rounded-sm transition-colors"
|
||||
className="text-xs font-medium text-blue-600 hover:text-blue-700 dark:hover:text-blue-300 dark:text-blue-300 border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950 hover:bg-blue-100 dark:hover:bg-blue-900 dark:bg-blue-900 px-2 py-1.5 rounded-sm transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfigExpanded(!configExpanded)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-left text-xs font-semibold text-gray-700 bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-left text-xs font-semibold text-foreground bg-muted hover:bg-muted transition-colors"
|
||||
>
|
||||
<span>Equivalent config</span>
|
||||
{configExpanded ? (
|
||||
<ChevronUpIcon className="h-3.5 w-3.5 text-gray-500" />
|
||||
<ChevronUpIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-3.5 w-3.5 text-gray-500" />
|
||||
<ChevronDownIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{configExpanded && (
|
||||
<pre className="p-3 text-xs font-mono text-gray-700 bg-white border-t border-gray-200 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
<pre className="p-3 text-xs font-mono text-foreground bg-card border-t border-border overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{buildEquivalentConfigYaml(g)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 bg-gray-50 border border-gray-200 rounded-lg p-3">
|
||||
<InfoIcon className="h-3.5 w-3.5 text-gray-400 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-gray-500 leading-relaxed">
|
||||
<div className="flex items-start gap-2 bg-muted border border-border rounded-lg p-3">
|
||||
<InfoIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
This guardrail runs on a separate instance. It receives the user request and forwards the result to the
|
||||
next step in the pipeline. See{" "}
|
||||
<a
|
||||
|
|
@ -650,10 +713,10 @@ function DetailPanel({
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 pt-4 border-t border-gray-100 space-y-2">
|
||||
<div className="mt-5 pt-4 border-t border-border space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-center gap-2 border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium py-2 rounded-md transition-colors"
|
||||
className="w-full flex items-center justify-center gap-2 border border-border text-foreground hover:bg-muted text-sm font-medium py-2 rounded-md transition-colors"
|
||||
>
|
||||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
Test Endpoint
|
||||
|
|
@ -671,7 +734,7 @@ function DetailPanel({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onReject}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 border border-red-300 text-red-600 hover:bg-red-50 text-sm font-medium py-2 rounded-md transition-colors"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 border border-red-300 dark:border-red-800 text-red-600 hover:bg-red-50 dark:hover:bg-red-950 dark:bg-red-950 text-sm font-medium py-2 rounded-md transition-colors"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
Reject
|
||||
|
|
@ -695,10 +758,10 @@ function ConfirmDialog({ action, guardrailName, onConfirm, onCancel }: ConfirmDi
|
|||
const isApprove = action === "approve";
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl p-6 max-w-sm w-full mx-4">
|
||||
<div className="bg-card rounded-xl shadow-xl p-6 max-w-sm w-full mx-4">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center mb-4 ${
|
||||
isApprove ? "bg-green-100" : "bg-red-100"
|
||||
isApprove ? "bg-green-100 dark:bg-green-900" : "bg-red-100 dark:bg-red-900"
|
||||
}`}
|
||||
>
|
||||
{isApprove ? (
|
||||
|
|
@ -707,12 +770,12 @@ function ConfirmDialog({ action, guardrailName, onConfirm, onCancel }: ConfirmDi
|
|||
<AlertCircleIcon className="h-5 w-5 text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-1">
|
||||
<h3 className="text-base font-semibold text-foreground mb-1">
|
||||
{isApprove ? "Approve Guardrail" : "Reject Guardrail"}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mb-5">
|
||||
<p className="text-sm text-muted-foreground mb-5">
|
||||
Are you sure you want to {action}{" "}
|
||||
<span className="font-medium text-gray-700">"{guardrailName}"</span>?{" "}
|
||||
<span className="font-medium text-foreground">"{guardrailName}"</span>?{" "}
|
||||
{isApprove
|
||||
? "This will make it active and available for use."
|
||||
: "This will mark it as rejected and notify the team."}
|
||||
|
|
@ -721,7 +784,7 @@ function ConfirmDialog({ action, guardrailName, onConfirm, onCancel }: ConfirmDi
|
|||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium py-2 rounded-md transition-colors"
|
||||
className="flex-1 border border-border text-foreground hover:bg-muted text-sm font-medium py-2 rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
|
@ -766,7 +829,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
|
|||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false);
|
||||
const [submitForm] = Form.useForm();
|
||||
const submitForm = useZodForm(submitGuardrailSchema, { defaultValues: EMPTY_SUBMIT_VALUES });
|
||||
const registerGuardrail = useRegisterGuardrail();
|
||||
|
||||
const fetchSubmissions = useCallback(async () => {
|
||||
|
|
@ -797,6 +860,29 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
|
|||
fetchSubmissions();
|
||||
}, [fetchSubmissions]);
|
||||
|
||||
const handleSubmitGuardrail = submitForm.handleSubmit(async (values) => {
|
||||
const litellm_params: Record<string, unknown> = {
|
||||
...(values.extra_litellm_params ? JSON.parse(values.extra_litellm_params) : {}),
|
||||
guardrail: "generic_guardrail_api",
|
||||
mode: values.mode,
|
||||
api_base: values.api_base,
|
||||
};
|
||||
try {
|
||||
await registerGuardrail.mutateAsync({
|
||||
team_id: values.team_id,
|
||||
guardrail_name: values.guardrail_name,
|
||||
litellm_params,
|
||||
guardrail_info: values.guardrail_info ? JSON.parse(values.guardrail_info) : undefined,
|
||||
});
|
||||
toast.success("Guardrail submitted for review");
|
||||
setIsSubmitModalOpen(false);
|
||||
submitForm.reset();
|
||||
fetchSubmissions();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const filtered = guardrails;
|
||||
const selected = guardrails.find((g) => g.id === selectedId) ?? null;
|
||||
const totalCount = summary.total;
|
||||
|
|
@ -896,28 +982,28 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
|
|||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className={`flex-1 min-w-0 p-6 overflow-auto ${selected ? "border-r border-gray-200" : ""}`}>
|
||||
<div className={`flex-1 min-w-0 p-6 overflow-auto ${selected ? "border-r border-border" : ""}`}>
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<StatCard label="Total Submitted" value={totalCount} color="text-gray-900" />
|
||||
<StatCard label="Total Submitted" value={totalCount} color="text-foreground" />
|
||||
<StatCard label="Pending Review" value={pendingCount} color="text-yellow-600" />
|
||||
<StatCard label="Active" value={activeCount} color="text-green-600" />
|
||||
<StatCard label="Rejected" value={rejectedCount} color="text-red-600" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search guardrails..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 border border-gray-200 rounded-md text-sm text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
className="w-full pl-9 pr-4 py-2 border border-border rounded-md text-sm text-foreground placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
|
||||
className="border border-gray-200 rounded-md px-3 py-2 text-sm text-gray-700 focus:outline-hidden focus:ring-1 focus:ring-blue-500 focus:border-blue-500 bg-white"
|
||||
className="border border-border rounded-md px-3 py-2 text-sm text-foreground focus:outline-hidden focus:ring-1 focus:ring-blue-500 focus:border-blue-500 bg-background"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="pending">Pending Review</option>
|
||||
|
|
@ -934,10 +1020,10 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
|
|||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{isLoading && <div className="text-center py-12 text-gray-500 text-sm">Loading submissions…</div>}
|
||||
{isLoading && <div className="text-center py-12 text-muted-foreground text-sm">Loading submissions…</div>}
|
||||
{error && <div className="text-center py-12 text-red-600 text-sm">{error}</div>}
|
||||
{!isLoading && !error && filtered.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">No guardrails match your filters.</div>
|
||||
<div className="text-center py-12 text-muted-foreground text-sm">No guardrails match your filters.</div>
|
||||
)}
|
||||
{!isLoading &&
|
||||
!error &&
|
||||
|
|
@ -985,119 +1071,86 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
|
|||
open={isSubmitModalOpen}
|
||||
onCancel={() => {
|
||||
setIsSubmitModalOpen(false);
|
||||
submitForm.resetFields();
|
||||
submitForm.reset();
|
||||
}}
|
||||
onOk={() => submitForm.submit()}
|
||||
onOk={handleSubmitGuardrail}
|
||||
okText="Submit for Review"
|
||||
>
|
||||
<div className="rounded-md bg-blue-50 border border-blue-200 px-4 py-3 text-sm text-blue-800 mb-4">
|
||||
<div className="rounded-md bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-900 px-4 py-3 text-sm text-blue-800 dark:text-blue-200 mb-4">
|
||||
Your guardrail will be sent for admin review before it becomes active.
|
||||
</div>
|
||||
<Form
|
||||
form={submitForm}
|
||||
layout="vertical"
|
||||
initialValues={{ mode: "pre_call" }}
|
||||
onFinish={async (values) => {
|
||||
const litellm_params: Record<string, unknown> = {
|
||||
...(values.extra_litellm_params ? JSON.parse(values.extra_litellm_params) : {}),
|
||||
guardrail: "generic_guardrail_api",
|
||||
mode: values.mode,
|
||||
api_base: values.api_base,
|
||||
};
|
||||
try {
|
||||
await registerGuardrail.mutateAsync({
|
||||
team_id: values.team_id,
|
||||
guardrail_name: values.guardrail_name,
|
||||
litellm_params,
|
||||
guardrail_info: values.guardrail_info ? JSON.parse(values.guardrail_info) : undefined,
|
||||
});
|
||||
toast.success("Guardrail submitted for review");
|
||||
setIsSubmitModalOpen(false);
|
||||
submitForm.resetFields();
|
||||
fetchSubmissions();
|
||||
} catch {
|
||||
// error already handled by networking layer
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item label="Team" name="team_id" rules={[{ required: true, message: "Select a team" }]}>
|
||||
<TeamDropdown />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Guardrail Name"
|
||||
name="guardrail_name"
|
||||
rules={[{ required: true, message: "Enter a guardrail name" }]}
|
||||
>
|
||||
<Input placeholder="e.g. pii-detection" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Mode" name="mode" rules={[{ required: true, message: "Select a mode" }]}>
|
||||
<Select>
|
||||
<Select.Option value="pre_call">Pre Call</Select.Option>
|
||||
<Select.Option value="post_call">Post Call</Select.Option>
|
||||
<Select.Option value="during_call">During Call</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="API Base URL"
|
||||
name="api_base"
|
||||
rules={[
|
||||
{ required: true, message: "Enter the API base URL" },
|
||||
{ type: "url", message: "Must be a valid URL" },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="https://your-guardrail-api.com/v1/check" className="font-mono" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Additional litellm_params (optional)"
|
||||
name="extra_litellm_params"
|
||||
tooltip="JSON object merged into litellm_params. e.g. forward_api_key, headers, model, unreachable_fallback"
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve();
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return Promise.reject("Must be a JSON object");
|
||||
}
|
||||
return Promise.resolve();
|
||||
} catch {
|
||||
return Promise.reject("Invalid JSON");
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
className="font-mono text-xs"
|
||||
placeholder='{"forward_api_key": true, "headers": {"X-Custom": "value"}}'
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Guardrail Info (optional)"
|
||||
name="guardrail_info"
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve();
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return Promise.resolve();
|
||||
} catch {
|
||||
return Promise.reject("Invalid JSON");
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
className="font-mono text-xs"
|
||||
placeholder='{"description": "Detects PII in requests"}'
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<TooltipProvider>
|
||||
<form onSubmit={handleSubmitGuardrail}>
|
||||
<FieldGroup>
|
||||
<FormField control={submitForm.control} name="team_id" label="Team">
|
||||
{({ id, value, onChange }) => <TeamDropdown id={id} value={value} onChange={onChange} />}
|
||||
</FormField>
|
||||
<FormField control={submitForm.control} name="guardrail_name" label="Guardrail Name">
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g. pii-detection" />}
|
||||
</FormField>
|
||||
<FormField control={submitForm.control} name="mode" label="Mode">
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{GUARDRAIL_MODES.map((mode) => (
|
||||
<SelectItem key={mode.value} value={mode.value} title={mode.label}>
|
||||
{mode.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField control={submitForm.control} name="api_base" label="API Base URL">
|
||||
{({ ref, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder="https://your-guardrail-api.com/v1/check"
|
||||
className="font-mono"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField
|
||||
control={submitForm.control}
|
||||
name="extra_litellm_params"
|
||||
label={labelWithHint(
|
||||
"Additional litellm_params (optional)",
|
||||
"JSON object merged into litellm_params. e.g. forward_api_key, headers, model, unreachable_fallback",
|
||||
)}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
ref={ref}
|
||||
rows={3}
|
||||
className="font-mono text-xs"
|
||||
placeholder='{"forward_api_key": true, "headers": {"X-Custom": "value"}}'
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField control={submitForm.control} name="guardrail_info" label="Guardrail Info (optional)">
|
||||
{({ ref, ...field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
ref={ref}
|
||||
rows={3}
|
||||
className="font-mono text-xs"
|
||||
placeholder='{"description": "Detects PII in requests"}'
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
import { useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getMajorAirlines } from "@/components/networking";
|
||||
|
||||
import CompetitorIntentConfiguration, { type CompetitorIntentConfig } from "./CompetitorIntentConfiguration";
|
||||
|
||||
vi.mock("@/components/networking", () => ({ getMajorAirlines: vi.fn() }));
|
||||
|
||||
const mockAirlines = vi.mocked(getMajorAirlines);
|
||||
const onChange = vi.fn();
|
||||
|
||||
const DEFAULT_CONFIG: CompetitorIntentConfig = {
|
||||
competitor_intent_type: "airline",
|
||||
brand_self: [],
|
||||
locations: [],
|
||||
policy: {
|
||||
competitor_comparison: "refuse",
|
||||
possible_competitor_comparison: "reframe",
|
||||
},
|
||||
threshold_high: 0.7,
|
||||
threshold_medium: 0.45,
|
||||
threshold_low: 0.3,
|
||||
};
|
||||
|
||||
const Harness = ({ initialEnabled = true }: { initialEnabled?: boolean }) => {
|
||||
const [enabled, setEnabled] = useState(initialEnabled);
|
||||
const [config, setConfig] = useState<CompetitorIntentConfig | null>(initialEnabled ? DEFAULT_CONFIG : null);
|
||||
const handleChange = (nextEnabled: boolean, nextConfig: CompetitorIntentConfig | null) => {
|
||||
onChange(nextEnabled, nextConfig);
|
||||
setEnabled(nextEnabled);
|
||||
setConfig(nextConfig);
|
||||
};
|
||||
return (
|
||||
<CompetitorIntentConfiguration enabled={enabled} config={config} accessToken="sk-test" onChange={handleChange} />
|
||||
);
|
||||
};
|
||||
|
||||
const lastConfig = (): CompetitorIntentConfig => onChange.mock.calls[onChange.mock.calls.length - 1][1];
|
||||
|
||||
const chooseOption = async (user: ReturnType<typeof userEvent.setup>, index: number, optionText: string) => {
|
||||
await user.click(screen.getAllByRole("combobox")[index]);
|
||||
const options = await screen.findAllByText(optionText);
|
||||
await user.click(options[options.length - 1]);
|
||||
};
|
||||
|
||||
describe("CompetitorIntentConfiguration reported config", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAirlines.mockResolvedValue({ airlines: [] });
|
||||
});
|
||||
|
||||
it("reports the seeded config when switched on and null when switched off", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness initialEnabled={false} />);
|
||||
|
||||
await user.click(screen.getByRole("switch"));
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, true, DEFAULT_CONFIG);
|
||||
|
||||
await user.click(screen.getByRole("switch"));
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, false, null);
|
||||
});
|
||||
|
||||
it("keeps every other key when the intent type changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
await chooseOption(user, 0, "Generic (specify competitors manually)");
|
||||
|
||||
expect(lastConfig()).toStrictEqual({ ...DEFAULT_CONFIG, competitor_intent_type: "generic" });
|
||||
expect(screen.getByText("Competitors")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Locations (optional)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports a policy change without dropping the other policy key", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
await chooseOption(user, 3, "Reframe (suggest alternative)");
|
||||
|
||||
expect(lastConfig()).toStrictEqual({
|
||||
...DEFAULT_CONFIG,
|
||||
policy: { competitor_comparison: "reframe", possible_competitor_comparison: "reframe" },
|
||||
});
|
||||
});
|
||||
|
||||
it("commits comma separated brand terms as separate tags", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
const brandSelf = screen.getAllByRole("combobox")[1];
|
||||
await user.click(brandSelf);
|
||||
await user.type(brandSelf, "acme,globex,");
|
||||
|
||||
expect(lastConfig().brand_self).toStrictEqual(["acme", "globex"]);
|
||||
});
|
||||
|
||||
it("commits the pending brand term when the field loses focus", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
const brandSelf = screen.getAllByRole("combobox")[1];
|
||||
await user.click(brandSelf);
|
||||
await user.type(brandSelf, "acme");
|
||||
await user.tab();
|
||||
|
||||
expect(lastConfig().brand_self).toStrictEqual(["acme"]);
|
||||
});
|
||||
|
||||
it("expands a picked airline into all of its match variants, lowercased", async () => {
|
||||
mockAirlines.mockResolvedValue({ airlines: [{ id: "qr", match: "Qatar Airways|qatar|qr", tags: [] }] });
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
await user.click(screen.getAllByRole("combobox")[1]);
|
||||
const options = await screen.findAllByText(/Qatar Airways/);
|
||||
await user.click(options[options.length - 1]);
|
||||
|
||||
expect(lastConfig().brand_self).toStrictEqual(["qatar airways", "qatar", "qr"]);
|
||||
});
|
||||
|
||||
it("reports locations only while the airline type is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
const locations = screen.getAllByRole("combobox")[2];
|
||||
await user.click(locations);
|
||||
await user.type(locations, "doha,");
|
||||
|
||||
expect(lastConfig()).toStrictEqual({ ...DEFAULT_CONFIG, locations: ["doha"] });
|
||||
});
|
||||
|
||||
it("reports a typed decimal threshold and leaves the other two alone", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
const thresholds = screen.getAllByRole("spinbutton");
|
||||
await user.clear(thresholds[0]);
|
||||
await user.type(thresholds[0], "0.55");
|
||||
|
||||
expect(lastConfig()).toStrictEqual({ ...DEFAULT_CONFIG, threshold_high: 0.55 });
|
||||
});
|
||||
|
||||
it("falls back to the default threshold when the field is cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
await user.clear(screen.getAllByRole("spinbutton")[1]);
|
||||
|
||||
expect(lastConfig()).toStrictEqual(DEFAULT_CONFIG);
|
||||
});
|
||||
|
||||
it("clamps a threshold above the maximum back to 1 when the field is left", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
const thresholds = screen.getAllByRole("spinbutton");
|
||||
await user.clear(thresholds[2]);
|
||||
await user.type(thresholds[2], "5");
|
||||
await user.tab();
|
||||
|
||||
expect(lastConfig()).toStrictEqual({ ...DEFAULT_CONFIG, threshold_low: 1 });
|
||||
});
|
||||
|
||||
it("explains the filter without rendering any control while switched off", () => {
|
||||
render(<Harness initialEnabled={false} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Block or reframe competitor comparison questions. When enabled, airline type auto-loads competitors from IATA; generic type requires manual competitor list.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryAllByRole("combobox")).toHaveLength(0);
|
||||
expect(screen.queryAllByRole("spinbutton")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Card, Typography, Select, Switch, Form, Space, InputNumber } from "antd";
|
||||
import { getMajorAirlines } from "@/components/networking";
|
||||
import React, { useEffect, useId, useState } from "react";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
import { getMajorAirlines } from "@/components/networking";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/shared/form/field";
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
import { TagsInput } from "./TagsInput";
|
||||
import { ThresholdInput } from "./ThresholdInput";
|
||||
|
||||
export interface MajorAirline {
|
||||
id: string;
|
||||
|
|
@ -45,6 +49,27 @@ const DEFAULT_CONFIG: CompetitorIntentConfig = {
|
|||
threshold_low: 0.3,
|
||||
};
|
||||
|
||||
const INTENT_TYPES = [
|
||||
{ value: "airline", label: "Airline (auto-load competitors from IATA)" },
|
||||
{ value: "generic", label: "Generic (specify competitors manually)" },
|
||||
] as const;
|
||||
|
||||
const COMPETITOR_COMPARISON_POLICIES = [
|
||||
{ value: "refuse", label: "Refuse (block request)" },
|
||||
{ value: "reframe", label: "Reframe (suggest alternative)" },
|
||||
] as const;
|
||||
|
||||
const POSSIBLE_COMPETITOR_COMPARISON_POLICIES = [
|
||||
{ value: "refuse", label: "Refuse (block request)" },
|
||||
{ value: "reframe", label: "Reframe (suggest alternative to backend LLM)" },
|
||||
] as const;
|
||||
|
||||
const THRESHOLDS = [
|
||||
{ field: "threshold_high", label: "High", hint: "e.g. 0.7", fallback: 0.7 },
|
||||
{ field: "threshold_medium", label: "Medium", hint: "e.g. 0.45", fallback: 0.45 },
|
||||
{ field: "threshold_low", label: "Low", hint: "e.g. 0.3", fallback: 0.3 },
|
||||
] as const;
|
||||
|
||||
const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps> = ({
|
||||
enabled,
|
||||
config,
|
||||
|
|
@ -54,6 +79,7 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
|
|||
const effectiveConfig = config ?? DEFAULT_CONFIG;
|
||||
const [airlineOptions, setAirlineOptions] = useState<MajorAirline[]>([]);
|
||||
const [loadingAirlines, setLoadingAirlines] = useState(false);
|
||||
const fieldId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveConfig.competitor_intent_type === "airline" && accessToken && airlineOptions.length === 0) {
|
||||
|
|
@ -111,212 +137,206 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
|
|||
onChange(enabled, { ...effectiveConfig, brand_self: expanded });
|
||||
};
|
||||
|
||||
const header = (
|
||||
<CardHeader className="gap-0">
|
||||
<CardTitle className="text-base">Competitor Intent Filter</CardTitle>
|
||||
<CardAction>
|
||||
<Switch checked={enabled} onCheckedChange={handleEnabledChange} />
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
);
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Competitor Intent Filter
|
||||
</Title>
|
||||
<Switch checked={false} onChange={handleEnabledChange} />
|
||||
</div>
|
||||
}
|
||||
size="small"
|
||||
>
|
||||
<Text type="secondary">
|
||||
Block or reframe competitor comparison questions. When enabled, airline type auto-loads competitors from IATA;
|
||||
generic type requires manual competitor list.
|
||||
</Text>
|
||||
<Card>
|
||||
{header}
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Block or reframe competitor comparison questions. When enabled, airline type auto-loads competitors from
|
||||
IATA; generic type requires manual competitor list.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Competitor Intent Filter
|
||||
</Title>
|
||||
<Switch checked={enabled} onChange={handleEnabledChange} />
|
||||
</div>
|
||||
}
|
||||
size="small"
|
||||
>
|
||||
<Text type="secondary" style={{ display: "block", marginBottom: 16 }}>
|
||||
Block or reframe competitor comparison questions. Airline type uses major airlines (excluding your brand);
|
||||
generic requires manual competitor list.
|
||||
</Text>
|
||||
<Form layout="vertical" size="small">
|
||||
<Form.Item label="Type">
|
||||
<Select
|
||||
value={effectiveConfig.competitor_intent_type}
|
||||
onChange={(v) => handleConfigChange("competitor_intent_type", v)}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Option value="airline">Airline (auto-load competitors from IATA)</Option>
|
||||
<Option value="generic">Generic (specify competitors manually)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
const airlineTags =
|
||||
effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0
|
||||
? airlineOptions.map((a) => {
|
||||
const primary = a.match.split("|")[0]?.trim() ?? a.id;
|
||||
const variants = a.match
|
||||
.split("|")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
return {
|
||||
value: primary.toLowerCase(),
|
||||
label: `${primary}${variants.length > 1 ? ` (${variants.slice(1).join(", ")})` : ""}`,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
<Form.Item
|
||||
label="Your Brand (brand_self)"
|
||||
required
|
||||
help={
|
||||
effectiveConfig.competitor_intent_type === "airline"
|
||||
? "Select your airline from the list (excluded from competitors) or type to add a custom term"
|
||||
: "Names/codes users use for your brand"
|
||||
}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{ width: "100%" }}
|
||||
placeholder={
|
||||
loadingAirlines
|
||||
? "Loading airlines..."
|
||||
: effectiveConfig.competitor_intent_type === "airline"
|
||||
return (
|
||||
<Card>
|
||||
{header}
|
||||
<CardContent>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Block or reframe competitor comparison questions. Airline type uses major airlines (excluding your brand);
|
||||
generic requires manual competitor list.
|
||||
</p>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${fieldId}-type`}>Type</FieldLabel>
|
||||
<Select
|
||||
value={effectiveConfig.competitor_intent_type}
|
||||
onValueChange={(v: string | null) => v !== null && handleConfigChange("competitor_intent_type", v)}
|
||||
>
|
||||
<SelectTrigger id={`${fieldId}-type`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{INTENT_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value} title={type.label}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${fieldId}-brand-self`}>Your Brand (brand_self)</FieldLabel>
|
||||
<TagsInput
|
||||
id={`${fieldId}-brand-self`}
|
||||
value={effectiveConfig.brand_self}
|
||||
onValueChange={(v) =>
|
||||
effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0
|
||||
? handleBrandSelfChange(v)
|
||||
: handleNestedArrayChange("brand_self", v)
|
||||
}
|
||||
options={airlineTags}
|
||||
tokenSeparators={[","]}
|
||||
loading={loadingAirlines}
|
||||
placeholder={
|
||||
effectiveConfig.competitor_intent_type === "airline"
|
||||
? "Search or select airline, or type to add custom"
|
||||
: "Type and press Enter to add"
|
||||
}
|
||||
value={effectiveConfig.brand_self}
|
||||
onChange={(v) =>
|
||||
effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0
|
||||
? handleBrandSelfChange(v ?? [])
|
||||
: handleNestedArrayChange("brand_self", v ?? [])
|
||||
}
|
||||
tokenSeparators={[","]}
|
||||
loading={loadingAirlines}
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label?.toString().toLowerCase() ?? "").includes(input.toLowerCase())
|
||||
}
|
||||
optionFilterProp="label"
|
||||
options={
|
||||
effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0
|
||||
? airlineOptions.map((a) => {
|
||||
const primary = a.match.split("|")[0]?.trim() ?? a.id;
|
||||
const variants = a.match
|
||||
.split("|")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
return {
|
||||
value: primary.toLowerCase(),
|
||||
label: `${primary}${variants.length > 1 ? ` (${variants.slice(1).join(", ")})` : ""}`,
|
||||
};
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{effectiveConfig.competitor_intent_type === "airline" && (
|
||||
<Form.Item
|
||||
label="Locations (optional)"
|
||||
help="Countries, cities, airports for disambiguation (e.g. qatar, doha)"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Type and press Enter to add"
|
||||
value={effectiveConfig.locations ?? []}
|
||||
onChange={(v) => handleNestedArrayChange("locations", v ?? [])}
|
||||
tokenSeparators={[","]}
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<FieldDescription>
|
||||
{effectiveConfig.competitor_intent_type === "airline"
|
||||
? "Select your airline from the list (excluded from competitors) or type to add a custom term"
|
||||
: "Names/codes users use for your brand"}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
{effectiveConfig.competitor_intent_type === "generic" && (
|
||||
<Form.Item label="Competitors" required help="Competitor names to detect (required for generic type)">
|
||||
{effectiveConfig.competitor_intent_type === "airline" && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${fieldId}-locations`}>Locations (optional)</FieldLabel>
|
||||
<TagsInput
|
||||
id={`${fieldId}-locations`}
|
||||
value={effectiveConfig.locations ?? []}
|
||||
onValueChange={(v) => handleNestedArrayChange("locations", v)}
|
||||
tokenSeparators={[","]}
|
||||
placeholder="Type and press Enter to add"
|
||||
/>
|
||||
<FieldDescription>Countries, cities, airports for disambiguation (e.g. qatar, doha)</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{effectiveConfig.competitor_intent_type === "generic" && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${fieldId}-competitors`}>Competitors</FieldLabel>
|
||||
<TagsInput
|
||||
id={`${fieldId}-competitors`}
|
||||
value={effectiveConfig.competitors ?? []}
|
||||
onValueChange={(v) => handleNestedArrayChange("competitors", v)}
|
||||
tokenSeparators={[","]}
|
||||
placeholder="Type and press Enter to add"
|
||||
/>
|
||||
<FieldDescription>Competitor names to detect (required for generic type)</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${fieldId}-competitor-comparison`}>Policy: Competitor comparison</FieldLabel>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Type and press Enter to add"
|
||||
value={effectiveConfig.competitors ?? []}
|
||||
onChange={(v) => handleNestedArrayChange("competitors", v ?? [])}
|
||||
tokenSeparators={[","]}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
value={effectiveConfig.policy?.competitor_comparison ?? "refuse"}
|
||||
onValueChange={(v: string | null) => v !== null && handlePolicyChange("competitor_comparison", v)}
|
||||
>
|
||||
<SelectTrigger id={`${fieldId}-competitor-comparison`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{COMPETITOR_COMPARISON_POLICIES.map((policy) => (
|
||||
<SelectItem key={policy.value} value={policy.value} title={policy.label}>
|
||||
{policy.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Form.Item label="Policy: Competitor comparison">
|
||||
<Select
|
||||
value={effectiveConfig.policy?.competitor_comparison ?? "refuse"}
|
||||
onChange={(v) => handlePolicyChange("competitor_comparison", v)}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Option value="refuse">Refuse (block request)</Option>
|
||||
<Option value="reframe">Reframe (suggest alternative)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${fieldId}-possible-competitor-comparison`}>
|
||||
Policy: Possible competitor comparison
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={effectiveConfig.policy?.possible_competitor_comparison ?? "reframe"}
|
||||
onValueChange={(v: string | null) =>
|
||||
v !== null && handlePolicyChange("possible_competitor_comparison", v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`${fieldId}-possible-competitor-comparison`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{POSSIBLE_COMPETITOR_COMPARISON_POLICIES.map((policy) => (
|
||||
<SelectItem key={policy.value} value={policy.value} title={policy.label}>
|
||||
{policy.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Form.Item label="Policy: Possible competitor comparison">
|
||||
<Select
|
||||
value={effectiveConfig.policy?.possible_competitor_comparison ?? "reframe"}
|
||||
onChange={(v) => handlePolicyChange("possible_competitor_comparison", v)}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Option value="refuse">Refuse (block request)</Option>
|
||||
<Option value="reframe">Reframe (suggest alternative to backend LLM)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Confidence thresholds"
|
||||
help={
|
||||
<>
|
||||
Classify competitor intent by confidence (0–1). Higher confidence → stronger intent.
|
||||
<ul style={{ marginBottom: 0, marginTop: 4, paddingLeft: 20 }}>
|
||||
<Field>
|
||||
<FieldLabel>Confidence thresholds</FieldLabel>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{THRESHOLDS.map((threshold) => (
|
||||
<Field key={threshold.field} className="w-20">
|
||||
<FieldLabel htmlFor={`${fieldId}-${threshold.field}`}>{threshold.label}</FieldLabel>
|
||||
<ThresholdInput
|
||||
id={`${fieldId}-${threshold.field}`}
|
||||
value={effectiveConfig[threshold.field] ?? threshold.fallback}
|
||||
onValueChange={(v) => handleConfigChange(threshold.field, v ?? threshold.fallback)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
/>
|
||||
<FieldDescription>{threshold.hint}</FieldDescription>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
<FieldDescription>
|
||||
Classify competitor intent by confidence (0–1). Higher confidence -> stronger intent.
|
||||
<ul className="mt-1 mb-0 list-disc pl-5">
|
||||
<li>
|
||||
<strong>High (≥)</strong>: Treat as full competitor comparison → uses "Competitor
|
||||
<strong>High (≥)</strong>: Treat as full competitor comparison -> uses "Competitor
|
||||
comparison" policy
|
||||
</li>
|
||||
<li>
|
||||
<strong>Medium (≥)</strong>: Treat as possible comparison → uses "Possible competitor
|
||||
<strong>Medium (≥)</strong>: Treat as possible comparison -> uses "Possible competitor
|
||||
comparison" policy
|
||||
</li>
|
||||
<li>
|
||||
<strong>Low (≥)</strong>: Log only; allow request. Below Low → allow with no action
|
||||
<strong>Low (≥)</strong>: Log only; allow request. Below Low -> allow with no action
|
||||
</li>
|
||||
</ul>
|
||||
Raise thresholds to be more permissive; lower them to be stricter.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Space wrap>
|
||||
<Form.Item label="High" style={{ marginBottom: 0 }} help="e.g. 0.7">
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={effectiveConfig.threshold_high ?? 0.7}
|
||||
onChange={(v) => handleConfigChange("threshold_high", v ?? 0.7)}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Medium" style={{ marginBottom: 0 }} help="e.g. 0.45">
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={effectiveConfig.threshold_medium ?? 0.45}
|
||||
onChange={(v) => handleConfigChange("threshold_medium", v ?? 0.45)}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Low" style={{ marginBottom: 0 }} help="e.g. 0.3">
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={effectiveConfig.threshold_low ?? 0.3}
|
||||
onChange={(v) => handleConfigChange("threshold_low", v ?? 0.3)}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import { useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { TagsInput, type TagsInputOption } from "./TagsInput";
|
||||
|
||||
const Harness = ({
|
||||
initial = [],
|
||||
options,
|
||||
onValueChange,
|
||||
}: {
|
||||
initial?: string[];
|
||||
options?: TagsInputOption[];
|
||||
onValueChange?: (value: string[]) => void;
|
||||
}) => {
|
||||
const [value, setValue] = useState<string[]>(initial);
|
||||
return (
|
||||
<TagsInput
|
||||
value={value}
|
||||
options={options}
|
||||
tokenSeparators={[","]}
|
||||
placeholder="tags"
|
||||
onValueChange={(next) => {
|
||||
onValueChange?.(next);
|
||||
setValue(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe("TagsInput", () => {
|
||||
it("commits each token separated value and keeps the unterminated remainder in the field", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValueChange = vi.fn();
|
||||
render(<Harness onValueChange={onValueChange} />);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, "acme,globex,initech");
|
||||
|
||||
expect(onValueChange).toHaveBeenLastCalledWith(["acme", "globex"]);
|
||||
expect(input).toHaveValue("initech");
|
||||
});
|
||||
|
||||
it("commits the pending value when the field loses focus", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValueChange = vi.fn();
|
||||
render(<Harness onValueChange={onValueChange} />);
|
||||
|
||||
await user.type(screen.getByRole("combobox"), "acme");
|
||||
await user.tab();
|
||||
|
||||
expect(onValueChange).toHaveBeenLastCalledWith(["acme"]);
|
||||
});
|
||||
|
||||
it("commits the pending value on Enter without submitting the surrounding form", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault());
|
||||
const onValueChange = vi.fn();
|
||||
render(
|
||||
<form onSubmit={onSubmit}>
|
||||
<Harness onValueChange={onValueChange} />
|
||||
<button type="submit">Save</button>
|
||||
</form>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByRole("combobox"), "acme{Enter}");
|
||||
|
||||
expect(onValueChange).toHaveBeenLastCalledWith(["acme"]);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a value that is already a tag", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValueChange = vi.fn();
|
||||
render(<Harness initial={["acme"]} onValueChange={onValueChange} />);
|
||||
|
||||
await user.type(screen.getByRole("combobox"), "acme,");
|
||||
|
||||
expect(onValueChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("labels a chip with the matching option label rather than the raw value", () => {
|
||||
render(<Harness initial={["qatar airways"]} options={[{ value: "qatar airways", label: "Qatar Airways (qr)" }]} />);
|
||||
|
||||
expect(screen.getByLabelText("Qatar Airways (qr)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
|
||||
export interface TagsInputOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface TagsInputProps {
|
||||
value: string[];
|
||||
onValueChange: (value: string[]) => void;
|
||||
options?: TagsInputOption[];
|
||||
placeholder?: string;
|
||||
emptyText?: string;
|
||||
tokenSeparators?: string[];
|
||||
loading?: boolean;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const splitOnSeparators = (raw: string, separators: string[]): string[] =>
|
||||
separators.reduce<string[]>((parts, separator) => parts.flatMap((part) => part.split(separator)), [raw]);
|
||||
|
||||
const toOption = (options: TagsInputOption[], value: string): TagsInputOption =>
|
||||
options.find((option) => option.value === value) ?? { label: value, value };
|
||||
|
||||
const matchesQuery = (option: TagsInputOption, query: string): boolean =>
|
||||
option.label.toLowerCase().includes(query.trim().toLowerCase());
|
||||
|
||||
export const TagsInput = ({
|
||||
value,
|
||||
onValueChange,
|
||||
options = [],
|
||||
placeholder,
|
||||
emptyText = "No matching options",
|
||||
tokenSeparators = [],
|
||||
loading = false,
|
||||
id,
|
||||
}: TagsInputProps) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const selected = value.map((tag) => toOption(options, tag));
|
||||
const pending = query.trim();
|
||||
const isCreatable = pending.length > 0 && !options.some((option) => option.value === pending);
|
||||
const items = isCreatable ? [{ label: pending, value: pending }, ...options] : options;
|
||||
|
||||
const addTags = (tags: string[]) => {
|
||||
const additions = tags
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
.filter((tag, index, all) => all.indexOf(tag) === index && !value.includes(tag));
|
||||
if (additions.length > 0) onValueChange([...value, ...additions]);
|
||||
};
|
||||
|
||||
const commitPending = () => {
|
||||
setQuery("");
|
||||
addTags([query]);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
if (event.currentTarget.getAttribute("aria-activedescendant")) return;
|
||||
commitPending();
|
||||
};
|
||||
|
||||
const handleInputValueChange = (next: string) => {
|
||||
if (!tokenSeparators.some((separator) => next.includes(separator))) {
|
||||
setQuery(next);
|
||||
return;
|
||||
}
|
||||
const parts = splitOnSeparators(next, tokenSeparators);
|
||||
setQuery(parts[parts.length - 1] ?? "");
|
||||
addTags(parts.slice(0, -1));
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
multiple
|
||||
items={items}
|
||||
value={selected}
|
||||
onValueChange={(next: TagsInputOption[]) => {
|
||||
setQuery("");
|
||||
onValueChange(next.map((option) => option.value));
|
||||
}}
|
||||
inputValue={query}
|
||||
onInputValueChange={handleInputValueChange}
|
||||
isItemEqualToValue={(option: TagsInputOption, other: TagsInputOption) => option.value === other.value}
|
||||
itemToStringLabel={(option: TagsInputOption) => option.label}
|
||||
filter={matchesQuery}
|
||||
openOnInputClick
|
||||
>
|
||||
<ComboboxChips render={<div ref={anchor} />} className="min-h-8 py-1 text-sm">
|
||||
<ComboboxValue>
|
||||
{(chips: TagsInputOption[]) => (
|
||||
<>
|
||||
{chips.map((option) => (
|
||||
<ComboboxChip key={option.value} aria-label={option.label}>
|
||||
{option.label}
|
||||
</ComboboxChip>
|
||||
))}
|
||||
<ComboboxChipsInput
|
||||
id={id}
|
||||
placeholder={loading ? "Loading..." : placeholder}
|
||||
className="min-w-24"
|
||||
onBlur={commitPending}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ComboboxValue>
|
||||
</ComboboxChips>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: TagsInputOption) => (
|
||||
<ComboboxItem key={option.value} value={option} title={option.label}>
|
||||
{option.label}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ThresholdInput } from "./ThresholdInput";
|
||||
|
||||
const Harness = ({
|
||||
initial = 0.7,
|
||||
onValueChange,
|
||||
}: {
|
||||
initial?: number;
|
||||
onValueChange?: (v: number | null) => void;
|
||||
}) => {
|
||||
const [value, setValue] = useState(initial);
|
||||
return (
|
||||
<ThresholdInput
|
||||
value={value}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
onValueChange={(next) => {
|
||||
onValueChange?.(next);
|
||||
setValue(next ?? initial);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe("ThresholdInput", () => {
|
||||
it("displays the value at the precision of the step", () => {
|
||||
render(<Harness initial={0.7} />);
|
||||
|
||||
expect(screen.getByRole("spinbutton")).toHaveValue("0.70");
|
||||
});
|
||||
|
||||
it("reports the parsed value while typing and null once the field is empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValueChange = vi.fn();
|
||||
render(<Harness onValueChange={onValueChange} />);
|
||||
|
||||
const input = screen.getByRole("spinbutton");
|
||||
await user.clear(input);
|
||||
expect(onValueChange).toHaveBeenLastCalledWith(null);
|
||||
|
||||
await user.type(input, "0.55");
|
||||
expect(onValueChange).toHaveBeenLastCalledWith(0.55);
|
||||
});
|
||||
|
||||
it("clamps a value above the maximum when the field loses focus", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValueChange = vi.fn();
|
||||
render(<Harness onValueChange={onValueChange} />);
|
||||
|
||||
const input = screen.getByRole("spinbutton");
|
||||
await user.clear(input);
|
||||
await user.type(input, "5");
|
||||
await user.tab();
|
||||
|
||||
expect(onValueChange).toHaveBeenLastCalledWith(1);
|
||||
expect(input).toHaveValue("1.00");
|
||||
});
|
||||
|
||||
it("steps by the step on the arrow keys and stops at the bounds", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValueChange = vi.fn();
|
||||
render(<Harness initial={0.95} onValueChange={onValueChange} />);
|
||||
|
||||
const input = screen.getByRole("spinbutton");
|
||||
await user.click(input);
|
||||
await user.keyboard("{ArrowUp}");
|
||||
expect(onValueChange).toHaveBeenLastCalledWith(1);
|
||||
|
||||
await user.keyboard("{ArrowUp}");
|
||||
expect(onValueChange).toHaveBeenLastCalledWith(1);
|
||||
|
||||
await user.keyboard("{ArrowDown}");
|
||||
expect(onValueChange).toHaveBeenLastCalledWith(0.95);
|
||||
});
|
||||
|
||||
it("exposes the bounds and the current value to assistive technology", () => {
|
||||
render(<Harness initial={0.45} />);
|
||||
|
||||
const input = screen.getByRole("spinbutton");
|
||||
expect(input).toHaveAttribute("aria-valuemin", "0");
|
||||
expect(input).toHaveAttribute("aria-valuemax", "1");
|
||||
expect(input).toHaveAttribute("aria-valuenow", "0.45");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface ThresholdInputProps {
|
||||
value: number;
|
||||
onValueChange: (value: number | null) => void;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const decimalsOf = (step: number): number => (String(step).split(".")[1] ?? "").length;
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max);
|
||||
|
||||
const parseDecimal = (raw: string): number | null => {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "") return null;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
export const ThresholdInput = ({ value, onValueChange, min, max, step, id }: ThresholdInputProps) => {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const decimals = decimalsOf(step);
|
||||
const display = draft ?? value.toFixed(decimals);
|
||||
const current = parseDecimal(display);
|
||||
|
||||
const stepBy = (direction: 1 | -1) => {
|
||||
const next = clamp(Number(((current ?? value) + direction * step).toFixed(decimals)), min, max);
|
||||
setDraft(next.toFixed(decimals));
|
||||
onValueChange(next);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setDraft(null);
|
||||
if (current === null) {
|
||||
onValueChange(null);
|
||||
return;
|
||||
}
|
||||
const clamped = clamp(current, min, max);
|
||||
if (clamped !== current) onValueChange(clamped);
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
role="spinbutton"
|
||||
inputMode="decimal"
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={current ?? undefined}
|
||||
className="w-20"
|
||||
value={display}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
onValueChange(parseDecimal(event.target.value));
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
stepBy(1);
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
stepBy(-1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { vectorStoreCreateCall } from "@/components/networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
import VectorStoreForm from "./VectorStoreForm";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
vectorStoreCreateCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/llm_calls/fetch_models", () => ({
|
||||
fetchAvailableModels: vi.fn().mockResolvedValue([
|
||||
{ model_group: "text-embedding-3-small", mode: "embedding" },
|
||||
{ model_group: "gpt-4o", mode: "chat" },
|
||||
]),
|
||||
}));
|
||||
|
||||
const mockCreate = vi.mocked(vectorStoreCreateCall);
|
||||
const mockToast = vi.mocked(toast);
|
||||
|
||||
const onSuccess = vi.fn();
|
||||
|
||||
const renderForm = () =>
|
||||
render(
|
||||
<VectorStoreForm
|
||||
isVisible={true}
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={onSuccess}
|
||||
accessToken="test-token"
|
||||
credentials={[{ credential_name: "bedrock-prod", credential_info: {}, credential_values: {} }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const setupUser = () => userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
const chooseFromSelect = async (user: ReturnType<typeof userEvent.setup>, index: number, optionText: string) => {
|
||||
const trigger = screen.getAllByRole("combobox")[index];
|
||||
await user.click(trigger);
|
||||
if (trigger.getAttribute("aria-expanded") !== "true") {
|
||||
trigger.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
}
|
||||
const options = await screen.findAllByText(optionText);
|
||||
await user.click(options[options.length - 1]);
|
||||
};
|
||||
|
||||
const chooseProvider = (user: ReturnType<typeof userEvent.setup>, providerLabel: string) =>
|
||||
chooseFromSelect(user, 0, providerLabel);
|
||||
|
||||
const submit = async (user: ReturnType<typeof userEvent.setup>) =>
|
||||
user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
const createdPayload = () => mockCreate.mock.calls[0][1];
|
||||
|
||||
describe("VectorStoreForm submit payload", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreate.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("sends every payload key for the default provider, leaving untouched optional fields undefined", async () => {
|
||||
const user = setupUser();
|
||||
renderForm();
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Enter vector store ID from your provider"), "vs-bedrock");
|
||||
await submit(user);
|
||||
|
||||
await vi.waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||
expect(mockCreate.mock.calls[0][0]).toBe("test-token");
|
||||
expect(createdPayload()).toStrictEqual({
|
||||
vector_store_id: "vs-bedrock",
|
||||
custom_llm_provider: "bedrock",
|
||||
vector_store_name: undefined,
|
||||
vector_store_description: undefined,
|
||||
vector_store_metadata: {},
|
||||
litellm_credential_name: undefined,
|
||||
litellm_params: {},
|
||||
});
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends the filled optional fields, parsed metadata and the selected credential", async () => {
|
||||
const user = setupUser();
|
||||
renderForm();
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Enter vector store ID from your provider"), "vs-full");
|
||||
const textboxes = screen.getAllByRole("textbox");
|
||||
await user.type(textboxes[1], "Support docs");
|
||||
await user.type(textboxes[2], "Docs for the support team");
|
||||
await user.clear(screen.getByPlaceholderText('{"key": "value"}'));
|
||||
await user.type(screen.getByPlaceholderText('{"key": "value"}'), '{{"tier": "gold"}');
|
||||
await chooseFromSelect(user, 1, "bedrock-prod");
|
||||
await submit(user);
|
||||
|
||||
await vi.waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||
expect(createdPayload()).toStrictEqual({
|
||||
vector_store_id: "vs-full",
|
||||
custom_llm_provider: "bedrock",
|
||||
vector_store_name: "Support docs",
|
||||
vector_store_description: "Docs for the support team",
|
||||
vector_store_metadata: { tier: "gold" },
|
||||
litellm_credential_name: "bedrock-prod",
|
||||
litellm_params: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("renames the milvus embedding model to litellm_embedding_model inside litellm_params", async () => {
|
||||
const user = setupUser();
|
||||
renderForm();
|
||||
|
||||
await chooseProvider(user, "Milvus");
|
||||
await user.type(screen.getByPlaceholderText("Enter vector store ID from your provider"), "vs-milvus");
|
||||
await user.type(screen.getByPlaceholderText("username:password or api key"), "user:pass");
|
||||
await user.type(screen.getByPlaceholderText("https://your-milvus-endpoint.com/"), "https://milvus.example.com");
|
||||
await chooseFromSelect(user, 1, "text-embedding-3-small");
|
||||
await submit(user);
|
||||
|
||||
await vi.waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||
expect(createdPayload().litellm_params).toStrictEqual({
|
||||
api_key: "user:pass",
|
||||
api_base: "https://milvus.example.com",
|
||||
litellm_embedding_model: "text-embedding-3-small",
|
||||
});
|
||||
expect(createdPayload().custom_llm_provider).toBe("milvus");
|
||||
});
|
||||
|
||||
it("sends a provider field's seeded default even when the user never touches it", async () => {
|
||||
const user = setupUser();
|
||||
renderForm();
|
||||
|
||||
await chooseProvider(user, "Vertex AI Search");
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('my-datastore_1234567890 (data store ID from Vertex AI / "Agent Search" console)'),
|
||||
"vs-vertex",
|
||||
);
|
||||
await user.type(screen.getByPlaceholderText("my-gcp-project-id"), "gcp-proj");
|
||||
await submit(user);
|
||||
|
||||
await vi.waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||
expect(createdPayload().litellm_params).toStrictEqual({
|
||||
vertex_project: "gcp-proj",
|
||||
vertex_location: "global",
|
||||
vertex_collection_id: undefined,
|
||||
vertex_engine_id: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps values typed under one provider when a later provider reuses the same field name", async () => {
|
||||
const user = setupUser();
|
||||
renderForm();
|
||||
|
||||
await chooseProvider(user, "PostgreSQL pgvector (LiteLLM Connector)");
|
||||
await user.type(screen.getByPlaceholderText("http://your-deployed-server:8000"), "http://pg:8000");
|
||||
await user.type(screen.getByPlaceholderText("your-deployed-api-key"), "pg-key");
|
||||
await chooseProvider(user, "Azure OpenAI");
|
||||
await user.type(screen.getByPlaceholderText("Enter vector store ID from your provider"), "vs-azure");
|
||||
await submit(user);
|
||||
|
||||
await vi.waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||
expect(createdPayload().litellm_params).toStrictEqual({
|
||||
api_key: "pg-key",
|
||||
api_base: "http://pg:8000",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks the request and reports invalid metadata JSON instead of submitting", async () => {
|
||||
const user = setupUser();
|
||||
renderForm();
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Enter vector store ID from your provider"), "vs-bad-json");
|
||||
await user.clear(screen.getByPlaceholderText('{"key": "value"}'));
|
||||
await user.type(screen.getByPlaceholderText('{"key": "value"}'), "not json");
|
||||
await submit(user);
|
||||
|
||||
await vi.waitFor(() => expect(mockToast.fromError).toHaveBeenCalledWith("Invalid JSON in metadata field"));
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the required-field messages that block an empty submit", async () => {
|
||||
const user = setupUser();
|
||||
renderForm();
|
||||
|
||||
await submit(user);
|
||||
|
||||
expect(await screen.findByText("Please input the vector store ID from your api provider")).toBeInTheDocument();
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,18 +1,37 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { TextInput, Button as TremorButton } from "@tremor/react";
|
||||
import { Modal, Form, Select, Tooltip, Input, Alert } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Modal, Alert } from "antd";
|
||||
import { CircleHelp, Eye, EyeOff } from "lucide-react";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { z } from "zod/v4";
|
||||
import { CredentialItem, vectorStoreCreateCall } from "@/components/networking";
|
||||
import {
|
||||
VectorStoreProviders,
|
||||
vectorStoreProviderLogoMap,
|
||||
vectorStoreProviderMap,
|
||||
getProviderSpecificFields,
|
||||
getVectorStoreProviderLogoAndName,
|
||||
VectorStoreFieldConfig,
|
||||
} from "@/components/vector_store_providers";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
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 { useZodForm } from "@/lib/forms/useZodForm";
|
||||
|
||||
interface VectorStoreFormProps {
|
||||
isVisible: boolean;
|
||||
|
|
@ -22,6 +41,103 @@ interface VectorStoreFormProps {
|
|||
credentials: CredentialItem[];
|
||||
}
|
||||
|
||||
const PROVIDER_FIELD_NAMES = [
|
||||
"api_base",
|
||||
"api_key",
|
||||
"vertex_project",
|
||||
"vertex_location",
|
||||
"vertex_collection_id",
|
||||
"vertex_engine_id",
|
||||
"embedding_model",
|
||||
"vector_bucket_name",
|
||||
"index_name",
|
||||
"aws_region_name",
|
||||
] as const;
|
||||
|
||||
type ProviderFieldName = (typeof PROVIDER_FIELD_NAMES)[number];
|
||||
|
||||
const isProviderFieldName = (name: string): name is ProviderFieldName =>
|
||||
(PROVIDER_FIELD_NAMES as readonly string[]).includes(name);
|
||||
|
||||
const optionalText = z.string().optional();
|
||||
|
||||
const vectorStoreShape = {
|
||||
custom_llm_provider: z.string().min(1, "Please select a provider"),
|
||||
vector_store_id: z.string().min(1, "Please input the vector store ID from your api provider"),
|
||||
vector_store_name: optionalText,
|
||||
vector_store_description: optionalText,
|
||||
litellm_credential_name: z.string().nullable().optional(),
|
||||
api_base: optionalText,
|
||||
api_key: optionalText,
|
||||
vertex_project: optionalText,
|
||||
vertex_location: optionalText,
|
||||
vertex_collection_id: optionalText,
|
||||
vertex_engine_id: optionalText,
|
||||
embedding_model: optionalText,
|
||||
vector_bucket_name: optionalText,
|
||||
index_name: optionalText,
|
||||
aws_region_name: optionalText,
|
||||
};
|
||||
|
||||
const vectorStoreSchema = z.object(vectorStoreShape).superRefine((values, ctx) => {
|
||||
getProviderSpecificFields(values.custom_llm_provider)
|
||||
.filter((field) => field.required && isProviderFieldName(field.name) && !values[field.name])
|
||||
.forEach((field) =>
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: [field.name],
|
||||
message:
|
||||
field.type === "select"
|
||||
? `Please select the ${field.label.toLowerCase()}`
|
||||
: `Please input the ${field.label.toLowerCase()}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
type VectorStoreFormValues = z.output<typeof vectorStoreSchema>;
|
||||
|
||||
const EMPTY_VALUES: VectorStoreFormValues = {
|
||||
custom_llm_provider: "bedrock",
|
||||
vector_store_id: "",
|
||||
vertex_location: "global",
|
||||
};
|
||||
|
||||
interface CredentialOption {
|
||||
label: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
const PasswordInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<typeof InputGroupInput>>(
|
||||
(props, ref) => {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
return (
|
||||
<InputGroup>
|
||||
<InputGroupInput {...props} ref={ref} type={revealed ? "text" : "password"} />
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={revealed ? "Hide Password" : "Show Password"}
|
||||
onClick={() => setRevealed(!revealed)}
|
||||
>
|
||||
{revealed ? <EyeOff /> : <Eye />}
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
);
|
||||
},
|
||||
);
|
||||
PasswordInput.displayName = "PasswordInput";
|
||||
|
||||
const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
||||
isVisible,
|
||||
onCancel,
|
||||
|
|
@ -29,11 +145,11 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
accessToken,
|
||||
credentials,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const form = useZodForm(vectorStoreSchema, { defaultValues: EMPTY_VALUES });
|
||||
const [metadataJson, setMetadataJson] = useState("{}");
|
||||
const [selectedProvider, setSelectedProvider] = useState("bedrock");
|
||||
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
|
||||
const vertexEngineId = Form.useWatch("vertex_engine_id", form);
|
||||
const vertexEngineId = useWatch({ control: form.control, name: "vertex_engine_id" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
|
|
@ -52,10 +168,23 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
loadModels();
|
||||
}, [accessToken]);
|
||||
|
||||
const handleCreate = async (formValues: any) => {
|
||||
const credentialOptions: CredentialOption[] = [
|
||||
{ value: null, label: "None" },
|
||||
...credentials.map((credential) => ({
|
||||
value: credential.credential_name,
|
||||
label: credential.credential_name,
|
||||
})),
|
||||
];
|
||||
|
||||
const makeProviderChangeHandler = (onChange: (provider: string) => void) => (provider: string | null) => {
|
||||
if (provider === null) return;
|
||||
onChange(provider);
|
||||
setSelectedProvider(provider);
|
||||
};
|
||||
|
||||
const handleCreate = async (formValues: VectorStoreFormValues) => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
// Parse metadata JSON
|
||||
let metadata = {};
|
||||
try {
|
||||
metadata = metadataJson.trim() ? JSON.parse(metadataJson) : {};
|
||||
|
|
@ -64,36 +193,28 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// Prepare the payload with provider-specific fields
|
||||
const payload: any = {
|
||||
const providerFields = getProviderSpecificFields(formValues.custom_llm_provider);
|
||||
const litellmParams = Object.fromEntries(
|
||||
providerFields.filter(isSupportedProviderField).map((field) => {
|
||||
const value = formValues[field.name];
|
||||
if (formValues.custom_llm_provider === "milvus" && field.name === "embedding_model") {
|
||||
return ["litellm_embedding_model", value];
|
||||
}
|
||||
return [field.name, value];
|
||||
}),
|
||||
);
|
||||
|
||||
await vectorStoreCreateCall(accessToken, {
|
||||
vector_store_id: formValues.vector_store_id,
|
||||
custom_llm_provider: formValues.custom_llm_provider,
|
||||
vector_store_name: formValues.vector_store_name,
|
||||
vector_store_description: formValues.vector_store_description,
|
||||
vector_store_metadata: metadata,
|
||||
litellm_credential_name: formValues.litellm_credential_name,
|
||||
};
|
||||
|
||||
// pass all provider fields as litellm params dict
|
||||
const providerFields = getProviderSpecificFields(formValues.custom_llm_provider);
|
||||
const litellmParams = providerFields.reduce(
|
||||
(acc, field) => {
|
||||
// Special handling for Milvus: rename embedding_model to litellm_embedding_model
|
||||
if (formValues.custom_llm_provider === "milvus" && field.name === "embedding_model") {
|
||||
acc["litellm_embedding_model"] = formValues[field.name];
|
||||
} else {
|
||||
acc[field.name] = formValues[field.name];
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
|
||||
payload["litellm_params"] = litellmParams;
|
||||
|
||||
await vectorStoreCreateCall(accessToken, payload);
|
||||
litellm_params: litellmParams,
|
||||
});
|
||||
toast.success("Vector store created successfully");
|
||||
form.resetFields();
|
||||
form.reset(EMPTY_VALUES);
|
||||
setMetadataJson("{}");
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
|
|
@ -103,312 +224,333 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
form.reset(EMPTY_VALUES);
|
||||
setMetadataJson("{}");
|
||||
setSelectedProvider("bedrock");
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const vectorStoreIdPlaceholder =
|
||||
selectedProvider === "vertex_rag_engine"
|
||||
? '6917529027641081856 (corpus ID from Vertex AI / "RAG Engine" console)'
|
||||
: selectedProvider === "vertex_ai/search_api"
|
||||
? vertexEngineId
|
||||
? "Any identifier you'll use to reference this in LiteLLM"
|
||||
: 'my-datastore_1234567890 (data store ID from Vertex AI / "Agent Search" console)'
|
||||
: "Enter vector store ID from your provider";
|
||||
|
||||
return (
|
||||
<Modal title="Add New Vector Store" open={isVisible} width={1000} footer={null} onCancel={handleCancel}>
|
||||
<Form form={form} onFinish={handleCreate} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} labelAlign="left">
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Provider{" "}
|
||||
<Tooltip title="Select the provider for this vector store">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="custom_llm_provider"
|
||||
rules={[{ required: true, message: "Please select a provider" }]}
|
||||
initialValue="bedrock"
|
||||
>
|
||||
<Select onChange={(value) => setSelectedProvider(value)}>
|
||||
{Object.entries(VectorStoreProviders).map(([providerEnum, providerDisplayName]) => {
|
||||
return (
|
||||
<Select.Option key={providerEnum} value={vectorStoreProviderMap[providerEnum]}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Logo
|
||||
src={vectorStoreProviderLogoMap[providerDisplayName]}
|
||||
label={providerDisplayName}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
<span>{providerDisplayName}</span>
|
||||
</div>
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{/* PG Vector Setup Instructions */}
|
||||
{selectedProvider === "pg_vector" && (
|
||||
<Alert
|
||||
message="PG Vector Setup Required"
|
||||
description={
|
||||
<div>
|
||||
<p>LiteLLM provides a server to connect to PG Vector. To use this provider:</p>
|
||||
<ol style={{ marginLeft: "16px", marginTop: "8px" }}>
|
||||
<li>
|
||||
Deploy the litellm-pgvector server from:{" "}
|
||||
<a href="https://github.com/BerriAI/litellm-pgvector" target="_blank" rel="noopener noreferrer">
|
||||
https://github.com/BerriAI/litellm-pgvector
|
||||
</a>
|
||||
</li>
|
||||
<li>Configure your PostgreSQL database with pgvector extension</li>
|
||||
<li>Start the server and note the API base URL and API key</li>
|
||||
<li>Enter those details in the fields below</li>
|
||||
</ol>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: "16px" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Vertex RAG Engine Setup Instructions */}
|
||||
{selectedProvider === "vertex_rag_engine" && (
|
||||
<Alert
|
||||
message="Vertex AI RAG Engine Setup"
|
||||
description={
|
||||
<div>
|
||||
<p>To use Vertex AI RAG Engine:</p>
|
||||
<p style={{ marginTop: "4px", fontStyle: "italic" }}>
|
||||
Note: Google Cloud has renamed this to "RAG Engine" in its console — the steps below still
|
||||
apply.
|
||||
</p>
|
||||
<ol style={{ marginLeft: "16px", marginTop: "8px" }}>
|
||||
<li>
|
||||
Set up your Vertex AI RAG Engine corpus following the guide:{" "}
|
||||
<a
|
||||
href="https://cloud.google.com/vertex-ai/generative-ai/docs/rag-engine/rag-overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Vertex AI RAG Engine Overview
|
||||
</a>
|
||||
</li>
|
||||
<li>Create a corpus in your Google Cloud project</li>
|
||||
<li>
|
||||
Note the corpus ID from the Vertex AI console (now labeled "RAG Engine" in Google Cloud)
|
||||
</li>
|
||||
<li>Enter the corpus ID in the Vector Store ID field below</li>
|
||||
</ol>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: "16px" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Vertex AI Search Setup Instructions */}
|
||||
{selectedProvider === "vertex_ai/search_api" && (
|
||||
<Alert
|
||||
message="Vertex AI Search Setup"
|
||||
description={
|
||||
<div>
|
||||
<p>To use Vertex AI Search (Discovery Engine):</p>
|
||||
<p style={{ marginTop: "4px", fontStyle: "italic" }}>
|
||||
Note: Google Cloud has renamed this to "Agent Search" in its console — the steps below still
|
||||
apply.
|
||||
</p>
|
||||
<ol style={{ marginLeft: "16px", marginTop: "8px" }}>
|
||||
<li>
|
||||
Enable the Discovery Engine API on your Google Cloud project and create a data store following the
|
||||
guide:{" "}
|
||||
<a
|
||||
href="https://cloud.google.com/generative-ai-app-builder/docs/create-data-store-es"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ textDecoration: "underline" }}
|
||||
>
|
||||
Create a Vertex AI Search data store
|
||||
</a>
|
||||
</li>
|
||||
<li>Pick a supported location: global, us, or eu</li>
|
||||
<li>
|
||||
For most data store types (Cloud Storage, BigQuery, Media): copy the data store ID and enter it in
|
||||
the Vector Store ID field below.
|
||||
</li>
|
||||
<li>
|
||||
For website, healthcare, and connector-based sources (Drive, Gmail, Slack, Jira, etc.): create a
|
||||
search app on top of the data store, then copy the <strong>Engine ID</strong> and enter it in the
|
||||
Engine ID field. The Vector Store ID is still required as the LiteLLM-side name for this record, but
|
||||
it isn't used in the GCP URL when Engine ID is set.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: "16px" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Vector Store ID{" "}
|
||||
<Tooltip title="Enter the vector store ID from your api provider">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="vector_store_id"
|
||||
rules={[{ required: true, message: "Please input the vector store ID from your api provider" }]}
|
||||
>
|
||||
<TextInput
|
||||
placeholder={
|
||||
selectedProvider === "vertex_rag_engine"
|
||||
? '6917529027641081856 (corpus ID from Vertex AI / "RAG Engine" console)'
|
||||
: selectedProvider === "vertex_ai/search_api"
|
||||
? vertexEngineId
|
||||
? "Any identifier you'll use to reference this in LiteLLM"
|
||||
: 'my-datastore_1234567890 (data store ID from Vertex AI / "Agent Search" console)'
|
||||
: "Enter vector store ID from your provider"
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Provider-specific fields */}
|
||||
{getProviderSpecificFields(selectedProvider).map((field: VectorStoreFieldConfig) => {
|
||||
if (field.type === "select") {
|
||||
const selectOptions =
|
||||
field.options ??
|
||||
modelInfo
|
||||
.filter((option: ModelGroup) => option.mode === "embedding" || option.mode === null)
|
||||
.map((option: ModelGroup) => ({
|
||||
value: option.model_group,
|
||||
label: option.model_group,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
key={field.name}
|
||||
label={
|
||||
<span>
|
||||
{field.label}{" "}
|
||||
<Tooltip title={field.tooltip}>
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name={field.name}
|
||||
initialValue={field.initialValue}
|
||||
rules={
|
||||
field.required ? [{ required: true, message: `Please select the ${field.label.toLowerCase()}` }] : []
|
||||
}
|
||||
>
|
||||
<Select
|
||||
placeholder={field.placeholder}
|
||||
showSearch={true}
|
||||
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
|
||||
options={selectOptions}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
key={field.name}
|
||||
label={
|
||||
<span>
|
||||
{field.label}{" "}
|
||||
<Tooltip title={field.tooltip}>
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name={field.name}
|
||||
rules={
|
||||
field.required ? [{ required: true, message: `Please input the ${field.label.toLowerCase()}` }] : []
|
||||
}
|
||||
<TooltipProvider>
|
||||
<form onSubmit={form.handleSubmit(handleCreate)}>
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="custom_llm_provider"
|
||||
label={labelWithHint("Provider", "Select the provider for this vector store")}
|
||||
>
|
||||
<TextInput type={field.type || "text"} placeholder={field.placeholder} />
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<Select value={value} onValueChange={makeProviderChangeHandler(onChange)}>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue>
|
||||
{(provider: string) => {
|
||||
const { displayName, logo } = getVectorStoreProviderLogoAndName(provider);
|
||||
return (
|
||||
<>
|
||||
<Logo src={logo} label={displayName} className="w-5 h-5" />
|
||||
<span>{displayName}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{Object.entries(VectorStoreProviders).map(([providerEnum, providerDisplayName]) => (
|
||||
<SelectItem key={providerEnum} value={vectorStoreProviderMap[providerEnum]}>
|
||||
<Logo
|
||||
src={vectorStoreProviderLogoMap[providerDisplayName]}
|
||||
label={providerDisplayName}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
<span>{providerDisplayName}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Vector Store Name{" "}
|
||||
<Tooltip title="Custom name you want to give to the vector store, this name will be rendered on the LiteLLM UI">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="vector_store_name"
|
||||
>
|
||||
<TextInput />
|
||||
</Form.Item>
|
||||
{selectedProvider === "pg_vector" && (
|
||||
<Alert
|
||||
message="PG Vector Setup Required"
|
||||
description={
|
||||
<div>
|
||||
<p>LiteLLM provides a server to connect to PG Vector. To use this provider:</p>
|
||||
<ol style={{ marginLeft: "16px", marginTop: "8px" }}>
|
||||
<li>
|
||||
Deploy the litellm-pgvector server from:{" "}
|
||||
<a href="https://github.com/BerriAI/litellm-pgvector" target="_blank" rel="noopener noreferrer">
|
||||
https://github.com/BerriAI/litellm-pgvector
|
||||
</a>
|
||||
</li>
|
||||
<li>Configure your PostgreSQL database with pgvector extension</li>
|
||||
<li>Start the server and note the API base URL and API key</li>
|
||||
<li>Enter those details in the fields below</li>
|
||||
</ol>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form.Item label="Description" name="vector_store_description">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
{selectedProvider === "vertex_rag_engine" && (
|
||||
<Alert
|
||||
message="Vertex AI RAG Engine Setup"
|
||||
description={
|
||||
<div>
|
||||
<p>To use Vertex AI RAG Engine:</p>
|
||||
<p style={{ marginTop: "4px", fontStyle: "italic" }}>
|
||||
Note: Google Cloud has renamed this to "RAG Engine" in its console — the steps below
|
||||
still apply.
|
||||
</p>
|
||||
<ol style={{ marginLeft: "16px", marginTop: "8px" }}>
|
||||
<li>
|
||||
Set up your Vertex AI RAG Engine corpus following the guide:{" "}
|
||||
<a
|
||||
href="https://cloud.google.com/vertex-ai/generative-ai/docs/rag-engine/rag-overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Vertex AI RAG Engine Overview
|
||||
</a>
|
||||
</li>
|
||||
<li>Create a corpus in your Google Cloud project</li>
|
||||
<li>
|
||||
Note the corpus ID from the Vertex AI console (now labeled "RAG Engine" in Google
|
||||
Cloud)
|
||||
</li>
|
||||
<li>Enter the corpus ID in the Vector Store ID field below</li>
|
||||
</ol>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Existing Credentials{" "}
|
||||
<Tooltip title="Optionally select API provider credentials for this vector store eg. Bedrock API KEY">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="litellm_credential_name"
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Select or search for existing credentials"
|
||||
optionFilterProp="children"
|
||||
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
|
||||
options={[
|
||||
{ value: null, label: "None" },
|
||||
...credentials.map((credential) => ({
|
||||
value: credential.credential_name,
|
||||
label: credential.credential_name,
|
||||
})),
|
||||
]}
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
{selectedProvider === "vertex_ai/search_api" && (
|
||||
<Alert
|
||||
message="Vertex AI Search Setup"
|
||||
description={
|
||||
<div>
|
||||
<p>To use Vertex AI Search (Discovery Engine):</p>
|
||||
<p style={{ marginTop: "4px", fontStyle: "italic" }}>
|
||||
Note: Google Cloud has renamed this to "Agent Search" in its console — the steps below
|
||||
still apply.
|
||||
</p>
|
||||
<ol style={{ marginLeft: "16px", marginTop: "8px" }}>
|
||||
<li>
|
||||
Enable the Discovery Engine API on your Google Cloud project and create a data store following
|
||||
the guide:{" "}
|
||||
<a
|
||||
href="https://cloud.google.com/generative-ai-app-builder/docs/create-data-store-es"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ textDecoration: "underline" }}
|
||||
>
|
||||
Create a Vertex AI Search data store
|
||||
</a>
|
||||
</li>
|
||||
<li>Pick a supported location: global, us, or eu</li>
|
||||
<li>
|
||||
For most data store types (Cloud Storage, BigQuery, Media): copy the data store ID and enter it
|
||||
in the Vector Store ID field below.
|
||||
</li>
|
||||
<li>
|
||||
For website, healthcare, and connector-based sources (Drive, Gmail, Slack, Jira, etc.): create a
|
||||
search app on top of the data store, then copy the <strong>Engine ID</strong> and enter it in
|
||||
the Engine ID field. The Vector Store ID is still required as the LiteLLM-side name for this
|
||||
record, but it isn't used in the GCP URL when Engine ID is set.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Metadata{" "}
|
||||
<Tooltip title="JSON metadata for the vector store (optional)">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
value={metadataJson}
|
||||
onChange={(e) => setMetadataJson(e.target.value)}
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
</Form.Item>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="vector_store_id"
|
||||
label={labelWithHint("Vector Store ID", "Enter the vector store ID from your api provider")}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder={vectorStoreIdPlaceholder} />}
|
||||
</FormField>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<TremorButton onClick={handleCancel} variant="secondary">
|
||||
Cancel
|
||||
</TremorButton>
|
||||
<TremorButton variant="primary" type="submit">
|
||||
Create
|
||||
</TremorButton>
|
||||
</div>
|
||||
</Form>
|
||||
{getProviderSpecificFields(selectedProvider)
|
||||
.filter(isSupportedProviderField)
|
||||
.map((field) => (
|
||||
<ProviderField key={field.name} field={field} control={form.control} modelInfo={modelInfo} />
|
||||
))}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="vector_store_name"
|
||||
label={labelWithHint(
|
||||
"Vector Store Name",
|
||||
"Custom name you want to give to the vector store, this name will be rendered on the LiteLLM UI",
|
||||
)}
|
||||
>
|
||||
{({ ref, value, ...field }) => <Input {...field} ref={ref} value={value ?? ""} />}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="vector_store_description" label="Description">
|
||||
{({ ref, value, ...field }) => <Textarea {...field} ref={ref} value={value ?? ""} rows={4} />}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="litellm_credential_name"
|
||||
label={labelWithHint(
|
||||
"Existing Credentials",
|
||||
"Optionally select API provider credentials for this vector store eg. Bedrock API KEY",
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<Combobox
|
||||
items={credentialOptions}
|
||||
value={credentialOptions.find((option) => option.value === value) ?? null}
|
||||
onValueChange={(option: CredentialOption | null) => onChange(option ? option.value : undefined)}
|
||||
itemToStringLabel={(option: CredentialOption) => option.label}
|
||||
isItemEqualToValue={(option: CredentialOption, selected: CredentialOption) =>
|
||||
option.value === selected.value
|
||||
}
|
||||
>
|
||||
<ComboboxInput
|
||||
id={id}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
placeholder="Select or search for existing credentials"
|
||||
className="w-full"
|
||||
showClear={value !== undefined}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No matching credentials</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: CredentialOption) => (
|
||||
<ComboboxItem key={option.label} value={option}>
|
||||
{option.label}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<div role="group" className="flex w-full flex-col gap-3">
|
||||
<span className="flex w-fit gap-2 text-sm leading-snug font-medium">
|
||||
{labelWithHint("Metadata", "JSON metadata for the vector store (optional)")}
|
||||
</span>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={metadataJson}
|
||||
onChange={(event) => setMetadataJson(event.target.value)}
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="mt-6 flex justify-end space-x-3">
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Create</Button>
|
||||
</div>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
type SupportedProviderField = VectorStoreFieldConfig & { name: ProviderFieldName };
|
||||
|
||||
const isSupportedProviderField = (field: VectorStoreFieldConfig): field is SupportedProviderField =>
|
||||
isProviderFieldName(field.name);
|
||||
|
||||
interface ProviderFieldProps {
|
||||
field: SupportedProviderField;
|
||||
control: ReturnType<typeof useZodForm<VectorStoreFormValues, VectorStoreFormValues>>["control"];
|
||||
modelInfo: ModelGroup[];
|
||||
}
|
||||
|
||||
const ProviderField: React.FC<ProviderFieldProps> = ({ field, control, modelInfo }) => {
|
||||
const label = labelWithHint(field.label, field.tooltip);
|
||||
|
||||
if (field.type === "select") {
|
||||
const selectOptions =
|
||||
field.options ??
|
||||
modelInfo
|
||||
.filter((option: ModelGroup) => option.mode === "embedding" || option.mode === null)
|
||||
.map((option: ModelGroup) => ({
|
||||
value: option.model_group,
|
||||
label: option.model_group,
|
||||
}));
|
||||
|
||||
return (
|
||||
<FormField control={control} name={field.name} label={label}>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<Combobox
|
||||
items={selectOptions}
|
||||
value={selectOptions.find((option) => option.value === value) ?? null}
|
||||
onValueChange={(option: { value: string; label: string } | null) => onChange(option?.value)}
|
||||
itemToStringLabel={(option: { value: string; label: string }) => option.label}
|
||||
isItemEqualToValue={(
|
||||
option: { value: string; label: string },
|
||||
selected: { value: string; label: string },
|
||||
) => option.value === selected.value}
|
||||
>
|
||||
<ComboboxInput
|
||||
id={id}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full"
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No matching options</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: { value: string; label: string }) => (
|
||||
<ComboboxItem key={option.value} value={option}>
|
||||
{option.label}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
)}
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField control={control} name={field.name} label={label}>
|
||||
{({ ref, value, ...controlProps }) =>
|
||||
field.type === "password" ? (
|
||||
<PasswordInput {...controlProps} ref={ref} value={value ?? ""} placeholder={field.placeholder} />
|
||||
) : (
|
||||
<Input {...controlProps} ref={ref} value={value ?? ""} type="text" placeholder={field.placeholder} />
|
||||
)
|
||||
}
|
||||
</FormField>
|
||||
);
|
||||
};
|
||||
|
||||
export default VectorStoreForm;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { credentialListCall, vectorStoreInfoCall, vectorStoreUpdateCall } from "@/components/networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
import VectorStoreInfoView from "./vector_store_info";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
vectorStoreInfoCall: vi.fn(),
|
||||
vectorStoreUpdateCall: vi.fn(),
|
||||
credentialListCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./VectorStoreTester", () => ({ __esModule: true, default: () => null }));
|
||||
|
||||
const mockInfo = vi.mocked(vectorStoreInfoCall);
|
||||
const mockUpdate = vi.mocked(vectorStoreUpdateCall);
|
||||
const mockCredentials = vi.mocked(credentialListCall);
|
||||
const mockToast = vi.mocked(toast);
|
||||
|
||||
const serverRecord = {
|
||||
vector_store_id: "vs-1",
|
||||
vector_store_name: "support-docs-store",
|
||||
vector_store_description: "Docs for support",
|
||||
custom_llm_provider: "bedrock",
|
||||
vector_store_metadata: { tier: "gold" },
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-02-02T00:00:00Z",
|
||||
litellm_credential_name: "bedrock-prod",
|
||||
};
|
||||
|
||||
const renderView = (editVectorStore: boolean) =>
|
||||
render(
|
||||
<VectorStoreInfoView
|
||||
vectorStoreId="vs-1"
|
||||
onClose={vi.fn()}
|
||||
accessToken="sk-test"
|
||||
is_admin={true}
|
||||
editVectorStore={editVectorStore}
|
||||
/>,
|
||||
);
|
||||
|
||||
const savedPayload = () => mockUpdate.mock.calls[0][1];
|
||||
|
||||
describe("VectorStoreInfoView save payload", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockInfo.mockResolvedValue({ vector_store: serverRecord });
|
||||
mockCredentials.mockResolvedValue({ credentials: [{ credential_name: "bedrock-prod" }] });
|
||||
mockUpdate.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("still saves when the server left the nullable name and description null", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockInfo.mockResolvedValue({
|
||||
vector_store: { ...serverRecord, vector_store_name: null, vector_store_description: null },
|
||||
});
|
||||
renderView(true);
|
||||
await screen.findByRole("button", { name: "Save Changes" });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
await vi.waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
expect(savedPayload()).toStrictEqual({
|
||||
vector_store_id: "vs-1",
|
||||
custom_llm_provider: "bedrock",
|
||||
vector_store_name: null,
|
||||
vector_store_description: null,
|
||||
vector_store_metadata: { tier: "gold" },
|
||||
});
|
||||
});
|
||||
|
||||
it("sends only the five editable keys and drops every server-only field", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderView(true);
|
||||
|
||||
const nameInput = await screen.findByDisplayValue("support-docs-store");
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, "renamed-store");
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
await vi.waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
expect(mockUpdate.mock.calls[0][0]).toBe("sk-test");
|
||||
expect(savedPayload()).toStrictEqual({
|
||||
vector_store_id: "vs-1",
|
||||
custom_llm_provider: "bedrock",
|
||||
vector_store_name: "renamed-store",
|
||||
vector_store_description: "Docs for support",
|
||||
vector_store_metadata: { tier: "gold" },
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the same five keys when editing is entered from the details view", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderView(false);
|
||||
|
||||
const editButtons = await screen.findAllByRole("button", { name: "Edit Vector Store" });
|
||||
await user.click(editButtons[0]);
|
||||
const descriptionInput = await screen.findByDisplayValue("Docs for support");
|
||||
await user.clear(descriptionInput);
|
||||
await user.type(descriptionInput, "new description");
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
await vi.waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
expect(savedPayload()).toStrictEqual({
|
||||
vector_store_id: "vs-1",
|
||||
custom_llm_provider: "bedrock",
|
||||
vector_store_name: "support-docs-store",
|
||||
vector_store_description: "new description",
|
||||
vector_store_metadata: { tier: "gold" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the credential field out of the payload even after it is picked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderView(true);
|
||||
|
||||
await screen.findByDisplayValue("support-docs-store");
|
||||
await user.click(screen.getAllByRole("combobox")[1]);
|
||||
const options = await screen.findAllByText("bedrock-prod");
|
||||
await user.click(options[options.length - 1]);
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
await vi.waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
expect(Object.keys(savedPayload())).toStrictEqual([
|
||||
"vector_store_id",
|
||||
"custom_llm_provider",
|
||||
"vector_store_name",
|
||||
"vector_store_description",
|
||||
"vector_store_metadata",
|
||||
]);
|
||||
});
|
||||
|
||||
it("blocks the request and reports invalid metadata JSON instead of saving", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderView(true);
|
||||
|
||||
const metadataInput = await screen.findByPlaceholderText('{"key": "value"}');
|
||||
await user.clear(metadataInput);
|
||||
await user.type(metadataInput, "not json");
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
await vi.waitFor(() => expect(mockToast.fromError).toHaveBeenCalledWith("Invalid JSON in metadata field"));
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the required-field message that blocks saving without a vector store id", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockInfo.mockResolvedValue({ vector_store: { ...serverRecord, vector_store_id: "" } });
|
||||
renderView(true);
|
||||
|
||||
await screen.findByDisplayValue("support-docs-store");
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
expect(await screen.findByText("Please input a vector store ID")).toBeInTheDocument();
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Card, Text, Title, Button, Badge, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
|
||||
import { Form, Input, Select as Select2, Tooltip, Button as AntButton } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/outline";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
vectorStoreInfoCall,
|
||||
vectorStoreUpdateCall,
|
||||
|
|
@ -15,6 +15,22 @@ import { getVectorStoreProviderLogoAndName } from "@/components/vector_store_pro
|
|||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import VectorStoreTester from "./VectorStoreTester";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Button as ShadcnButton } from "@/components/ui/button";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
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 { useZodForm } from "@/lib/forms/useZodForm";
|
||||
|
||||
interface VectorStoreInfoViewProps {
|
||||
vectorStoreId: string;
|
||||
|
|
@ -24,6 +40,46 @@ interface VectorStoreInfoViewProps {
|
|||
editVectorStore: boolean;
|
||||
}
|
||||
|
||||
const vectorStoreEditShape = {
|
||||
vector_store_id: z.string().min(1, "Please input a vector store ID"),
|
||||
vector_store_name: z.string().nullish(),
|
||||
vector_store_description: z.string().nullish(),
|
||||
custom_llm_provider: z.string().min(1, "Please select a provider"),
|
||||
litellm_credential_name: z.string().nullable().optional(),
|
||||
};
|
||||
|
||||
const vectorStoreEditSchema = z.object(vectorStoreEditShape);
|
||||
|
||||
type VectorStoreEditValues = z.output<typeof vectorStoreEditSchema>;
|
||||
|
||||
const EMPTY_VALUES: VectorStoreEditValues = {
|
||||
vector_store_id: "",
|
||||
custom_llm_provider: "",
|
||||
};
|
||||
|
||||
const toFormValues = (vectorStore: VectorStore): VectorStoreEditValues => ({
|
||||
vector_store_id: vectorStore.vector_store_id,
|
||||
vector_store_name: vectorStore.vector_store_name,
|
||||
vector_store_description: vectorStore.vector_store_description,
|
||||
custom_llm_provider: vectorStore.custom_llm_provider ?? "",
|
||||
litellm_credential_name: vectorStore.litellm_credential_name,
|
||||
});
|
||||
|
||||
interface CredentialOption {
|
||||
label: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
||||
vectorStoreId,
|
||||
onClose,
|
||||
|
|
@ -31,7 +87,7 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
is_admin,
|
||||
editVectorStore,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const form = useZodForm(vectorStoreEditSchema, { defaultValues: EMPTY_VALUES });
|
||||
const [vectorStoreDetails, setVectorStoreDetails] = useState<VectorStore | null>(null);
|
||||
const [loadFailed, setLoadFailed] = useState<boolean>(false);
|
||||
const [isEditing, setIsEditing] = useState<boolean>(editVectorStore);
|
||||
|
|
@ -49,7 +105,6 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
}
|
||||
setVectorStoreDetails(response.vector_store);
|
||||
|
||||
// If metadata exists and is an object, stringify it for display/editing
|
||||
if (response.vector_store.vector_store_metadata) {
|
||||
const metadata =
|
||||
typeof response.vector_store.vector_store_metadata === "string"
|
||||
|
|
@ -58,14 +113,7 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
setMetadataString(JSON.stringify(metadata, null, 2));
|
||||
}
|
||||
|
||||
if (editVectorStore) {
|
||||
form.setFieldsValue({
|
||||
vector_store_id: response.vector_store.vector_store_id,
|
||||
custom_llm_provider: response.vector_store.custom_llm_provider,
|
||||
vector_store_name: response.vector_store.vector_store_name,
|
||||
vector_store_description: response.vector_store.vector_store_description,
|
||||
});
|
||||
}
|
||||
form.reset(toFormValues(response.vector_store));
|
||||
} catch (error) {
|
||||
console.error("Error fetching vector store details:", error);
|
||||
toast.fromError("Error fetching vector store details: " + error);
|
||||
|
|
@ -88,10 +136,16 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
fetchCredentials();
|
||||
}, [vectorStoreId, accessToken]);
|
||||
|
||||
const handleSave = async (values: any) => {
|
||||
const startEditing = () => {
|
||||
if (vectorStoreDetails) {
|
||||
form.reset(toFormValues(vectorStoreDetails));
|
||||
}
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = async (values: VectorStoreEditValues) => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
// Parse the metadata JSON string
|
||||
let metadata = {};
|
||||
try {
|
||||
metadata = metadataString ? JSON.parse(metadataString) : {};
|
||||
|
|
@ -118,6 +172,14 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const credentialOptions: CredentialOption[] = [
|
||||
{ value: null, label: "None" },
|
||||
...credentials.map((credential) => ({
|
||||
value: credential.credential_name,
|
||||
label: credential.credential_name,
|
||||
})),
|
||||
];
|
||||
|
||||
if (loadFailed) {
|
||||
return (
|
||||
<div className="p-4 max-w-full">
|
||||
|
|
@ -125,7 +187,7 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
Back to Vector Stores
|
||||
</Button>
|
||||
<Title>Vector store not found</Title>
|
||||
<Text className="text-gray-500">
|
||||
<Text className="text-muted-foreground">
|
||||
Vector store {vectorStoreId} could not be loaded. It may have been deleted.
|
||||
</Text>
|
||||
</div>
|
||||
|
|
@ -144,9 +206,11 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
Back to Vector Stores
|
||||
</Button>
|
||||
<Title>Vector Store ID: {vectorStoreDetails.vector_store_id}</Title>
|
||||
<Text className="text-gray-500">{vectorStoreDetails.vector_store_description || "No description"}</Text>
|
||||
<Text className="text-muted-foreground">
|
||||
{vectorStoreDetails.vector_store_description || "No description"}
|
||||
</Text>
|
||||
</div>
|
||||
{is_admin && !isEditing && <Button onClick={() => setIsEditing(true)}>Edit Vector Store</Button>}
|
||||
{is_admin && !isEditing && <Button onClick={startEditing}>Edit Vector Store</Button>}
|
||||
</div>
|
||||
|
||||
<TabGroup>
|
||||
|
|
@ -156,7 +220,6 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* Details Tab */}
|
||||
<TabPanel>
|
||||
{isEditing ? (
|
||||
<div>
|
||||
|
|
@ -164,117 +227,145 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
<Title>Edit Vector Store</Title>
|
||||
</div>
|
||||
<Card>
|
||||
<Form form={form} onFinish={handleSave} layout="vertical" initialValues={vectorStoreDetails}>
|
||||
<Form.Item
|
||||
label="Vector Store ID"
|
||||
name="vector_store_id"
|
||||
rules={[{ required: true, message: "Please input a vector store ID" }]}
|
||||
>
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
<TooltipProvider>
|
||||
<form onSubmit={form.handleSubmit(handleSave)}>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="vector_store_id" label="Vector Store ID">
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} disabled />}
|
||||
</FormField>
|
||||
|
||||
<Form.Item label="Vector Store Name" name="vector_store_name">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<FormField control={form.control} name="vector_store_name" label="Vector Store Name">
|
||||
{({ ref, value, ...field }) => <Input {...field} ref={ref} value={value ?? ""} />}
|
||||
</FormField>
|
||||
|
||||
<Form.Item label="Description" name="vector_store_description">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<FormField control={form.control} name="vector_store_description" label="Description">
|
||||
{({ ref, value, ...field }) => <Textarea {...field} ref={ref} value={value ?? ""} rows={4} />}
|
||||
</FormField>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Provider{" "}
|
||||
<Tooltip title="Select the provider for this vector store">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="custom_llm_provider"
|
||||
rules={[{ required: true, message: "Please select a provider" }]}
|
||||
>
|
||||
<Select2>
|
||||
{Object.entries(Providers).map(([providerEnum, providerDisplayName]) => {
|
||||
// Currently only showing Bedrock since it's the only supported provider
|
||||
if (providerEnum === "Bedrock") {
|
||||
return (
|
||||
<Select2.Option key={providerEnum} value={provider_map[providerEnum]}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Logo provider={providerEnum} label={providerDisplayName} className="w-5 h-5" />
|
||||
<span>{providerDisplayName}</span>
|
||||
</div>
|
||||
</Select2.Option>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</Select2>
|
||||
</Form.Item>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="custom_llm_provider"
|
||||
label={labelWithHint("Provider", "Select the provider for this vector store")}
|
||||
>
|
||||
{({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
"aria-invalid": ariaInvalid,
|
||||
"aria-describedby": ariaDescribedBy,
|
||||
}) => (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue>
|
||||
{(provider: string) => {
|
||||
const { displayName, logo } = getVectorStoreProviderLogoAndName(provider);
|
||||
return (
|
||||
<>
|
||||
<Logo src={logo} label={displayName} className="w-5 h-5" />
|
||||
<span>{displayName}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{Object.entries(Providers)
|
||||
.filter(([providerEnum]) => providerEnum === "Bedrock")
|
||||
.map(([providerEnum, providerDisplayName]) => (
|
||||
<SelectItem key={providerEnum} value={provider_map[providerEnum]}>
|
||||
<Logo provider={providerEnum} label={providerDisplayName} className="w-5 h-5" />
|
||||
<span>{providerDisplayName}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{/* Credentials */}
|
||||
<div className="mb-4">
|
||||
<Text className="text-sm text-gray-500 mb-2">
|
||||
Either select existing credentials OR enter provider credentials below
|
||||
</Text>
|
||||
</div>
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
Either select existing credentials OR enter provider credentials below
|
||||
</Text>
|
||||
|
||||
<Form.Item label="Existing Credentials" name="litellm_credential_name">
|
||||
<Select2
|
||||
showSearch
|
||||
placeholder="Select or search for existing credentials"
|
||||
optionFilterProp="children"
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={[
|
||||
{ value: null, label: "None" },
|
||||
...credentials.map((credential) => ({
|
||||
value: credential.credential_name,
|
||||
label: credential.credential_name,
|
||||
})),
|
||||
]}
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
<FormField control={form.control} name="litellm_credential_name" label="Existing Credentials">
|
||||
{({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
"aria-invalid": ariaInvalid,
|
||||
"aria-describedby": ariaDescribedBy,
|
||||
}) => (
|
||||
<Combobox
|
||||
items={credentialOptions}
|
||||
value={credentialOptions.find((option) => option.value === value) ?? null}
|
||||
onValueChange={(option: CredentialOption | null) =>
|
||||
onChange(option ? option.value : undefined)
|
||||
}
|
||||
itemToStringLabel={(option: CredentialOption) => option.label}
|
||||
isItemEqualToValue={(option: CredentialOption, selected: CredentialOption) =>
|
||||
option.value === selected.value
|
||||
}
|
||||
>
|
||||
<ComboboxInput
|
||||
id={id}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
placeholder="Select or search for existing credentials"
|
||||
className="w-full"
|
||||
showClear={value !== undefined}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No matching credentials</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: CredentialOption) => (
|
||||
<ComboboxItem key={option.label} value={option}>
|
||||
{option.label}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<div className="flex items-center my-4">
|
||||
<div className="grow border-t border-gray-200"></div>
|
||||
<span className="px-4 text-gray-500 text-sm">OR</span>
|
||||
<div className="grow border-t border-gray-200"></div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="grow border-t border-border"></div>
|
||||
<span className="px-4 text-muted-foreground text-sm">OR</span>
|
||||
<div className="grow border-t border-border"></div>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Metadata{" "}
|
||||
<Tooltip title="JSON metadata for the vector store">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
value={metadataString}
|
||||
onChange={(e) => setMetadataString(e.target.value)}
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
</Form.Item>
|
||||
<div role="group" className="flex w-full flex-col gap-3">
|
||||
<span className="flex w-fit gap-2 text-sm leading-snug font-medium">
|
||||
{labelWithHint("Metadata", "JSON metadata for the vector store")}
|
||||
</span>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={metadataString}
|
||||
onChange={(event) => setMetadataString(event.target.value)}
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<AntButton onClick={() => setIsEditing(false)}>Cancel</AntButton>
|
||||
<AntButton type="primary" htmlType="submit">
|
||||
Save Changes
|
||||
</AntButton>
|
||||
</div>
|
||||
</Form>
|
||||
<div className="mt-6 flex justify-end space-x-2">
|
||||
<ShadcnButton type="button" variant="outline" onClick={() => setIsEditing(false)}>
|
||||
Cancel
|
||||
</ShadcnButton>
|
||||
<ShadcnButton type="submit">Save Changes</ShadcnButton>
|
||||
</div>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title>Vector Store Details</Title>
|
||||
{is_admin && <Button onClick={() => setIsEditing(true)}>Edit Vector Store</Button>}
|
||||
{is_admin && <Button onClick={startEditing}>Edit Vector Store</Button>}
|
||||
</div>
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
|
|
@ -308,7 +399,7 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Metadata</Text>
|
||||
<div className="bg-gray-50 p-3 rounded-sm mt-2 font-mono text-xs overflow-auto max-h-48">
|
||||
<div className="bg-muted p-3 rounded-sm mt-2 font-mono text-xs overflow-auto max-h-48">
|
||||
<pre>{metadataString}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -330,7 +421,6 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* Test Tab */}
|
||||
<TabPanel>
|
||||
<VectorStoreTester vectorStoreId={vectorStoreDetails.vector_store_id} accessToken={accessToken || ""} />
|
||||
</TabPanel>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export interface VectorStore {
|
|||
vector_store_name?: string;
|
||||
vector_store_description?: string;
|
||||
vector_store_metadata?: VectorStoreMetadata;
|
||||
litellm_credential_name?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
|
|
|
|||
37
ui/litellm-dashboard/src/lib/forms/antdUrl.test.ts
Normal file
37
ui/litellm-dashboard/src/lib/forms/antdUrl.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ANTD_URL_REGEX, isAntdUrl, MAX_ANTD_URL_LENGTH } from "./antdUrl";
|
||||
|
||||
const ASYNC_VALIDATOR_5_1_0_URL_SOURCE =
|
||||
'(?:^(?:(?:(?:[a-z]+:)?\\/\\/)|www\\.)(?:\\S+(?::\\S*)?@)?(?:localhost|(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?:(?:[a-fA-F\\d]{1,4}:){7}(?:[a-fA-F\\d]{1,4}|:)|(?:[a-fA-F\\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|:[a-fA-F\\d]{1,4}|:)|(?:[a-fA-F\\d]{1,4}:){5}(?::(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,2}|:)|(?:[a-fA-F\\d]{1,4}:){4}(?:(?::[a-fA-F\\d]{1,4}){0,1}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,3}|:)|(?:[a-fA-F\\d]{1,4}:){3}(?:(?::[a-fA-F\\d]{1,4}){0,2}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,4}|:)|(?:[a-fA-F\\d]{1,4}:){2}(?:(?::[a-fA-F\\d]{1,4}){0,3}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,5}|:)|(?:[a-fA-F\\d]{1,4}:){1}(?:(?::[a-fA-F\\d]{1,4}){0,4}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,6}|:)|(?::(?:(?::[a-fA-F\\d]{1,4}){0,5}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,7}|:)))(?:%[0-9a-zA-Z]{1,})?|(?:(?:[a-z\\u00a1-\\uffff0-9][-_]*)*[a-z\\u00a1-\\uffff0-9]+)(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))(?::\\d{2,5})?(?:[/?#][^\\s"]*)?$)';
|
||||
|
||||
describe("isAntdUrl", () => {
|
||||
it("compiles to the exact pattern async-validator 5.1.0 uses for rule type url", () => {
|
||||
expect(ANTD_URL_REGEX.source).toBe(ASYNC_VALIDATOR_5_1_0_URL_SOURCE);
|
||||
expect(ANTD_URL_REGEX.flags).toBe("i");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://guard.example.com/v1/check",
|
||||
"http://localhost:4000",
|
||||
"www.example.com",
|
||||
"//example.com",
|
||||
"https://127.0.0.1:8080/path?q=1",
|
||||
"https://user:pass@example.com",
|
||||
])("accepts %s the way antd does", (value) => {
|
||||
expect(isAntdUrl(value)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["example.com", "", "not a url", "https://", "ftp:/example.com", "http://exa mple.com"])(
|
||||
"rejects %s the way antd does",
|
||||
(value) => {
|
||||
expect(isAntdUrl(value)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a url longer than the 2048 characters antd allows", () => {
|
||||
const long = `https://example.com/${"a".repeat(MAX_ANTD_URL_LENGTH)}`;
|
||||
expect(ANTD_URL_REGEX.test(long)).toBe(true);
|
||||
expect(isAntdUrl(long)).toBe(false);
|
||||
});
|
||||
});
|
||||
29
ui/litellm-dashboard/src/lib/forms/antdUrl.ts
Normal file
29
ui/litellm-dashboard/src/lib/forms/antdUrl.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
const V4 = "(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}";
|
||||
const V6SEG = "[a-fA-F\\d]{1,4}";
|
||||
const V6 = `(?:${[
|
||||
`(?:${V6SEG}:){7}(?:${V6SEG}|:)`,
|
||||
`(?:${V6SEG}:){6}(?:${V4}|:${V6SEG}|:)`,
|
||||
`(?:${V6SEG}:){5}(?::${V4}|(?::${V6SEG}){1,2}|:)`,
|
||||
`(?:${V6SEG}:){4}(?:(?::${V6SEG}){0,1}:${V4}|(?::${V6SEG}){1,3}|:)`,
|
||||
`(?:${V6SEG}:){3}(?:(?::${V6SEG}){0,2}:${V4}|(?::${V6SEG}){1,4}|:)`,
|
||||
`(?:${V6SEG}:){2}(?:(?::${V6SEG}){0,3}:${V4}|(?::${V6SEG}){1,5}|:)`,
|
||||
`(?:${V6SEG}:){1}(?:(?::${V6SEG}){0,4}:${V4}|(?::${V6SEG}){1,6}|:)`,
|
||||
`(?::(?:(?::${V6SEG}){0,5}:${V4}|(?::${V6SEG}){1,7}|:))`,
|
||||
].join("|")})(?:%[0-9a-zA-Z]{1,})?`;
|
||||
|
||||
const PROTOCOL = "(?:(?:[a-z]+:)?//)";
|
||||
const AUTH = "(?:\\S+(?::\\S*)?@)?";
|
||||
const HOST = "(?:(?:[a-z\\u00a1-\\uffff0-9][-_]*)*[a-z\\u00a1-\\uffff0-9]+)";
|
||||
const DOMAIN = "(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*";
|
||||
const TLD = "(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))";
|
||||
const PORT = "(?::\\d{2,5})?";
|
||||
const PATH = '(?:[/?#][^\\s"]*)?';
|
||||
|
||||
export const ANTD_URL_REGEX = new RegExp(
|
||||
`(?:^(?:${PROTOCOL}|www\\.)${AUTH}(?:localhost|${V4}|${V6}|${HOST}${DOMAIN}${TLD})${PORT}${PATH}$)`,
|
||||
"i",
|
||||
);
|
||||
|
||||
export const MAX_ANTD_URL_LENGTH = 2048;
|
||||
|
||||
export const isAntdUrl = (value: string): boolean => value.length <= MAX_ANTD_URL_LENGTH && ANTD_URL_REGEX.test(value);
|
||||
Loading…
Add table
Reference in a new issue