refactor(ui): migrate playground to shadcn

Replaces antd and Tremor with shadcn primitives across the six route-owned,
form-free playground components: the compare view and its panel, message input
and unified selector, plus the realtime playground and the agent builder.

Markup only, no behaviour change. The characterisation tests added in the
previous commit are untouched here and stay green through the swap.

Adds ui/slider.tsx via the shadcn CLI and retires the six antd
no-restricted-imports suppressions the migration made obsolete.
This commit is contained in:
Yuneng Jiang 2026-08-13 15:59:53 -07:00
parent 923a49a6f8
commit 9a26da90a4
No known key found for this signature in database
8 changed files with 514 additions and 414 deletions

View file

@ -1055,9 +1055,6 @@
"no-nested-ternary": {
"count": 2
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 5
}
@ -1098,9 +1095,6 @@
"no-nested-ternary": {
"count": 2
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/immutability": {
"count": 2
},
@ -1115,9 +1109,6 @@
"no-nested-ternary": {
"count": 4
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -1125,9 +1116,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": {
@ -1135,21 +1123,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
@ -3582,6 +3560,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

View file

@ -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. Wed love your feedbackemail 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,231 @@ 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
&amp; 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 &amp;
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 &quot;{selectedAgent?.model_name}&quot;? This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction variant="outline">Cancel</AlertDialogAction>
{/* Not AlertDialogAction: that closes on click, and the dialog must stay up while the delete is in flight */}
<Button variant="destructive" onClick={handleConfirmDelete} disabled={deleting}>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -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">

View file

@ -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,35 @@ 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>
{/* A disabled button emits no pointer events, so the hover target has to be a wrapper */}
<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 +820,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 +838,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>

View file

@ -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>

View file

@ -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>
);

View file

@ -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>
);
}

View 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 };