mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge pull request #36847 from BerriAI/litellm_/awesome-shirley-6a1a86
refactor(ui): migrate playground to shadcn
This commit is contained in:
commit
2913ee811c
14 changed files with 1385 additions and 573 deletions
|
|
@ -1052,9 +1052,6 @@
|
|||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 5
|
||||
}
|
||||
|
|
@ -1095,9 +1092,6 @@
|
|||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 2
|
||||
},
|
||||
|
|
@ -1112,9 +1106,6 @@
|
|||
"no-nested-ternary": {
|
||||
"count": 4
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -1122,9 +1113,6 @@
|
|||
"src/app/(dashboard)/playground/components/compareUI/components/ComparisonPanel.tsx": {
|
||||
"local/no-complex-jsx-arrow": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/compareUI/components/MessageDisplay.tsx": {
|
||||
|
|
@ -1132,21 +1120,11 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/compareUI/components/MessageInput.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/compareUI/components/UnifiedSelector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx": {
|
||||
"local/no-complex-jsx-arrow": {
|
||||
"count": 2
|
||||
|
|
@ -3531,6 +3509,14 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/ui/slider.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/ui/switch.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -0,0 +1,320 @@
|
|||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AgentBuilderView from "./AgentBuilderView";
|
||||
import type { AgentModel } from "../../llm_calls/fetch_agents";
|
||||
|
||||
const modelCreateCall = vi.fn().mockResolvedValue({ model_id: "id-new" });
|
||||
const modelPatchUpdateCall = vi.fn().mockResolvedValue({});
|
||||
const modelDeleteCall = vi.fn().mockResolvedValue({});
|
||||
const keyCreateCall = vi.fn().mockResolvedValue({ key: "sk-agent-key" });
|
||||
const fetchMCPServers = vi.fn().mockResolvedValue([]);
|
||||
const fetchAvailableAgentModels = vi.fn();
|
||||
const fetchAvailableModels = vi.fn().mockResolvedValue([{ model_group: "gpt-4o" }, { model_group: "claude-sonnet-4" }]);
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
proxyBaseUrl: "https://proxy.example.com",
|
||||
modelCreateCall: (...args: unknown[]) => modelCreateCall(...args),
|
||||
modelPatchUpdateCall: (...args: unknown[]) => modelPatchUpdateCall(...args),
|
||||
modelDeleteCall: (...args: unknown[]) => modelDeleteCall(...args),
|
||||
keyCreateCall: (...args: unknown[]) => keyCreateCall(...args),
|
||||
fetchMCPServers: (...args: unknown[]) => fetchMCPServers(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../llm_calls/fetch_agents", () => ({
|
||||
fetchAvailableAgentModels: (...args: unknown[]) => fetchAvailableAgentModels(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/llm_calls/fetch_models", () => ({
|
||||
fetchAvailableModels: (...args: unknown[]) => fetchAvailableModels(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/CodeBlock", () => ({
|
||||
default: ({ code }: { code: string }) => <pre data-testid="code-block">{code}</pre>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/molecules/notifications_manager", () => ({
|
||||
default: { success: vi.fn(), fromBackend: vi.fn() },
|
||||
}));
|
||||
|
||||
const StatefulPanel = ({ label }: { label: string }) => {
|
||||
const [draft, setDraft] = useState("");
|
||||
return <input aria-label={label} value={draft} onChange={(event) => setDraft(event.target.value)} />;
|
||||
};
|
||||
|
||||
vi.mock("./ChatUI", () => ({
|
||||
default: () => <StatefulPanel label="chat scratch" />,
|
||||
}));
|
||||
|
||||
vi.mock("../complianceUI/ComplianceUI", () => ({
|
||||
default: () => <StatefulPanel label="batch scratch" />,
|
||||
}));
|
||||
|
||||
const AGENTS: AgentModel[] = [
|
||||
{
|
||||
model_name: "support-agent",
|
||||
litellm_params: { model: "litellm_agent/gpt-4o", litellm_system_prompt: "Be helpful.", temperature: 0.3 },
|
||||
model_info: { id: "agent-1" },
|
||||
},
|
||||
{
|
||||
model_name: "research-agent",
|
||||
litellm_params: { model: "litellm_agent/claude-sonnet-4" },
|
||||
model_info: { id: "agent-2" },
|
||||
},
|
||||
];
|
||||
|
||||
const props = {
|
||||
accessToken: "sk-access",
|
||||
token: "tok",
|
||||
userID: "u1",
|
||||
userRole: "Admin",
|
||||
};
|
||||
|
||||
const controlUnder = (label: string): HTMLElement =>
|
||||
within(screen.getByText(label).parentElement!).getByRole("combobox");
|
||||
|
||||
const renderView = () => render(<AgentBuilderView {...props} />);
|
||||
|
||||
const waitForRoster = async () => {
|
||||
await screen.findByRole("button", { name: "support-agent litellm_agent" });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchAvailableAgentModels.mockResolvedValue(AGENTS);
|
||||
fetchAvailableModels.mockResolvedValue([{ model_group: "gpt-4o" }, { model_group: "claude-sonnet-4" }]);
|
||||
fetchMCPServers.mockResolvedValue([]);
|
||||
modelCreateCall.mockResolvedValue({ model_id: "id-new" });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentBuilderView", () => {
|
||||
it("asks the visitor to sign in when there is no session", () => {
|
||||
render(<AgentBuilderView accessToken={null} token={null} userID={null} userRole={null} />);
|
||||
|
||||
expect(screen.getByText("Sign in to use Agent Builder.")).toBeInTheDocument();
|
||||
expect(fetchAvailableAgentModels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks itself busy while the roster loads", async () => {
|
||||
let release: (agents: AgentModel[]) => void = () => {};
|
||||
fetchAvailableAgentModels.mockReturnValue(
|
||||
new Promise<AgentModel[]>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
renderView();
|
||||
|
||||
expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument();
|
||||
|
||||
release(AGENTS);
|
||||
await waitForRoster();
|
||||
expect(document.querySelector('[aria-busy="true"]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists every agent and opens the first one's configuration", async () => {
|
||||
renderView();
|
||||
await waitForRoster();
|
||||
|
||||
expect(screen.getByRole("button", { name: "research-agent litellm_agent" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Agent Builder")).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByDisplayValue("support-agent")).toBeInTheDocument());
|
||||
expect(screen.getByDisplayValue("Be helpful.")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("0.3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads the configuration of whichever agent is picked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderView();
|
||||
await waitForRoster();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "research-agent litellm_agent" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue("research-agent")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("offers a blank draft and a save control for a new agent", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderView();
|
||||
await waitForRoster();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /New agent/i }));
|
||||
|
||||
expect(screen.getByRole("button", { name: /Save Agent/i })).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("You are a helpful assistant.")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Update Agent/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates the agent under the litellm_agent prefix", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderView();
|
||||
await waitForRoster();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /New agent/i }));
|
||||
await user.type(screen.getByPlaceholderText("My Agent"), "billing-agent");
|
||||
await user.click(screen.getByRole("button", { name: /Save Agent/i }));
|
||||
|
||||
await waitFor(() => expect(modelCreateCall).toHaveBeenCalled());
|
||||
const payload = modelCreateCall.mock.calls[0][1];
|
||||
expect(payload.model_name).toBe("billing-agent");
|
||||
expect(payload.litellm_params.model).toBe("litellm_agent/gpt-4o");
|
||||
});
|
||||
|
||||
it("will not save a draft without a name", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderView();
|
||||
await waitForRoster();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /New agent/i }));
|
||||
await user.click(screen.getByRole("button", { name: /Save Agent/i }));
|
||||
|
||||
expect(modelCreateCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates the selected agent through its model id", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderView();
|
||||
await waitForRoster();
|
||||
await screen.findByDisplayValue("support-agent");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Update Agent/i }));
|
||||
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(modelPatchUpdateCall.mock.calls[0][2]).toBe("agent-1");
|
||||
});
|
||||
|
||||
it("deletes only after the warning is confirmed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderView();
|
||||
await waitForRoster();
|
||||
await screen.findByDisplayValue("support-agent");
|
||||
|
||||
await user.click(screen.getAllByRole("button", { name: /Delete$/ })[0]);
|
||||
|
||||
expect(await screen.findByText(/Are you sure you want to delete "support-agent"/)).toBeInTheDocument();
|
||||
expect(modelDeleteCall).not.toHaveBeenCalled();
|
||||
|
||||
const confirmations = screen.getAllByRole("button", { name: /Delete$/ });
|
||||
await user.click(confirmations[confirmations.length - 1]);
|
||||
|
||||
await waitFor(() => expect(modelDeleteCall).toHaveBeenCalledWith("sk-access", "agent-1"));
|
||||
});
|
||||
|
||||
it("abandons the delete when the warning is dismissed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderView();
|
||||
await waitForRoster();
|
||||
await screen.findByDisplayValue("support-agent");
|
||||
|
||||
await user.click(screen.getAllByRole("button", { name: /Delete$/ })[0]);
|
||||
await screen.findByText(/Are you sure you want to delete "support-agent"/);
|
||||
await user.click(screen.getAllByRole("button", { name: /Cancel$/ }).at(-1)!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(/Are you sure you want to delete "support-agent"/)).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(modelDeleteCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a ready-to-run curl example on the Connect tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderView();
|
||||
await waitForRoster();
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: /Connect/i }));
|
||||
|
||||
const snippet = await screen.findByTestId("code-block");
|
||||
expect(snippet).toHaveTextContent("https://proxy.example.com/v1/chat/completions");
|
||||
expect(snippet).toHaveTextContent('"model": "support-agent"');
|
||||
});
|
||||
|
||||
it("mints a key scoped to the selected agent", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderView();
|
||||
await waitForRoster();
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: /Connect/i }));
|
||||
await user.click(await screen.findByRole("button", { name: /Create key for this agent/i }));
|
||||
|
||||
await waitFor(() => expect(keyCreateCall).toHaveBeenCalled());
|
||||
expect(keyCreateCall.mock.calls[0][2].models).toEqual(["support-agent"]);
|
||||
expect(await screen.findByTestId("code-block")).toHaveTextContent("Bearer sk-agent-key");
|
||||
});
|
||||
|
||||
it("keeps a tab's own state alive while the user works in another tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderView();
|
||||
await waitForRoster();
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: /Chat/i }));
|
||||
const scratch = await screen.findByLabelText("chat scratch");
|
||||
await user.type(scratch, "half a thought");
|
||||
expect(scratch).toHaveValue("half a thought");
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: /Configure/i }));
|
||||
await screen.findByDisplayValue("support-agent");
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: /Chat/i }));
|
||||
expect(await screen.findByLabelText("chat scratch")).toHaveValue("half a thought");
|
||||
});
|
||||
|
||||
it("keeps the batch tab's state alive across a round trip too", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderView();
|
||||
await waitForRoster();
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: /Batch Test/i }));
|
||||
await user.type(await screen.findByLabelText("batch scratch"), "seven cases");
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: /Connect/i }));
|
||||
await screen.findByTestId("code-block");
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: /Batch Test/i }));
|
||||
expect(await screen.findByLabelText("batch scratch")).toHaveValue("seven cases");
|
||||
});
|
||||
|
||||
it("attaches the MCP servers the agent should reach", async () => {
|
||||
const user = userEvent.setup();
|
||||
fetchMCPServers.mockResolvedValue([{ server_id: "srv-1", alias: "github", server_name: "github-mcp" }]);
|
||||
renderView();
|
||||
await waitForRoster();
|
||||
await screen.findByDisplayValue("support-agent");
|
||||
|
||||
await user.click(controlUnder("MCP servers"));
|
||||
const options = await screen.findAllByText("github");
|
||||
await user.click(options[options.length - 1]);
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Update Agent/i }));
|
||||
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(modelPatchUpdateCall.mock.calls[0][1].litellm_params.tools).toEqual([
|
||||
{ type: "mcp", server_label: "litellm", server_url: "litellm_proxy/mcp/github", require_approval: "never" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("warns that the builder is experimental", async () => {
|
||||
renderView();
|
||||
await waitForRoster();
|
||||
|
||||
expect(screen.getByText(/Agent Builder is experimental/)).toBeInTheDocument();
|
||||
expect(within(screen.getByText(/Agent Builder is experimental/)).getByRole("link")).toHaveAttribute(
|
||||
"href",
|
||||
"mailto:product@berri.ai",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,16 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
CommentOutlined,
|
||||
DeleteOutlined,
|
||||
ExperimentOutlined,
|
||||
LinkOutlined,
|
||||
PlusOutlined,
|
||||
RobotOutlined,
|
||||
SaveOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Input, Modal, Select, Spin, Tabs } from "antd";
|
||||
import { Bot, FlaskConical, Link as LinkIcon, MessageSquare, Plus, Save, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { useVisitedTabs } from "@/hooks/useVisitedTabs";
|
||||
import CodeBlock from "@/components/CodeBlock";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import {
|
||||
|
|
@ -27,8 +35,6 @@ import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_m
|
|||
import ComplianceUI from "../complianceUI/ComplianceUI";
|
||||
import ChatUI from "./ChatUI";
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export interface AgentBuilderViewProps {
|
||||
accessToken: string | null;
|
||||
token: string | null;
|
||||
|
|
@ -45,6 +51,8 @@ export interface AgentBuilderViewProps {
|
|||
|
||||
const NEW_AGENT_ID = "__new__";
|
||||
|
||||
type AgentTab = "configure" | "chat" | "test" | "connect";
|
||||
|
||||
function getConnectTabBaseUrl(
|
||||
proxySettings: AgentBuilderViewProps["proxySettings"],
|
||||
customProxyBaseUrl?: string,
|
||||
|
|
@ -116,7 +124,7 @@ function ConnectTabContent({
|
|||
Create a virtual key that can only call this agent. The key will be scoped to you (user_id) and restricted to
|
||||
the model <span className="font-mono text-gray-800">{agentName}</span>.
|
||||
</p>
|
||||
<Button type="primary" onClick={onCreateKey} loading={creatingKey} disabled={disabledPersonalKeyCreation}>
|
||||
<Button onClick={onCreateKey} disabled={creatingKey || disabledPersonalKeyCreation}>
|
||||
Create key for this agent
|
||||
</Button>
|
||||
{disabledPersonalKeyCreation && (
|
||||
|
|
@ -190,7 +198,12 @@ export default function AgentBuilderView({
|
|||
const [modelGroups, setModelGroups] = useState<ModelGroup[]>([]);
|
||||
const [loadingAgents, setLoadingAgents] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"configure" | "chat" | "test" | "connect">("configure");
|
||||
const [activeTab, setActiveTab] = useState<AgentTab>("configure");
|
||||
const { onTabChange, hasVisited } = useVisitedTabs("configure");
|
||||
const goToTab = (tab: AgentTab) => {
|
||||
setActiveTab(tab);
|
||||
onTabChange(tab);
|
||||
};
|
||||
const [creatingKey, setCreatingKey] = useState(false);
|
||||
const [createdKeyValue, setCreatedKeyValue] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -207,6 +220,7 @@ export default function AgentBuilderView({
|
|||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
|
||||
const effectiveApiKey = apiKey || accessToken || "";
|
||||
const selectedAgent =
|
||||
|
|
@ -314,7 +328,7 @@ export default function AgentBuilderView({
|
|||
setDraftTemperature(0.7);
|
||||
setDraftMaxTokens(4096);
|
||||
setDraftTools([]);
|
||||
setActiveTab("configure");
|
||||
goToTab("configure");
|
||||
};
|
||||
|
||||
const handleSaveAgent = async () => {
|
||||
|
|
@ -344,7 +358,7 @@ export default function AgentBuilderView({
|
|||
? 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);
|
||||
setActiveTab("chat");
|
||||
goToTab("chat");
|
||||
} catch (e) {
|
||||
NotificationsManager.fromBackend("Failed to save agent");
|
||||
} finally {
|
||||
|
|
@ -411,27 +425,24 @@ export default function AgentBuilderView({
|
|||
|
||||
const handleDeleteAgent = () => {
|
||||
if (!selectedAgent || !selectedAgentModelId || !accessToken) return;
|
||||
Modal.confirm({
|
||||
title: "Delete agent",
|
||||
content: `Are you sure you want to delete "${selectedAgent.model_name}"? This cannot be undone.`,
|
||||
okText: "Delete",
|
||||
okType: "danger",
|
||||
cancelText: "Cancel",
|
||||
onOk: async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await modelDeleteCall(accessToken, selectedAgentModelId);
|
||||
NotificationsManager.success("Agent deleted");
|
||||
const list = await loadAgents();
|
||||
const remaining = list.filter((a) => getAgentModelId(a) !== selectedAgentModelId);
|
||||
setSelectedId(remaining.length > 0 ? getAgentSelectionKey(remaining[0]) : null);
|
||||
} catch (e) {
|
||||
NotificationsManager.fromBackend("Failed to delete agent");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
setConfirmingDelete(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!selectedAgent || !selectedAgentModelId || !accessToken) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await modelDeleteCall(accessToken, selectedAgentModelId);
|
||||
NotificationsManager.success("Agent deleted");
|
||||
const list = await loadAgents();
|
||||
const remaining = list.filter((a) => getAgentModelId(a) !== selectedAgentModelId);
|
||||
setSelectedId(remaining.length > 0 ? getAgentSelectionKey(remaining[0]) : null);
|
||||
} catch (e) {
|
||||
NotificationsManager.fromBackend("Failed to delete agent");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setConfirmingDelete(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!accessToken || !userID || !userRole) {
|
||||
|
|
@ -446,13 +457,8 @@ export default function AgentBuilderView({
|
|||
<div className="flex h-12 items-center justify-between px-4">
|
||||
<span className="text-sm font-medium text-gray-900">Agent Builder</span>
|
||||
{isNewAgent ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSaveAgent}
|
||||
loading={saving}
|
||||
disabled={!draftName?.trim() || !draftUnderlyingModel}
|
||||
>
|
||||
<Button onClick={handleSaveAgent} disabled={saving || !draftName?.trim() || !draftUnderlyingModel}>
|
||||
<Save />
|
||||
Save Agent
|
||||
</Button>
|
||||
) : (
|
||||
|
|
@ -460,7 +466,7 @@ export default function AgentBuilderView({
|
|||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border-t border-amber-200 bg-amber-50 px-4 py-2 text-xs text-amber-800">
|
||||
<ExperimentOutlined className="shrink-0 text-amber-600" />
|
||||
<FlaskConical className="size-4 shrink-0 text-amber-600" />
|
||||
<span>
|
||||
Agent Builder is experimental and may change or be removed without notice. We’d love your feedback—email us
|
||||
at{" "}
|
||||
|
|
@ -477,12 +483,14 @@ export default function AgentBuilderView({
|
|||
<div className="w-60 shrink-0 border-r border-gray-200 bg-white flex flex-col">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">Agents</span>
|
||||
<Button type="text" size="small" icon={<PlusOutlined />} onClick={handleAddAgent} aria-label="Add agent" />
|
||||
<Button variant="ghost" size="icon-sm" onClick={handleAddAgent} aria-label="Add agent">
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{loadingAgents ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Spin size="small" />
|
||||
<div className="flex justify-center py-4" aria-busy="true">
|
||||
<UiLoadingSpinner className="size-4 text-gray-400" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -509,7 +517,7 @@ export default function AgentBuilderView({
|
|||
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"
|
||||
>
|
||||
<PlusOutlined className="mr-1" /> New agent
|
||||
<Plus className="mr-1 inline size-4" /> New agent
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -526,227 +534,230 @@ export default function AgentBuilderView({
|
|||
{(selectedId !== null || isNewAgent) && (
|
||||
<>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(k) => setActiveTab(k as "configure" | "chat" | "test" | "connect")}
|
||||
className="flex-1 overflow-hidden [&_.ant-tabs-content]:h-full [&_.ant-tabs-tabpane]:h-full [&_.ant-tabs-nav]:pl-4"
|
||||
items={[
|
||||
{
|
||||
key: "configure",
|
||||
label: (
|
||||
<span>
|
||||
<RobotOutlined className="mr-1" /> Configure
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
{isNewAgent || selectedAgent ? (
|
||||
<div className="mx-auto max-w-xl space-y-4">
|
||||
{!selectedAgentModelId && selectedAgent && (
|
||||
<div className="rounded-sm border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
This agent cannot be updated or deleted here (missing model id). Manage it from Models
|
||||
& Endpoints.
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Agent name</label>
|
||||
<Input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
placeholder="My Agent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">System prompt</label>
|
||||
<TextArea
|
||||
value={draftSystemPrompt}
|
||||
onChange={(e) => setDraftSystemPrompt(e.target.value)}
|
||||
placeholder="You are a helpful assistant..."
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Underlying LLM</label>
|
||||
<Select
|
||||
value={draftUnderlyingModel}
|
||||
onChange={setDraftUnderlyingModel}
|
||||
className="w-full"
|
||||
options={modelGroups.map((m) => ({ value: m.model_group, label: m.model_group }))}
|
||||
placeholder="Select model"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Temperature</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={draftTemperature}
|
||||
onChange={(e) => setDraftTemperature(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Max tokens</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={draftMaxTokens}
|
||||
onChange={(e) => setDraftMaxTokens(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">MCP servers</label>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Select MCP servers to attach (same format as chat completions API)"
|
||||
value={selectedMCPServerIds}
|
||||
onChange={handleMCPServerChange}
|
||||
loading={loadingMCPServers}
|
||||
className="w-full"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={mcpServers.map((s) => ({
|
||||
value: s.server_id,
|
||||
label: s.alias || s.server_name || s.server_id,
|
||||
}))}
|
||||
/>
|
||||
{selectedAgent && draftTools.length > 0 && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{draftTools.length} MCP server{draftTools.length !== 1 ? "s" : ""} saved. Use the same{" "}
|
||||
<code className="rounded-sm bg-gray-100 px-1">tools</code> array in chat completions
|
||||
when calling this agent.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedAgent && (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2">
|
||||
{selectedAgentModelId && (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleUpdateAgent}
|
||||
loading={saving}
|
||||
disabled={!draftName?.trim() || !draftUnderlyingModel}
|
||||
>
|
||||
Update Agent
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleDeleteAgent}
|
||||
loading={deleting}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button type="primary" icon={<CommentOutlined />} onClick={() => setActiveTab("chat")}>
|
||||
Test in Chat
|
||||
value={activeTab}
|
||||
onValueChange={(value) => goToTab(value as AgentTab)}
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0 pl-4">
|
||||
<TabsTrigger value="configure" className="flex-none rounded-none px-4 py-2">
|
||||
<Bot />
|
||||
Configure
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="chat" disabled={isNewAgent} className="flex-none rounded-none px-4 py-2">
|
||||
<MessageSquare />
|
||||
Chat
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="test" disabled={isNewAgent} className="flex-none rounded-none px-4 py-2">
|
||||
<FlaskConical />
|
||||
Batch Test
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="connect" disabled={isNewAgent} className="flex-none rounded-none px-4 py-2">
|
||||
<LinkIcon />
|
||||
Connect
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="configure"
|
||||
keepMounted={hasVisited("configure")}
|
||||
className="min-h-0 overflow-hidden"
|
||||
>
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
{isNewAgent || selectedAgent ? (
|
||||
<div className="mx-auto max-w-xl space-y-4">
|
||||
{!selectedAgentModelId && selectedAgent && (
|
||||
<div className="rounded-sm border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
This agent cannot be updated or deleted here (missing model id). Manage it from Models &
|
||||
Endpoints.
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Agent name</label>
|
||||
<Input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
placeholder="My Agent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">System prompt</label>
|
||||
<Textarea
|
||||
value={draftSystemPrompt}
|
||||
onChange={(e) => setDraftSystemPrompt(e.target.value)}
|
||||
placeholder="You are a helpful assistant..."
|
||||
rows={6}
|
||||
className="field-sizing-fixed"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Underlying LLM</label>
|
||||
<Select
|
||||
value={draftUnderlyingModel ?? null}
|
||||
onValueChange={(model: string | null) => setDraftUnderlyingModel(model ?? undefined)}
|
||||
>
|
||||
<SelectTrigger className="w-full" aria-label="Underlying LLM">
|
||||
<SelectValue placeholder="Select model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelGroups.map((m) => (
|
||||
<SelectItem key={m.model_group} value={m.model_group}>
|
||||
{m.model_group}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Temperature</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={draftTemperature}
|
||||
onChange={(e) => setDraftTemperature(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Max tokens</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={draftMaxTokens}
|
||||
onChange={(e) => setDraftMaxTokens(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">MCP servers</label>
|
||||
<MultiSelect
|
||||
placeholder="Select MCP servers to attach (same format as chat completions API)"
|
||||
value={selectedMCPServerIds}
|
||||
onValueChange={handleMCPServerChange}
|
||||
loading={loadingMCPServers}
|
||||
className="w-full"
|
||||
options={mcpServers.map((s) => ({
|
||||
value: s.server_id,
|
||||
label: s.alias || s.server_name || s.server_id,
|
||||
}))}
|
||||
/>
|
||||
{selectedAgent && draftTools.length > 0 && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{draftTools.length} MCP server{draftTools.length !== 1 ? "s" : ""} saved. Use the same{" "}
|
||||
<code className="rounded-sm bg-gray-100 px-1">tools</code> array in chat completions when
|
||||
calling this agent.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedAgent && (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2">
|
||||
{selectedAgentModelId && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleUpdateAgent}
|
||||
disabled={saving || !draftName?.trim() || !draftUnderlyingModel}
|
||||
>
|
||||
<Save />
|
||||
Update Agent
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={handleDeleteAgent} disabled={deleting}>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "chat",
|
||||
label: (
|
||||
<span>
|
||||
<CommentOutlined className="mr-1" /> Chat
|
||||
</span>
|
||||
),
|
||||
disabled: isNewAgent,
|
||||
children: (
|
||||
<div className="flex h-full flex-col min-h-0">
|
||||
{selectedAgent ? (
|
||||
<ChatUI
|
||||
key={selectedAgent.model_name}
|
||||
simplified
|
||||
fixedModel={selectedAgent.model_name}
|
||||
accessToken={accessToken}
|
||||
token={token}
|
||||
userRole={userRole}
|
||||
userID={userID}
|
||||
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
|
||||
proxySettings={proxySettings}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-gray-500">
|
||||
Save an agent first to test in Chat.
|
||||
<Button onClick={() => goToTab("chat")}>
|
||||
<MessageSquare />
|
||||
Test in Chat
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "test",
|
||||
label: (
|
||||
<span>
|
||||
<ExperimentOutlined className="mr-1" /> Batch Test
|
||||
</span>
|
||||
),
|
||||
disabled: isNewAgent,
|
||||
children: (
|
||||
<div className="flex h-full flex-col min-h-0">
|
||||
{selectedAgent ? (
|
||||
<ComplianceUI
|
||||
accessToken={accessToken}
|
||||
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
|
||||
backendMode="chat_completions"
|
||||
fixedModel={selectedAgent.model_name}
|
||||
proxySettings={proxySettings}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-gray-500">
|
||||
Select an agent to run batch tests.
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="chat" keepMounted={hasVisited("chat")} className="min-h-0 overflow-hidden">
|
||||
<div className="flex h-full flex-col min-h-0">
|
||||
{selectedAgent ? (
|
||||
<ChatUI
|
||||
key={selectedAgent.model_name}
|
||||
simplified
|
||||
fixedModel={selectedAgent.model_name}
|
||||
accessToken={accessToken}
|
||||
token={token}
|
||||
userRole={userRole}
|
||||
userID={userID}
|
||||
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
|
||||
proxySettings={proxySettings}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-gray-500">
|
||||
Save an agent first to test in Chat.
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "connect",
|
||||
label: (
|
||||
<span>
|
||||
<LinkOutlined className="mr-1" /> Connect
|
||||
</span>
|
||||
),
|
||||
disabled: isNewAgent,
|
||||
children: (
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
{selectedAgent ? (
|
||||
<ConnectTabContent
|
||||
agentName={selectedAgent.model_name}
|
||||
proxySettings={proxySettings}
|
||||
customProxyBaseUrl={customProxyBaseUrl}
|
||||
accessToken={accessToken}
|
||||
userID={userID}
|
||||
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
|
||||
creatingKey={creatingKey}
|
||||
createdKeyValue={createdKeyValue}
|
||||
onCreateKey={handleCreateKeyForAgent}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-gray-500">
|
||||
Select an agent to see how to connect.
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="test" keepMounted={hasVisited("test")} className="min-h-0 overflow-hidden">
|
||||
<div className="flex h-full flex-col min-h-0">
|
||||
{selectedAgent ? (
|
||||
<ComplianceUI
|
||||
accessToken={accessToken}
|
||||
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
|
||||
backendMode="chat_completions"
|
||||
fixedModel={selectedAgent.model_name}
|
||||
proxySettings={proxySettings}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-gray-500">
|
||||
Select an agent to run batch tests.
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="connect" keepMounted={hasVisited("connect")} className="min-h-0 overflow-hidden">
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
{selectedAgent ? (
|
||||
<ConnectTabContent
|
||||
agentName={selectedAgent.model_name}
|
||||
proxySettings={proxySettings}
|
||||
customProxyBaseUrl={customProxyBaseUrl}
|
||||
accessToken={accessToken}
|
||||
userID={userID}
|
||||
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
|
||||
creatingKey={creatingKey}
|
||||
createdKeyValue={createdKeyValue}
|
||||
onCreateKey={handleCreateKeyForAgent}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-gray-500">
|
||||
Select an agent to see how to connect.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={confirmingDelete} onOpenChange={setConfirmingDelete}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete agent</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{selectedAgent?.model_name}"? This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction variant="outline">Cancel</AlertDialogAction>
|
||||
<Button variant="destructive" onClick={handleConfirmDelete} disabled={deleting}>
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,267 @@
|
|||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import RealtimePlayground from "./RealtimePlayground";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getProxyBaseUrl: () => "https://proxy.example.com",
|
||||
}));
|
||||
|
||||
class FakeSocket {
|
||||
static instances: FakeSocket[] = [];
|
||||
static OPEN = 1;
|
||||
|
||||
readyState = 0;
|
||||
sent: string[] = [];
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
close = vi.fn(() => {
|
||||
this.readyState = 3;
|
||||
this.onclose?.();
|
||||
});
|
||||
|
||||
constructor(
|
||||
public url: string,
|
||||
public protocols?: string[],
|
||||
) {
|
||||
FakeSocket.instances.push(this);
|
||||
}
|
||||
|
||||
send(payload: string) {
|
||||
this.sent.push(payload);
|
||||
}
|
||||
|
||||
open() {
|
||||
this.readyState = 1;
|
||||
this.onopen?.();
|
||||
}
|
||||
|
||||
emit(message: Record<string, unknown>) {
|
||||
this.onmessage?.({ data: JSON.stringify(message) });
|
||||
}
|
||||
}
|
||||
|
||||
const latestSocket = () => FakeSocket.instances[FakeSocket.instances.length - 1];
|
||||
|
||||
const connect = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByRole("button", { name: /Connect/i }));
|
||||
await act(async () => {
|
||||
latestSocket().open();
|
||||
});
|
||||
};
|
||||
|
||||
const props = {
|
||||
accessToken: "sk-realtime",
|
||||
selectedModel: "gpt-realtime",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
FakeSocket.instances = [];
|
||||
vi.stubGlobal("WebSocket", FakeSocket);
|
||||
vi.stubGlobal(
|
||||
"AudioContext",
|
||||
class {
|
||||
currentTime = 0;
|
||||
destination = {};
|
||||
close = vi.fn();
|
||||
createBuffer = vi.fn(() => ({ getChannelData: () => new Float32Array(1), duration: 0 }));
|
||||
createBufferSource = vi.fn(() => ({ connect: vi.fn(), start: vi.fn(), buffer: null }));
|
||||
},
|
||||
);
|
||||
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();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("RealtimePlayground", () => {
|
||||
it("opens disconnected, with the invitation to connect", () => {
|
||||
render(<RealtimePlayground {...props} />);
|
||||
|
||||
expect(screen.getByText("Realtime Voice Chat")).toBeInTheDocument();
|
||||
expect(screen.getByText("Disconnected")).toBeInTheDocument();
|
||||
expect(screen.getByText("Realtime Voice Playground")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Connect/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the composer until a session exists", () => {
|
||||
render(<RealtimePlayground {...props} />);
|
||||
|
||||
expect(screen.queryByPlaceholderText("Type a message or use the mic...")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dials the realtime endpoint for the selected model, carrying the key as a protocol", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RealtimePlayground {...props} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Connect/i }));
|
||||
|
||||
expect(latestSocket().url).toBe("wss://proxy.example.com/v1/realtime?model=gpt-realtime");
|
||||
expect(latestSocket().protocols).toEqual(["realtime", "openai-insecure-api-key.sk-realtime"]);
|
||||
});
|
||||
|
||||
it("passes a custom proxy base url through instead of the default", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RealtimePlayground {...props} customProxyBaseUrl="https://tenant.example.com" />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Connect/i }));
|
||||
|
||||
expect(latestSocket().url).toContain("wss://tenant.example.com/v1/realtime");
|
||||
});
|
||||
|
||||
it("appends the selected guardrails to the session url", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RealtimePlayground {...props} selectedGuardrails={["pii", "toxicity"]} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Connect/i }));
|
||||
|
||||
expect(latestSocket().url).toContain("guardrails=pii%2Ctoxicity");
|
||||
});
|
||||
|
||||
it("refuses to dial without a model and says why", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RealtimePlayground {...props} selectedModel="" />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Connect/i }));
|
||||
|
||||
expect(FakeSocket.instances).toHaveLength(0);
|
||||
expect(screen.getByText("Please select a model first")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reveals the composer and the disconnect control once the session opens", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RealtimePlayground {...props} />);
|
||||
|
||||
await connect(user);
|
||||
|
||||
expect(screen.getByText("Connected")).toBeInTheDocument();
|
||||
expect(screen.getByText("Connected to realtime API")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Type a message or use the mic...")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Disconnect/i })).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Start recording")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("configures the session against the chosen voice when it is created", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RealtimePlayground {...props} />);
|
||||
|
||||
await connect(user);
|
||||
await act(async () => {
|
||||
latestSocket().emit({ type: "session.created" });
|
||||
});
|
||||
|
||||
const update = JSON.parse(latestSocket().sent[0]);
|
||||
expect(update.type).toBe("session.update");
|
||||
expect(update.session.voice).toBe("alloy");
|
||||
expect(update.session.type).toBe("realtime");
|
||||
});
|
||||
|
||||
it("sends what was typed and then asks for a response", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RealtimePlayground {...props} />);
|
||||
|
||||
await connect(user);
|
||||
await user.type(screen.getByPlaceholderText("Type a message or use the mic..."), "hello there");
|
||||
await user.click(screen.getByRole("button", { name: /send/i }));
|
||||
|
||||
const payloads = latestSocket().sent.map((raw) => JSON.parse(raw));
|
||||
expect(payloads[0].item.content[0].text).toBe("hello there");
|
||||
expect(payloads[1]).toEqual({ type: "response.create" });
|
||||
expect(screen.getByText("hello there")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Type a message or use the mic...")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("will not send an empty message", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RealtimePlayground {...props} />);
|
||||
|
||||
await connect(user);
|
||||
await user.click(screen.getByRole("button", { name: /send/i }));
|
||||
|
||||
expect(latestSocket().sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("streams assistant text deltas into a single reply", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RealtimePlayground {...props} />);
|
||||
|
||||
await connect(user);
|
||||
await act(async () => {
|
||||
latestSocket().emit({ type: "response.output_text.delta", delta: "Hel" });
|
||||
latestSocket().emit({ type: "response.output_text.delta", delta: "lo!" });
|
||||
});
|
||||
|
||||
expect(screen.getByText("Hello!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the completed response when no delta arrived", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RealtimePlayground {...props} />);
|
||||
|
||||
await connect(user);
|
||||
await act(async () => {
|
||||
latestSocket().emit({
|
||||
type: "response.done",
|
||||
response: { output: [{ content: [{ type: "output_audio", transcript: "spoken reply" }] }] },
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByText("spoken reply")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows what the microphone heard", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RealtimePlayground {...props} />);
|
||||
|
||||
await connect(user);
|
||||
await act(async () => {
|
||||
latestSocket().emit({
|
||||
type: "conversation.item.input_audio_transcription.completed",
|
||||
transcript: "what is the weather",
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByText("what is the weather")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces an error frame in the transcript", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RealtimePlayground {...props} />);
|
||||
|
||||
await connect(user);
|
||||
await act(async () => {
|
||||
latestSocket().emit({ type: "error", error: { message: "rate limited" } });
|
||||
});
|
||||
|
||||
expect(screen.getByText("Error: rate limited")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes the socket and returns to the disconnected state", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RealtimePlayground {...props} />);
|
||||
|
||||
await connect(user);
|
||||
const socket = latestSocket();
|
||||
await user.click(screen.getByRole("button", { name: /Disconnect/i }));
|
||||
|
||||
expect(socket.close).toHaveBeenCalled();
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /Connect/i })).toBeInTheDocument());
|
||||
expect(screen.queryByPlaceholderText("Type a message or use the mic...")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { AudioMutedOutlined, AudioOutlined, CloseCircleOutlined, SendOutlined, SoundOutlined } from "@ant-design/icons";
|
||||
import { Button, Input, Select, Typography } from "antd";
|
||||
import { CircleX, Mic, MicOff, Send, Volume2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import { OPEN_AI_VOICE_SELECT_OPTIONS } from "./chatConstants";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface RealtimeMessage {
|
||||
role: "user" | "assistant" | "system" | "status";
|
||||
content: string;
|
||||
|
|
@ -364,28 +364,37 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
|
|||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<SoundOutlined className="text-lg text-blue-500" />
|
||||
<Text className="font-semibold text-gray-800">Realtime Voice Chat</Text>
|
||||
<Volume2 className="size-5 text-blue-500" />
|
||||
<span className="font-semibold text-gray-800">Realtime Voice Chat</span>
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${isConnected ? "bg-green-500" : "bg-gray-300"}`} />
|
||||
<Text className="text-xs text-gray-500">
|
||||
<span className="text-xs text-gray-500">
|
||||
{isConnected ? "Connected" : isConnecting ? "Connecting..." : "Disconnected"}
|
||||
</Text>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
size="small"
|
||||
value={selectedVoice}
|
||||
onChange={setSelectedVoice}
|
||||
options={OPEN_AI_VOICE_SELECT_OPTIONS}
|
||||
style={{ width: 220 }}
|
||||
onValueChange={(voice) => setSelectedVoice(voice ?? selectedVoice)}
|
||||
disabled={isConnected}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-[220px]" aria-label="Voice">
|
||||
<SelectValue>{OPEN_AI_VOICE_SELECT_OPTIONS.find((v) => v.value === selectedVoice)?.label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPEN_AI_VOICE_SELECT_OPTIONS.map((voice) => (
|
||||
<SelectItem key={voice.value} value={voice.value}>
|
||||
{voice.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!isConnected ? (
|
||||
<Button type="primary" onClick={connect} loading={isConnecting} size="small">
|
||||
<Button onClick={connect} disabled={isConnecting} size="sm">
|
||||
Connect
|
||||
</Button>
|
||||
) : (
|
||||
<Button danger onClick={disconnect} size="small" icon={<CloseCircleOutlined />}>
|
||||
<Button variant="destructive" onClick={disconnect} size="sm">
|
||||
<CircleX />
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -396,12 +405,12 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
|
|||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.length === 0 && !isConnected && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-400 gap-3">
|
||||
<SoundOutlined style={{ fontSize: 48 }} />
|
||||
<Text className="text-lg text-gray-500">Realtime Voice Playground</Text>
|
||||
<Text className="text-sm text-gray-400 text-center max-w-md">
|
||||
<Volume2 className="size-12" />
|
||||
<span className="text-lg text-gray-500">Realtime Voice Playground</span>
|
||||
<p className="text-sm text-gray-400 text-center max-w-md">
|
||||
Click <b>Connect</b> to start a realtime session. You can speak using your microphone or type messages.
|
||||
The AI will respond with voice and text.
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
|
|
@ -433,30 +442,26 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
|
|||
<div className="border-t border-gray-200 p-3 bg-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
type={isRecording ? "primary" : "default"}
|
||||
danger={isRecording}
|
||||
icon={isRecording ? <AudioMutedOutlined /> : <AudioOutlined />}
|
||||
size="icon-lg"
|
||||
variant={isRecording ? "destructive" : "outline"}
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
title={isRecording ? "Stop recording" : "Start recording"}
|
||||
className={isRecording ? "animate-pulse" : ""}
|
||||
/>
|
||||
className={`rounded-full ${isRecording ? "animate-pulse" : ""}`}
|
||||
>
|
||||
{isRecording ? <MicOff /> : <Mic />}
|
||||
</Button>
|
||||
<Input
|
||||
placeholder="Type a message or use the mic..."
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
onPressEnter={sendTextMessage}
|
||||
className="flex-1"
|
||||
size="large"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={sendTextMessage}
|
||||
disabled={!inputText.trim()}
|
||||
size="large"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") sendTextMessage();
|
||||
}}
|
||||
className="h-10 flex-1"
|
||||
/>
|
||||
<Button size="icon-lg" onClick={sendTextMessage} disabled={!inputText.trim()} aria-label="Send">
|
||||
<Send />
|
||||
</Button>
|
||||
</div>
|
||||
{isRecording && (
|
||||
<div className="mt-2 flex items-center gap-2 text-red-500 text-xs">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { render, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import CompareUI from "./CompareUI";
|
||||
|
|
@ -108,10 +108,7 @@ describe("CompareUI", () => {
|
|||
let comparisonPanels = container.querySelectorAll('[data-testid^="comparison-panel-"]');
|
||||
expect(comparisonPanels).toHaveLength(2);
|
||||
|
||||
const addButtons = Array.from(container.querySelectorAll('button[class*="ant-btn"]'));
|
||||
const addComparisonButton = addButtons.find((btn) => btn.textContent?.includes("Add Comparison"));
|
||||
expect(addComparisonButton).toBeInTheDocument();
|
||||
await user.click(addComparisonButton!);
|
||||
await user.click(screen.getByRole("button", { name: /Add Comparison/i }));
|
||||
|
||||
// Wait for the new comparison panel to be added (should have 3 total now)
|
||||
await waitFor(() => {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@
|
|||
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { ClearOutlined, DeleteOutlined, FilePdfOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { Eraser, FileText, Plus, Trash2 } from "lucide-react";
|
||||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
import { Button, Input, Select, Tooltip } from "antd";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import ChatImageUpload from "../chat_ui/ChatImageUpload";
|
||||
|
|
@ -692,17 +695,22 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
|
|||
<span className="text-sm font-medium text-gray-600">Virtual Key Source</span>
|
||||
<Select
|
||||
value={apiKeySource}
|
||||
onChange={(value) => setApiKeySource(value as "session" | "custom")}
|
||||
onValueChange={(value) => setApiKeySource(value as "session" | "custom")}
|
||||
disabled={disabledPersonalKeyCreation}
|
||||
className="w-48"
|
||||
>
|
||||
<Select.Option value="session" disabled={!canUseSessionKey}>
|
||||
Current UI Session
|
||||
</Select.Option>
|
||||
<Select.Option value="custom">Virtual Key</Select.Option>
|
||||
<SelectTrigger className="w-48" aria-label="Virtual Key Source">
|
||||
<SelectValue>{apiKeySource === "custom" ? "Virtual Key" : "Current UI Session"}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="session" disabled={!canUseSessionKey}>
|
||||
Current UI Session
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">Virtual Key</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{apiKeySource === "custom" && (
|
||||
<Input.Password
|
||||
<Input
|
||||
type="password"
|
||||
value={customApiKey}
|
||||
onChange={(event) => setCustomApiKey(event.target.value)}
|
||||
placeholder="Enter Virtual Key"
|
||||
|
|
@ -712,30 +720,34 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
|
|||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-600">Endpoint</span>
|
||||
<Select
|
||||
value={selectedEndpoint}
|
||||
onChange={(value) => setSelectedEndpoint(value as EndpointIdType)}
|
||||
className="w-56"
|
||||
>
|
||||
{getAvailableEndpoints().map((endpoint) => (
|
||||
<Select.Option key={endpoint.value} value={endpoint.value}>
|
||||
{endpoint.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
<Select value={selectedEndpoint} onValueChange={(value) => setSelectedEndpoint(value as EndpointIdType)}>
|
||||
<SelectTrigger className="w-56" aria-label="Endpoint">
|
||||
<SelectValue>{endpointConfig.label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{getAvailableEndpoints().map((endpoint) => (
|
||||
<SelectItem key={endpoint.value} value={endpoint.value}>
|
||||
{endpoint.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={clearAllChats} disabled={!hasMessages} icon={<ClearOutlined />}>
|
||||
<Button variant="outline" onClick={clearAllChats} disabled={!hasMessages}>
|
||||
<Eraser />
|
||||
Clear All Chats
|
||||
</Button>
|
||||
<Tooltip
|
||||
title={
|
||||
comparisons.length >= maxComparisons ? "Compare up to 3 models at a time" : "Add another comparison"
|
||||
}
|
||||
>
|
||||
<Button onClick={addComparison} disabled={comparisons.length >= maxComparisons} icon={<PlusOutlined />}>
|
||||
Add Comparison
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span className="inline-flex" />}>
|
||||
<Button variant="outline" onClick={addComparison} disabled={comparisons.length >= maxComparisons}>
|
||||
<Plus />
|
||||
Add Comparison
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{comparisons.length >= maxComparisons ? "Compare up to 3 models at a time" : "Add another comparison"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -807,8 +819,8 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
|
|||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="relative inline-block">
|
||||
{isUploadedFilePdf ? (
|
||||
<div className="w-10 h-10 rounded-md bg-red-500 flex items-center justify-center">
|
||||
<FilePdfOutlined style={{ fontSize: "16px", color: "white" }} />
|
||||
<div className="w-10 h-10 rounded-md bg-red-500 flex items-center justify-center text-white">
|
||||
<FileText className="size-4" aria-label="file-pdf" />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
|
|
@ -825,8 +837,9 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
|
|||
<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"
|
||||
onClick={handleRemoveFile}
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
<DeleteOutlined style={{ fontSize: "12px" }} />
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { render } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ComparisonInstance } from "../CompareUI";
|
||||
|
|
@ -76,20 +76,137 @@ const mockProps = {
|
|||
apiKey: "test-api-key",
|
||||
};
|
||||
|
||||
const buttonWithIcon = (icon: string): HTMLButtonElement => {
|
||||
const match = Array.from(document.querySelectorAll("button")).find((button) =>
|
||||
button.querySelector(`svg.lucide-${icon}`),
|
||||
);
|
||||
if (!match) throw new Error(`no button carrying the ${icon} icon`);
|
||||
return match;
|
||||
};
|
||||
|
||||
const openSettings = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(buttonWithIcon("settings"));
|
||||
await screen.findByText("General Settings");
|
||||
};
|
||||
|
||||
describe("ComparisonPanel", () => {
|
||||
it("should render", () => {
|
||||
const { getByTestId } = render(<ComparisonPanel {...mockProps} />);
|
||||
expect(getByTestId("unified-selector")).toBeInTheDocument();
|
||||
expect(getByTestId("message-display")).toBeInTheDocument();
|
||||
it("renders the selector and the transcript", () => {
|
||||
render(<ComparisonPanel {...mockProps} />);
|
||||
|
||||
expect(screen.getByTestId("unified-selector")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("message-display")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onRemove when remove button is clicked", async () => {
|
||||
it("removes the panel when the remove control is used", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRemove = vi.fn();
|
||||
const { container } = render(<ComparisonPanel {...mockProps} onRemove={onRemove} />);
|
||||
const removeButton = container.querySelector('button[class*="text-red-600"]');
|
||||
expect(removeButton).toBeInTheDocument();
|
||||
await user.click(removeButton!);
|
||||
render(<ComparisonPanel {...mockProps} onRemove={onRemove} />);
|
||||
|
||||
await user.click(buttonWithIcon("x"));
|
||||
|
||||
expect(onRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hides the remove control on the last remaining panel", () => {
|
||||
render(<ComparisonPanel {...mockProps} canRemove={false} />);
|
||||
|
||||
expect(() => buttonWithIcon("x")).toThrow();
|
||||
});
|
||||
|
||||
it("keeps the settings out of sight until the gear is used", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ComparisonPanel {...mockProps} />);
|
||||
|
||||
expect(screen.queryByText("General Settings")).not.toBeInTheDocument();
|
||||
|
||||
await openSettings(user);
|
||||
|
||||
expect(screen.getByText("General Settings")).toBeInTheDocument();
|
||||
expect(screen.getByText("Advanced Settings")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tag-selector")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("vector-store-selector")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("guardrail-selector")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the current temperature and token ceiling", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ComparisonPanel {...mockProps} />);
|
||||
|
||||
await openSettings(user);
|
||||
|
||||
expect(screen.getByText("Temperature")).toBeInTheDocument();
|
||||
expect(screen.getByText("1.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("Max Tokens")).toBeInTheDocument();
|
||||
expect(screen.getByText("2048")).toBeInTheDocument();
|
||||
|
||||
const ranges = Array.from(document.querySelectorAll("[aria-valuenow]"));
|
||||
expect(ranges.map((range) => range.getAttribute("aria-valuenow"))).toEqual(["1", "2048"]);
|
||||
});
|
||||
|
||||
it("pushes the whole parameter set to every panel when sync is switched on", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUpdate = vi.fn();
|
||||
render(<ComparisonPanel {...mockProps} onUpdate={onUpdate} />);
|
||||
|
||||
await openSettings(user);
|
||||
await user.click(screen.getByRole("checkbox", { name: /Sync Settings Across Models/i }));
|
||||
|
||||
await waitFor(() => expect(onUpdate).toHaveBeenCalled());
|
||||
const [updates, options] = onUpdate.mock.calls[0];
|
||||
expect(updates.applyAcrossModels).toBe(true);
|
||||
expect(updates.temperature).toBe(1);
|
||||
expect(updates.maxTokens).toBe(2048);
|
||||
expect(options.applyToAll).toBe(true);
|
||||
expect(options.keysToApply).toContain("temperature");
|
||||
expect(options.keysToApply).toContain("maxTokens");
|
||||
});
|
||||
|
||||
it("turns sync off without resetting the values it was sharing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUpdate = vi.fn();
|
||||
render(
|
||||
<ComparisonPanel
|
||||
{...mockProps}
|
||||
comparison={{ ...mockComparison, applyAcrossModels: true }}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openSettings(user);
|
||||
await user.click(screen.getByRole("checkbox", { name: /Sync Settings Across Models/i }));
|
||||
|
||||
await waitFor(() => expect(onUpdate).toHaveBeenCalled());
|
||||
expect(onUpdate.mock.calls[0][0]).toEqual({ applyAcrossModels: false });
|
||||
});
|
||||
|
||||
it("keeps an advanced-parameter toggle local while sync is off", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUpdate = vi.fn();
|
||||
render(<ComparisonPanel {...mockProps} onUpdate={onUpdate} />);
|
||||
|
||||
await openSettings(user);
|
||||
await user.click(screen.getByRole("checkbox", { name: /Use Advanced Parameters/i }));
|
||||
|
||||
await waitFor(() => expect(onUpdate).toHaveBeenCalled());
|
||||
expect(onUpdate.mock.calls[0][0]).toEqual({ useAdvancedParams: true });
|
||||
expect(onUpdate.mock.calls[0][1]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fans an advanced-parameter toggle out to every panel while sync is on", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUpdate = vi.fn();
|
||||
render(
|
||||
<ComparisonPanel
|
||||
{...mockProps}
|
||||
comparison={{ ...mockComparison, applyAcrossModels: true }}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openSettings(user);
|
||||
await user.click(screen.getByRole("checkbox", { name: /Use Advanced Parameters/i }));
|
||||
|
||||
await waitFor(() => expect(onUpdate).toHaveBeenCalled());
|
||||
expect(onUpdate.mock.calls[0][1]).toEqual({ applyToAll: true, keysToApply: ["useAdvancedParams"] });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import { Settings, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { ComparisonInstance } from "../CompareUI";
|
||||
import { MessageDisplay } from "./MessageDisplay";
|
||||
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 { Checkbox, Divider, Popover, Slider } from "antd";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { SelectorOption, EndpointConfig, isAgentEndpoint, getComparisonSelection } from "../endpoint_config";
|
||||
|
||||
interface ComparisonPanelProps {
|
||||
|
|
@ -35,6 +38,8 @@ export function ComparisonPanel({
|
|||
const isA2AMode = isAgentEndpoint(endpointConfig.id);
|
||||
const currentSelection = getComparisonSelection(comparison, endpointConfig.id);
|
||||
const [popoverVisible, setPopoverVisible] = useState(false);
|
||||
const syncId = useId();
|
||||
const advancedParamsId = useId();
|
||||
|
||||
const handleSyncChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
|
|
@ -103,12 +108,18 @@ export function ComparisonPanel({
|
|||
<div className="space-y-2">
|
||||
{/* Sync Checkbox */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={comparison.applyAcrossModels} onChange={(e) => handleSyncChange(e.target.checked)}>
|
||||
<span className="text-xs font-medium">Sync Settings Across Models</span>
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
id={syncId}
|
||||
checked={comparison.applyAcrossModels}
|
||||
onCheckedChange={handleSyncChange}
|
||||
aria-label="Sync Settings Across Models"
|
||||
/>
|
||||
<label htmlFor={syncId} className="cursor-pointer text-xs font-medium">
|
||||
Sync Settings Across Models
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Divider className="border-gray-200" />
|
||||
<Separator className="my-3" />
|
||||
|
||||
{/* General Settings */}
|
||||
<div>
|
||||
|
|
@ -146,11 +157,14 @@ export function ComparisonPanel({
|
|||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 pb-1">
|
||||
<Checkbox
|
||||
id={advancedParamsId}
|
||||
checked={comparison.useAdvancedParams}
|
||||
onChange={(e) => handleAdvancedParamsChange(e.target.checked)}
|
||||
>
|
||||
<span className="text-sm font-medium">Use Advanced Parameters</span>
|
||||
</Checkbox>
|
||||
onCheckedChange={handleAdvancedParamsChange}
|
||||
aria-label="Use Advanced Parameters"
|
||||
/>
|
||||
<label htmlFor={advancedParamsId} className="cursor-pointer text-sm font-medium">
|
||||
Use Advanced Parameters
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-2 transition-opacity duration-200" style={{ opacity: disabledOpacity }}>
|
||||
<div>
|
||||
|
|
@ -162,8 +176,8 @@ export function ComparisonPanel({
|
|||
min={0}
|
||||
max={2}
|
||||
step={0.01}
|
||||
value={comparison.temperature}
|
||||
onChange={(value) => {
|
||||
value={[comparison.temperature]}
|
||||
onValueChange={(value) => {
|
||||
const nextValue = Array.isArray(value) ? value[0] : value;
|
||||
const clamped = Math.min(2, Math.max(0, Number(nextValue.toFixed(2))));
|
||||
handleSettingChange("temperature", clamped);
|
||||
|
|
@ -180,8 +194,8 @@ export function ComparisonPanel({
|
|||
min={1}
|
||||
max={32768}
|
||||
step={1}
|
||||
value={comparison.maxTokens}
|
||||
onChange={(value) => {
|
||||
value={[comparison.maxTokens]}
|
||||
onValueChange={(value) => {
|
||||
const nextValue = Array.isArray(value) ? value[0] : value;
|
||||
const clamped = Math.min(32768, Math.max(1, Math.round(nextValue)));
|
||||
handleSettingChange("maxTokens", clamped);
|
||||
|
|
@ -209,26 +223,29 @@ export function ComparisonPanel({
|
|||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover
|
||||
content={settingsContent}
|
||||
trigger={[]}
|
||||
open={popoverVisible}
|
||||
onOpenChange={() => {
|
||||
// Prevent automatic closing - we control it manually
|
||||
}}
|
||||
placement="bottomRight"
|
||||
destroyTooltipOnHide={false}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
<PopoverContent side="bottom" align="end" className="w-auto">
|
||||
{settingsContent}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,42 +1,95 @@
|
|||
import { render } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MessageInput } from "./MessageInput";
|
||||
|
||||
const PLACEHOLDER = "Type your message... (Shift+Enter for new line)";
|
||||
|
||||
describe("MessageInput", () => {
|
||||
it("should render", () => {
|
||||
const onChange = vi.fn();
|
||||
const onSend = vi.fn();
|
||||
const { container } = render(<MessageInput value="" onChange={onChange} onSend={onSend} />);
|
||||
const textarea = container.querySelector("textarea");
|
||||
const button = container.querySelector("button");
|
||||
expect(textarea).toBeInTheDocument();
|
||||
expect(button).toBeInTheDocument();
|
||||
it("renders a message box and a send control", () => {
|
||||
render(<MessageInput value="" onChange={vi.fn()} onSend={vi.fn()} />);
|
||||
|
||||
expect(screen.getByPlaceholderText(PLACEHOLDER)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable send button initially", () => {
|
||||
it("reports every keystroke through onChange", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const onSend = vi.fn();
|
||||
const { container } = render(<MessageInput value="" onChange={onChange} onSend={onSend} />);
|
||||
const button = container.querySelector("button") as HTMLButtonElement;
|
||||
render(<MessageInput value="" onChange={onChange} onSend={vi.fn()} />);
|
||||
|
||||
expect(button).toBeDisabled();
|
||||
await user.type(screen.getByPlaceholderText(PLACEHOLDER), "hi");
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("h");
|
||||
});
|
||||
|
||||
it("should enable send button when hasAttachment is true even with empty value", () => {
|
||||
const onChange = vi.fn();
|
||||
it("refuses to send while the box is empty and nothing is attached", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSend = vi.fn();
|
||||
const uploadComponent = <div data-testid="upload-component">Upload</div>;
|
||||
const { container, getByTestId } = render(
|
||||
render(<MessageInput value="" onChange={vi.fn()} onSend={onSend} />);
|
||||
|
||||
const send = screen.getByRole("button");
|
||||
expect(send).toBeDisabled();
|
||||
|
||||
await user.click(send);
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends once the box holds text", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSend = vi.fn();
|
||||
render(<MessageInput value="hello" onChange={vi.fn()} onSend={onSend} />);
|
||||
|
||||
const send = screen.getByRole("button");
|
||||
expect(send).not.toBeDisabled();
|
||||
|
||||
await user.click(send);
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends on an attachment alone and surfaces the upload control", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSend = vi.fn();
|
||||
render(
|
||||
<MessageInput
|
||||
value=""
|
||||
onChange={onChange}
|
||||
onChange={vi.fn()}
|
||||
onSend={onSend}
|
||||
hasAttachment={true}
|
||||
uploadComponent={uploadComponent}
|
||||
hasAttachment
|
||||
uploadComponent={<span data-testid="upload-component">Upload</span>}
|
||||
/>,
|
||||
);
|
||||
const button = container.querySelector("button") as HTMLButtonElement;
|
||||
expect(getByTestId("upload-component")).toBeInTheDocument();
|
||||
expect(button).not.toBeDisabled();
|
||||
|
||||
expect(screen.getByTestId("upload-component")).toBeInTheDocument();
|
||||
|
||||
const send = screen.getByRole("button");
|
||||
expect(send).not.toBeDisabled();
|
||||
|
||||
await user.click(send);
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stays inert while disabled even with text present", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSend = vi.fn();
|
||||
render(<MessageInput value="hello" onChange={vi.fn()} onSend={onSend} disabled />);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends on Enter but adds a newline on Shift+Enter", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSend = vi.fn();
|
||||
render(<MessageInput value="hello" onChange={vi.fn()} onSend={onSend} />);
|
||||
|
||||
const box = screen.getByPlaceholderText(PLACEHOLDER);
|
||||
await user.click(box);
|
||||
|
||||
await user.keyboard("{Shift>}{Enter}{/Shift}");
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import React from "react";
|
||||
import { Input, Button } from "antd";
|
||||
import { ArrowUpOutlined } from "@ant-design/icons";
|
||||
|
||||
const { TextArea } = Input;
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface MessageInputProps {
|
||||
value: string;
|
||||
|
|
@ -29,25 +28,25 @@ export function MessageInput({ value, onChange, onSend, disabled, hasAttachment,
|
|||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center flex-1 bg-white border border-gray-300 rounded-xl px-3 py-1 min-h-[44px]">
|
||||
{uploadComponent && <div className="shrink-0 mr-2">{uploadComponent}</div>}
|
||||
<TextArea
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type your message... (Shift+Enter for new line)"
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
style={{
|
||||
resize: "none",
|
||||
border: "none",
|
||||
boxShadow: "none",
|
||||
background: "transparent",
|
||||
padding: "4px 0",
|
||||
fontSize: "14px",
|
||||
lineHeight: "20px",
|
||||
}}
|
||||
rows={1}
|
||||
className="max-h-20 min-h-0 flex-1 resize-none overflow-y-auto border-0 bg-transparent px-0 py-1 text-sm leading-5 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
<Button onClick={onSend} disabled={!canSend} icon={<ArrowUpOutlined />} shape="circle" />
|
||||
<Button
|
||||
onClick={onSend}
|
||||
disabled={!canSend}
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
className="rounded-full"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<ArrowUp />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,158 +4,118 @@ import { describe, expect, it, vi } from "vitest";
|
|||
import { UnifiedSelector } from "./UnifiedSelector";
|
||||
import { EndpointId, ENDPOINT_CONFIGS } from "../endpoint_config";
|
||||
|
||||
const CHAT = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS];
|
||||
const AGENTS = ENDPOINT_CONFIGS[EndpointId.A2A_AGENTS];
|
||||
|
||||
const promptIsVisible = (text: string) =>
|
||||
screen.queryByText(text) !== null || screen.queryByPlaceholderText(text) !== null;
|
||||
|
||||
const openList = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
};
|
||||
|
||||
describe("UnifiedSelector", () => {
|
||||
it("should render", () => {
|
||||
const onChange = vi.fn();
|
||||
const options = [
|
||||
{ value: "option1", label: "Option 1" },
|
||||
{ value: "option2", label: "Option 2" },
|
||||
];
|
||||
const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS];
|
||||
|
||||
render(<UnifiedSelector value="" options={options} loading={false} config={config} onChange={onChange} />);
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
expect(select).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display placeholder when not loading", () => {
|
||||
const onChange = vi.fn();
|
||||
const options = [{ value: "option1", label: "Option 1" }];
|
||||
const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS];
|
||||
|
||||
const { container } = render(
|
||||
<UnifiedSelector value="" options={options} loading={false} config={config} onChange={onChange} />,
|
||||
it("renders a combobox", () => {
|
||||
render(
|
||||
<UnifiedSelector
|
||||
value=""
|
||||
options={[{ value: "option1", label: "Option One" }]}
|
||||
loading={false}
|
||||
config={CHAT}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const placeholder = container.querySelector(".ant-select-selection-placeholder");
|
||||
expect(placeholder).toHaveTextContent(config.selectorPlaceholder);
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display loading placeholder when loading", () => {
|
||||
const onChange = vi.fn();
|
||||
const options = [{ value: "option1", label: "Option 1" }];
|
||||
const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS];
|
||||
it("prompts with the endpoint's own selector copy", () => {
|
||||
render(<UnifiedSelector value="" options={[]} loading={false} config={CHAT} onChange={vi.fn()} />);
|
||||
|
||||
const { container } = render(
|
||||
<UnifiedSelector value="" options={options} loading={true} config={config} onChange={onChange} />,
|
||||
);
|
||||
|
||||
const placeholder = container.querySelector(".ant-select-selection-placeholder");
|
||||
expect(placeholder).toHaveTextContent(`Loading ${config.selectorLabel.toLowerCase()}s...`);
|
||||
expect(promptIsVisible(CHAT.selectorPlaceholder)).toBe(true);
|
||||
});
|
||||
|
||||
it("should call onChange when option is selected", async () => {
|
||||
it("prompts with the agent endpoint's copy when configured for agents", () => {
|
||||
render(<UnifiedSelector value="" options={[]} loading={false} config={AGENTS} onChange={vi.fn()} />);
|
||||
|
||||
expect(promptIsVisible(AGENTS.selectorPlaceholder)).toBe(true);
|
||||
});
|
||||
|
||||
it("swaps the prompt for a loading message while options are in flight", () => {
|
||||
render(<UnifiedSelector value="" options={[]} loading config={CHAT} onChange={vi.fn()} />);
|
||||
|
||||
expect(promptIsVisible(`Loading ${CHAT.selectorLabel.toLowerCase()}s...`)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports the chosen option's value, not its label", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const options = [
|
||||
{ value: "option1", label: "Option 1" },
|
||||
{ value: "option2", label: "Option 2" },
|
||||
];
|
||||
const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS];
|
||||
|
||||
render(<UnifiedSelector value="" options={options} loading={false} config={config} onChange={onChange} />);
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
await user.click(select);
|
||||
|
||||
await waitFor(() => {
|
||||
const option = screen.getByText("Option 1");
|
||||
expect(option).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const option = screen.getByText("Option 1");
|
||||
await user.click(option);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
const callArgs = onChange.mock.calls[0];
|
||||
expect(callArgs[0]).toBe("option1");
|
||||
});
|
||||
|
||||
it("should display selected value", () => {
|
||||
const onChange = vi.fn();
|
||||
const options = [
|
||||
{ value: "option1", label: "Option 1" },
|
||||
{ value: "option2", label: "Option 2" },
|
||||
];
|
||||
const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS];
|
||||
|
||||
const { container } = render(
|
||||
<UnifiedSelector value="option1" options={options} loading={false} config={config} onChange={onChange} />,
|
||||
render(
|
||||
<UnifiedSelector
|
||||
value=""
|
||||
options={[
|
||||
{ value: "option1", label: "Option One" },
|
||||
{ value: "option2", label: "Option Two" },
|
||||
]}
|
||||
loading={false}
|
||||
config={CHAT}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const selectedValue = container.querySelector(".ant-select-selection-item");
|
||||
expect(selectedValue).toHaveTextContent("Option 1");
|
||||
await openList(user);
|
||||
|
||||
const matches = await screen.findAllByText("Option Two");
|
||||
await user.click(matches[matches.length - 1]);
|
||||
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalled());
|
||||
expect(onChange.mock.calls[0][0]).toBe("option2");
|
||||
});
|
||||
|
||||
it("should filter options by search input", async () => {
|
||||
it("narrows the list as the user searches", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const options = [
|
||||
{ value: "option1", label: "Option One" },
|
||||
{ value: "option2", label: "Option Two" },
|
||||
{ value: "option3", label: "Different" },
|
||||
];
|
||||
const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS];
|
||||
render(
|
||||
<UnifiedSelector
|
||||
value=""
|
||||
options={[
|
||||
{ value: "option1", label: "Option One" },
|
||||
{ value: "option2", label: "Option Two" },
|
||||
{ value: "option3", label: "Different" },
|
||||
]}
|
||||
loading={false}
|
||||
config={CHAT}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
render(<UnifiedSelector value="" options={options} loading={false} config={config} onChange={onChange} />);
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
await user.click(select);
|
||||
await user.type(select, "One");
|
||||
const combobox = screen.getByRole("combobox");
|
||||
await user.click(combobox);
|
||||
await user.type(combobox, "One");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Option One")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Option One").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("Option Two")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Different")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show loading spinner in notFoundContent when loading", async () => {
|
||||
it("shows a busy indicator in the empty list while loading", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const options: { value: string; label: string }[] = [];
|
||||
const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS];
|
||||
render(<UnifiedSelector value="" options={[]} loading config={CHAT} onChange={vi.fn()} />);
|
||||
|
||||
render(<UnifiedSelector value="" options={options} loading={true} config={config} onChange={onChange} />);
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
await user.click(select);
|
||||
await openList(user);
|
||||
|
||||
await waitFor(() => {
|
||||
const spin = document.querySelector(".ant-spin");
|
||||
expect(spin).toBeInTheDocument();
|
||||
expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show no options message when not loading and no options", async () => {
|
||||
it("says so when there is nothing to pick and nothing is loading", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const options: { value: string; label: string }[] = [];
|
||||
const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS];
|
||||
render(<UnifiedSelector value="" options={[]} loading={false} config={CHAT} onChange={vi.fn()} />);
|
||||
|
||||
render(<UnifiedSelector value="" options={options} loading={false} config={config} onChange={onChange} />);
|
||||
await openList(user);
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
await user.click(select);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(`No ${config.selectorLabel.toLowerCase()}s available`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should work with agent endpoint config", () => {
|
||||
const onChange = vi.fn();
|
||||
const options = [{ value: "agent1", label: "Agent One" }];
|
||||
const config = ENDPOINT_CONFIGS[EndpointId.A2A_AGENTS];
|
||||
|
||||
const { container } = render(
|
||||
<UnifiedSelector value="" options={options} loading={false} config={config} onChange={onChange} />,
|
||||
);
|
||||
|
||||
const placeholder = container.querySelector(".ant-select-selection-placeholder");
|
||||
expect(placeholder).toHaveTextContent(config.selectorPlaceholder);
|
||||
expect(await screen.findByText(`No ${CHAT.selectorLabel.toLowerCase()}s available`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,15 @@
|
|||
* based on the current endpoint configuration.
|
||||
*/
|
||||
|
||||
import { Select, Spin } from "antd";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { SelectorOption, EndpointConfig } from "../endpoint_config";
|
||||
|
||||
interface UnifiedSelectorProps {
|
||||
|
|
@ -14,26 +22,44 @@ interface UnifiedSelectorProps {
|
|||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const matchesQuery = (option: SelectorOption, query: string): boolean =>
|
||||
option.label.toLowerCase().includes(query.trim().toLowerCase());
|
||||
|
||||
export function UnifiedSelector({ value, options, loading, config, onChange }: UnifiedSelectorProps) {
|
||||
const selected = options.find((option) => option.value === value) ?? null;
|
||||
const noun = config.selectorLabel.toLowerCase();
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value || undefined}
|
||||
placeholder={loading ? `Loading ${config.selectorLabel.toLowerCase()}s...` : config.selectorPlaceholder}
|
||||
onChange={onChange}
|
||||
loading={loading}
|
||||
showSearch
|
||||
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
|
||||
options={options}
|
||||
className="w-48 md:w-64 lg:w-72"
|
||||
notFoundContent={
|
||||
loading ? (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
) : (
|
||||
`No ${config.selectorLabel.toLowerCase()}s available`
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Combobox
|
||||
items={options}
|
||||
value={selected}
|
||||
onValueChange={(option: SelectorOption | null) => onChange(option?.value ?? "")}
|
||||
isItemEqualToValue={(a: SelectorOption, b: SelectorOption) => a.value === b.value}
|
||||
itemToStringLabel={(option: SelectorOption) => option.label}
|
||||
filter={matchesQuery}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder={loading ? `Loading ${noun}s...` : config.selectorPlaceholder}
|
||||
className="w-48 md:w-64 lg:w-72"
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>
|
||||
{loading ? (
|
||||
<span aria-busy="true" className="flex items-center justify-center py-2">
|
||||
<UiLoadingSpinner className="size-4" />
|
||||
</span>
|
||||
) : (
|
||||
`No ${noun}s available`
|
||||
)}
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: SelectorOption) => (
|
||||
<ComboboxItem key={option.value} value={option}>
|
||||
{option.label}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
41
ui/litellm-dashboard/src/components/ui/slider.tsx
Normal file
41
ui/litellm-dashboard/src/components/ui/slider.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { Slider as SliderPrimitive } from "@base-ui/react/slider";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
function Slider({ className, defaultValue, value, min = 0, max = 100, ...props }: SliderPrimitive.Root.Props) {
|
||||
const _values = Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max];
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
className={cn("data-horizontal:w-full data-vertical:h-full", className)}
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
thumbAlignment="edge"
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Control className="relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col">
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className="relative grow overflow-hidden rounded-full bg-muted select-none data-horizontal:h-1.5 data-horizontal:w-full data-vertical:h-full data-vertical:w-1.5"
|
||||
>
|
||||
<SliderPrimitive.Indicator
|
||||
data-slot="slider-range"
|
||||
className="bg-primary select-none data-horizontal:h-full data-vertical:w-full"
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="block size-4 shrink-0 rounded-full border border-primary bg-white shadow-sm ring-ring/50 transition-[color,box-shadow] select-none hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Control>
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Slider };
|
||||
Loading…
Add table
Reference in a new issue