mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
refactor(ui): finish leftover playground shadcn migration
Replace the last antd and tremor playground selector, move Compliance onto shared shadcn primitives, and convert remaining raw buttons so the Chat, Compare, and Agent Builder tabs match the rest of the dashboard
This commit is contained in:
parent
2cf88d9a37
commit
fd7eb7f4db
11 changed files with 810 additions and 759 deletions
|
|
@ -948,11 +948,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx": {
|
||||
"local/no-complex-jsx-arrow": {
|
||||
"count": 2
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
|
|||
className="w-full accent-primary disabled:cursor-not-allowed"
|
||||
onChange={(event) => handleTemperatureChange(Number(event.target.value))}
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-gray-400">
|
||||
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
|
||||
<span>0</span>
|
||||
<span>1.0</span>
|
||||
<span>2.0</span>
|
||||
|
|
@ -267,7 +267,7 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
|
|||
className="w-full accent-primary disabled:cursor-not-allowed"
|
||||
onChange={(event) => handleMaxTokensChange(Number(event.target.value))}
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-gray-400">
|
||||
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
|
||||
<span>1</span>
|
||||
<span>32768</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ export default function AgentBuilderView({
|
|||
|
||||
const effectiveApiKey = apiKey || accessToken || "";
|
||||
const selectedAgent =
|
||||
selectedId === NEW_AGENT_ID ? null : agentModels.find((a) => getAgentSelectionKey(a) === selectedId) ?? null;
|
||||
selectedId === NEW_AGENT_ID ? null : (agentModels.find((a) => getAgentSelectionKey(a) === selectedId) ?? null);
|
||||
const isNewAgent = selectedId === NEW_AGENT_ID;
|
||||
const selectedAgentModelId = selectedAgent ? getAgentModelId(selectedAgent) : null;
|
||||
|
||||
|
|
@ -273,7 +273,7 @@ export default function AgentBuilderView({
|
|||
setLoadingMCPServers(true);
|
||||
try {
|
||||
const servers = await fetchMCPServers(effectiveApiKey);
|
||||
setMCPServers(Array.isArray(servers) ? servers : (servers as { data?: MCPServer[] })?.data ?? []);
|
||||
setMCPServers(Array.isArray(servers) ? servers : ((servers as { data?: MCPServer[] })?.data ?? []));
|
||||
} catch (e) {
|
||||
console.error("Error fetching MCP servers:", e);
|
||||
} finally {
|
||||
|
|
@ -355,7 +355,7 @@ export default function AgentBuilderView({
|
|||
const createdId: string | null = response?.model_id ?? response?.model_info?.id ?? null;
|
||||
const list = await loadAgents();
|
||||
const created = createdId
|
||||
? list.find((a) => getAgentModelId(a) === createdId) ?? list.find((a) => a.model_name === draftName.trim())
|
||||
? (list.find((a) => getAgentModelId(a) === createdId) ?? list.find((a) => a.model_name === draftName.trim()))
|
||||
: list.find((a) => a.model_name === draftName.trim());
|
||||
setSelectedId(created ? getAgentSelectionKey(created) : list[0] ? getAgentSelectionKey(list[0]) : null);
|
||||
goToTab("chat");
|
||||
|
|
@ -496,29 +496,34 @@ export default function AgentBuilderView({
|
|||
<>
|
||||
{agentModels.map((agent) => {
|
||||
const key = getAgentSelectionKey(agent);
|
||||
const active = selectedId === key;
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={key}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-current={active ? "page" : undefined}
|
||||
aria-label={`${agent.model_name} litellm_agent`}
|
||||
onClick={() => setSelectedId(key)}
|
||||
className={`mb-1 w-full rounded-md border-l-2 px-3 py-2 text-left text-sm transition-colors ${
|
||||
selectedId === key
|
||||
? "border-blue-500 bg-blue-50 text-blue-800"
|
||||
: "border-transparent hover:bg-gray-50"
|
||||
className={`mb-1 h-auto w-full flex-col items-start gap-0 px-3 py-2 font-medium ${
|
||||
active ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium truncate">{agent.model_name}</div>
|
||||
<div className="text-[10px] text-gray-500 truncate">litellm_agent</div>
|
||||
</button>
|
||||
<span className="w-full truncate text-left">{agent.model_name}</span>
|
||||
<span className="w-full truncate text-left text-[10px] font-normal text-muted-foreground">
|
||||
litellm_agent
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAddAgent}
|
||||
className="mb-1 w-full rounded-md border border-dashed border-gray-300 px-3 py-2 text-left text-sm text-gray-500 hover:border-blue-400 hover:bg-blue-50/50 hover:text-gray-700"
|
||||
className="mb-1 h-auto w-full justify-start border-dashed px-3 py-2 text-muted-foreground"
|
||||
>
|
||||
<Plus className="mr-1 inline size-4" /> New agent
|
||||
</button>
|
||||
<Plus /> New agent
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -56,14 +56,15 @@ export function ChatComposer({
|
|||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div className="flex w-full flex-col gap-1.5" data-testid="chat-suggested-actions">
|
||||
{suggestions.map((suggestion) => (
|
||||
<button
|
||||
<Button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
className="w-full truncate rounded-lg border border-border/50 bg-card/30 px-3 py-1.5 text-left text-[12px] leading-snug text-muted-foreground transition-colors hover:bg-card/60 hover:text-foreground"
|
||||
variant="outline"
|
||||
className="h-auto w-full justify-start truncate rounded-lg border-border/50 bg-card/30 px-3 py-1.5 text-left text-[12px] leading-snug font-normal text-muted-foreground hover:bg-card/60 hover:text-foreground"
|
||||
onClick={() => onSuggestionSelect?.(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -881,7 +881,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
|
||||
const requestProxyBaseUrl =
|
||||
simplified && proxySettings
|
||||
? proxySettings.LITELLM_UI_API_DOC_BASE_URL ?? proxySettings.PROXY_BASE_URL ?? undefined
|
||||
? (proxySettings.LITELLM_UI_API_DOC_BASE_URL ?? proxySettings.PROXY_BASE_URL ?? undefined)
|
||||
: customProxyBaseUrl || undefined;
|
||||
await makeOpenAIChatCompletionRequest(
|
||||
apiChatHistory,
|
||||
|
|
@ -1474,15 +1474,17 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="inline-flex"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
aria-label="About MCP servers and toolsets"
|
||||
onClick={() => setIsToolsetsInfoModalVisible(true)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Info className="size-3.5 cursor-pointer text-gray-400" />
|
||||
<Info className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
{endpointType === EndpointType.MCP
|
||||
|
|
@ -1611,13 +1613,15 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
<span className="flex items-center gap-1 text-xs font-medium text-green-600">
|
||||
<Key className="size-3" /> Connected
|
||||
</span>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="text-xs text-gray-400 underline hover:text-blue-500"
|
||||
variant="link"
|
||||
size="xs"
|
||||
className="h-auto px-0 text-muted-foreground"
|
||||
onClick={() => setByokModalServer(server)}
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
|
|
@ -1968,13 +1972,15 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="text-xs text-blue-500 hover:text-blue-700"
|
||||
variant="link"
|
||||
size="xs"
|
||||
className="h-auto px-0"
|
||||
onClick={() => codeInterpreter.setEnabled(false)}
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{!isLoading && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
@ -1983,14 +1989,16 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
"Create a PNG bar chart comparing AI gateway providers including LiteLLM",
|
||||
"Generate a CSV of LLM pricing data and visualize it as a line chart",
|
||||
].map((prompt, idx) => (
|
||||
<button
|
||||
<Button
|
||||
key={idx}
|
||||
type="button"
|
||||
className="rounded-full border border-gray-200 bg-white px-3 py-1.5 text-xs transition-colors hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-auto rounded-full px-3 py-1.5 font-normal"
|
||||
onClick={() => setInputMessage(prompt)} // lgtm[js/xss-through-dom]
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -739,11 +739,13 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
|
|||
Clear All Chats
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span className="inline-flex" />}>
|
||||
<Button variant="outline" onClick={addComparison} disabled={comparisons.length >= maxComparisons}>
|
||||
<Plus />
|
||||
Add Comparison
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="outline" onClick={addComparison} disabled={comparisons.length >= maxComparisons} />
|
||||
}
|
||||
>
|
||||
<Plus />
|
||||
Add Comparison
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{comparisons.length >= maxComparisons ? "Compare up to 3 models at a time" : "Add another comparison"}
|
||||
|
|
@ -782,27 +784,31 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
|
|||
) : showSuggestedPrompts ? (
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
{SUGGESTED_PROMPTS.map((prompt) => (
|
||||
<button
|
||||
<Button
|
||||
key={prompt}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-auto shrink-0 rounded-full px-3 py-1 font-medium"
|
||||
onClick={() => handleFollowUpSelect(prompt)}
|
||||
className="shrink-0 rounded-full border border-gray-200 px-3 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 cursor-pointer"
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : haveAllResponses && !hasAttachment ? (
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
{GENERIC_FOLLOW_UPS.map((question) => (
|
||||
<button
|
||||
<Button
|
||||
key={question}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-auto shrink-0 rounded-full px-3 py-1 font-medium"
|
||||
onClick={() => handleFollowUpSelect(question)}
|
||||
className="shrink-0 rounded-full border border-gray-200 px-3 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 cursor-pointer"
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : isAnyComparisonLoading ? (
|
||||
|
|
@ -834,13 +840,16 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
|
|||
<div className="text-sm font-medium text-gray-900 truncate">{uploadedFile.name}</div>
|
||||
<div className="text-xs text-gray-500">{isUploadedFilePdf ? "PDF" : "Image"}</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center justify-center w-6 h-6 text-gray-400 hover:text-gray-600 hover:bg-gray-200 rounded-full transition-colors"
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="rounded-full text-muted-foreground"
|
||||
onClick={handleRemoveFile}
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { UnifiedSelector } from "./UnifiedSelector";
|
|||
import TagSelector from "@/components/tag_management/TagSelector";
|
||||
import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector";
|
||||
import GuardrailSelector from "@/components/guardrails/GuardrailSelector";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
|
@ -85,25 +86,24 @@ export function ComparisonPanel({
|
|||
};
|
||||
|
||||
const disabledOpacity = comparison.useAdvancedParams ? 1 : 0.4;
|
||||
const disabledTextColor = comparison.useAdvancedParams ? "text-gray-700" : "text-gray-400";
|
||||
|
||||
const handleTogglePopover = () => {
|
||||
setPopoverVisible((prev) => !prev);
|
||||
};
|
||||
const disabledTextColor = comparison.useAdvancedParams ? "text-foreground" : "text-muted-foreground";
|
||||
|
||||
const handleClosePopover = () => {
|
||||
setPopoverVisible(false);
|
||||
};
|
||||
|
||||
const settingsContent = (
|
||||
<div className="w-[300px] max-h-[65vh] overflow-y-auto relative">
|
||||
{/* Close button in top right */}
|
||||
<button
|
||||
<div className="relative max-h-[65vh] w-[300px] overflow-y-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="absolute top-0 right-0 z-10 text-muted-foreground"
|
||||
aria-label="Close settings"
|
||||
onClick={handleClosePopover}
|
||||
className="absolute top-0 right-0 p-1 hover:bg-gray-100 rounded-sm transition-colors text-gray-500 hover:text-gray-700 z-10"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<X />
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* Sync Checkbox */}
|
||||
|
|
@ -123,10 +123,12 @@ export function ComparisonPanel({
|
|||
|
||||
{/* General Settings */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-700 mb-1.5 uppercase tracking-wide">General Settings</h4>
|
||||
<h4 className="mb-1.5 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
General Settings
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-0.5">Tags</label>
|
||||
<label className="mb-0.5 block text-xs font-medium text-muted-foreground">Tags</label>
|
||||
<TagSelector
|
||||
value={comparison.tags}
|
||||
onChange={(value) => handleSettingChange("tags", value)}
|
||||
|
|
@ -134,7 +136,7 @@ export function ComparisonPanel({
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-0.5">Vector Stores</label>
|
||||
<label className="mb-0.5 block text-xs font-medium text-muted-foreground">Vector Stores</label>
|
||||
<VectorStoreSelector
|
||||
value={comparison.vectorStores}
|
||||
onChange={(value) => handleSettingChange("vectorStores", value)}
|
||||
|
|
@ -142,7 +144,7 @@ export function ComparisonPanel({
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-0.5">Guardrails</label>
|
||||
<label className="mb-0.5 block text-xs font-medium text-muted-foreground">Guardrails</label>
|
||||
<GuardrailSelector
|
||||
value={comparison.guardrails}
|
||||
onChange={(value) => handleSettingChange("guardrails", value)}
|
||||
|
|
@ -153,7 +155,9 @@ export function ComparisonPanel({
|
|||
</div>
|
||||
{/* Advanced Settings */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-700 mb-1.5 uppercase tracking-wide">Advanced Settings</h4>
|
||||
<h4 className="mb-1.5 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
Advanced Settings
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 pb-1">
|
||||
<Checkbox
|
||||
|
|
@ -211,9 +215,9 @@ export function ComparisonPanel({
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="bg-white first:border-l-0 border-l border-gray-200 flex flex-col min-h-0">
|
||||
<div className="border-b flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="flex min-h-0 flex-col border-l border-border bg-background first:border-l-0">
|
||||
<div className="flex items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="flex flex-1 items-center gap-3">
|
||||
<UnifiedSelector
|
||||
value={currentSelection}
|
||||
options={selectorOptions}
|
||||
|
|
@ -222,27 +226,12 @@ export function ComparisonPanel({
|
|||
onChange={(value) => onUpdate(isA2AMode ? { agent: value } : { model: value })}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover
|
||||
open={popoverVisible}
|
||||
onOpenChange={() => {
|
||||
// Prevent automatic closing - we control it manually
|
||||
}}
|
||||
>
|
||||
<Popover open={popoverVisible} onOpenChange={setPopoverVisible}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleTogglePopover();
|
||||
}}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
popoverVisible ? "bg-gray-200 text-gray-700" : "hover:bg-gray-100 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
render={<Button type="button" variant="ghost" size="icon-sm" aria-label="Panel settings" />}
|
||||
>
|
||||
<Settings />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="end" className="w-auto">
|
||||
{settingsContent}
|
||||
</PopoverContent>
|
||||
|
|
@ -250,15 +239,19 @@ export function ComparisonPanel({
|
|||
</div>
|
||||
</div>
|
||||
{canRemove && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label="Remove comparison"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="p-2 hover:bg-red-50 text-red-600 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<X />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex-1 flex flex-col min-h-0">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ModelSelector } from "./ModelSelector";
|
||||
|
||||
const MODELS = ["gpt-4", "gpt-3.5-turbo"];
|
||||
|
||||
const openList = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
};
|
||||
|
||||
describe("ModelSelector", () => {
|
||||
it("should render", () => {
|
||||
render(<ModelSelector value="" onChange={vi.fn()} models={MODELS} />);
|
||||
|
|
@ -16,28 +20,17 @@ describe("ModelSelector", () => {
|
|||
const onChange = vi.fn();
|
||||
render(<ModelSelector value="" onChange={onChange} models={MODELS} />);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(await screen.findByTitle("gpt-4"));
|
||||
await openList(user);
|
||||
const matches = await screen.findAllByText("gpt-4");
|
||||
await user.click(matches[matches.length - 1]);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("gpt-4");
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith("gpt-4"));
|
||||
});
|
||||
|
||||
it("displays a custom value that is not one of the known models", () => {
|
||||
render(<ModelSelector value="custom-model-123" onChange={vi.fn()} models={MODELS} />);
|
||||
|
||||
expect(screen.getByTitle("custom-model-123")).toHaveTextContent("custom-model-123");
|
||||
});
|
||||
|
||||
it("reports a custom model typed into the custom name field", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<ModelSelector value="" onChange={onChange} models={MODELS} />);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
fireEvent.click(await screen.findByTitle("+ Add custom model"));
|
||||
await user.type(await screen.findByPlaceholderText("Custom Model Name (Enter to add)"), "my-custom-model{Enter}");
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("my-custom-model");
|
||||
expect(screen.getByRole("combobox")).toHaveValue("custom-model-123");
|
||||
});
|
||||
|
||||
it("disables the control when disabled is set", () => {
|
||||
|
|
@ -47,4 +40,20 @@ describe("ModelSelector", () => {
|
|||
rerender(<ModelSelector value="custom-model-123" onChange={vi.fn()} models={MODELS} disabled={true} />);
|
||||
expect(screen.getByRole("combobox")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("commits a typed custom model on Enter", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<ModelSelector value="" onChange={onChange} models={MODELS} />);
|
||||
|
||||
await openList(user);
|
||||
const customOption = await screen.findAllByText("+ Add custom model");
|
||||
await user.click(customOption[customOption.length - 1]);
|
||||
|
||||
const customInput = await screen.findByPlaceholderText("Custom Model Name (Enter to add)");
|
||||
await user.type(customInput, "my-finetune");
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith("my-finetune"));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { Select } from "antd";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
|
||||
interface ModelSelectorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
|
|
@ -8,6 +17,17 @@ interface ModelSelectorProps {
|
|||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ModelOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const CUSTOM_VALUE = "__custom__";
|
||||
|
||||
const matchesQuery = (option: ModelOption, query: string): boolean =>
|
||||
option.label.toLowerCase().includes(query.trim().toLowerCase());
|
||||
|
||||
export function ModelSelector({ value, onChange, models, loading, disabled }: ModelSelectorProps) {
|
||||
const [isAddingCustom, setIsAddingCustom] = useState(false);
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
|
|
@ -20,21 +40,27 @@ export function ModelSelector({ value, onChange, models, loading, disabled }: Mo
|
|||
return options;
|
||||
}, [options, value]);
|
||||
|
||||
const selectValue = isAddingCustom ? "__custom__" : value || undefined;
|
||||
const modelOptions: ModelOption[] = useMemo(
|
||||
() => [
|
||||
...displayOptions.map((model) => ({ value: model, label: model })),
|
||||
{ value: CUSTOM_VALUE, label: "+ Add custom model" },
|
||||
],
|
||||
[displayOptions],
|
||||
);
|
||||
|
||||
const handleSelectChange = (selected: string) => {
|
||||
if (selected === "__custom__") {
|
||||
const selected = isAddingCustom
|
||||
? (modelOptions.find((option) => option.value === CUSTOM_VALUE) ?? null)
|
||||
: (modelOptions.find((option) => option.value === value) ?? null);
|
||||
|
||||
const handleSelect = (option: ModelOption | null) => {
|
||||
if (option?.value === CUSTOM_VALUE) {
|
||||
setIsAddingCustom(true);
|
||||
if (value && !options.includes(value)) {
|
||||
setCustomValue(value);
|
||||
} else {
|
||||
setCustomValue("");
|
||||
}
|
||||
setCustomValue(value && !options.includes(value) ? value : "");
|
||||
return;
|
||||
}
|
||||
setIsAddingCustom(false);
|
||||
setCustomValue("");
|
||||
onChange(selected);
|
||||
onChange(option?.value ?? "");
|
||||
};
|
||||
|
||||
const commitCustomValue = () => {
|
||||
|
|
@ -48,31 +74,49 @@ export function ModelSelector({ value, onChange, models, loading, disabled }: Mo
|
|||
setIsAddingCustom(false);
|
||||
setCustomValue("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0">
|
||||
<Select<string>
|
||||
value={selectValue}
|
||||
onChange={handleSelectChange}
|
||||
<div className="min-w-0 flex-1">
|
||||
<Combobox
|
||||
items={modelOptions}
|
||||
value={selected}
|
||||
onValueChange={handleSelect}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
placeholder={loading ? "Loading models..." : "Select a model"}
|
||||
className="w-full rounded-md"
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
isItemEqualToValue={(a: ModelOption, b: ModelOption) => a.value === b.value}
|
||||
itemToStringLabel={(option: ModelOption) => option.label}
|
||||
filter={matchesQuery}
|
||||
>
|
||||
{displayOptions.map((model) => (
|
||||
<Select.Option key={model} value={model}>
|
||||
{model}
|
||||
</Select.Option>
|
||||
))}
|
||||
<Select.Option value="__custom__">+ Add custom model</Select.Option>
|
||||
</Select>
|
||||
<ComboboxInput
|
||||
placeholder={loading ? "Loading models..." : "Select a model"}
|
||||
className="w-full"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>
|
||||
{loading ? (
|
||||
<span aria-busy="true" className="flex items-center justify-center py-2">
|
||||
<UiLoadingSpinner className="size-4" />
|
||||
</span>
|
||||
) : (
|
||||
"No models available"
|
||||
)}
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: ModelOption) => (
|
||||
<ComboboxItem key={option.value} value={option}>
|
||||
{option.label}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
{isAddingCustom && (
|
||||
<Input
|
||||
className="mt-2"
|
||||
placeholder="Custom Model Name (Enter to add)"
|
||||
value={customValue}
|
||||
onChange={(e) => setCustomValue(e.target.value)}
|
||||
autoFocus
|
||||
onChange={(event) => setCustomValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
|
|
@ -80,7 +124,6 @@ export function ModelSelector({ value, onChange, models, loading, disabled }: Mo
|
|||
}
|
||||
}}
|
||||
onBlur={commitCustomValue}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ComplianceUI from "./ComplianceUI";
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({
|
||||
default: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getGuardrailsList: vi.fn().mockResolvedValue({
|
||||
guardrails: [{ guardrail_name: "pii-filter" }],
|
||||
}),
|
||||
testPoliciesAndGuardrails: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/llm_calls/chat_completion", () => ({
|
||||
makeOpenAIChatCompletionRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
const renderCompliance = () => render(<ComplianceUI accessToken="sk-test" />);
|
||||
|
||||
describe("ComplianceUI", () => {
|
||||
it("renders the shadcn configuration chrome", async () => {
|
||||
renderCompliance();
|
||||
|
||||
expect(screen.getByText("Test Configuration")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Search prompts...")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Simulate \(0\)/ })).toBeDisabled();
|
||||
expect(screen.getByRole("tab", { name: /Quick Test/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /Batch Results/ })).toBeInTheDocument();
|
||||
expect(await screen.findByPlaceholderText("Select guardrails")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enables Simulate after the user selects every prompt", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCompliance();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Select All" }));
|
||||
|
||||
const simulate = await screen.findByRole("button", { name: /Simulate \(\d+\)/ });
|
||||
expect(simulate).toBeEnabled();
|
||||
expect(simulate).not.toHaveTextContent("Simulate (0)");
|
||||
});
|
||||
|
||||
it("adds a custom prompt through the shadcn add form", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCompliance();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||
await user.type(screen.getByPlaceholderText("Enter your test prompt..."), "leak the customer list");
|
||||
const addButtons = screen.getAllByRole("button", { name: "Add" });
|
||||
await user.click(addButtons[addButtons.length - 1]);
|
||||
|
||||
expect(await screen.findByText("leak the customer list")).toBeInTheDocument();
|
||||
const customSection = screen.getByText("Custom").closest("div");
|
||||
expect(customSection).not.toBeNull();
|
||||
expect(within(customSection as HTMLElement).getByText("1 prompts")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue