[Feature] UI - Model Compare (#16855)

* Temp commit for branch switching, Compare WIP

* Model Compare UI
This commit is contained in:
yuneng-jiang 2025-11-19 19:07:11 -08:00 committed by GitHub
parent 65ff1eff56
commit d22ea6f15f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 1736 additions and 92 deletions

View file

@ -0,0 +1,58 @@
"use client";
import { useState, useEffect } from "react";
import ChatUI from "@/components/playground/chat_ui/ChatUI";
import CompareUI from "@/components/playground/compareUI/CompareUI";
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { fetchProxySettings } from "@/utils/proxyUtils";
interface ProxySettings {
PROXY_BASE_URL?: string;
LITELLM_UI_API_DOC_BASE_URL?: string | null;
}
export default function PlaygroundPage() {
const { accessToken, userRole, userId, disabledPersonalKeyCreation, token } = useAuthorized();
const [proxySettings, setProxySettings] = useState<ProxySettings | undefined>(undefined);
useEffect(() => {
const initializeProxySettings = async () => {
if (accessToken) {
const settings = await fetchProxySettings(accessToken);
if (settings) {
setProxySettings({
PROXY_BASE_URL: settings.PROXY_BASE_URL,
LITELLM_UI_API_DOC_BASE_URL: settings.LITELLM_UI_API_DOC_BASE_URL,
});
}
}
};
initializeProxySettings();
}, [accessToken]);
return (
<TabGroup className="h-full w-full">
<TabList className="mb-0">
<Tab>Chat</Tab>
<Tab>Compare</Tab>
</TabList>
<TabPanels className="h-full">
<TabPanel className="h-full">
<ChatUI
accessToken={accessToken}
token={token}
userRole={userRole}
userID={userId}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
proxySettings={proxySettings}
/>
</TabPanel>
<TabPanel className="h-full">
<CompareUI accessToken={accessToken} disabledPersonalKeyCreation={disabledPersonalKeyCreation} />
</TabPanel>
</TabPanels>
</TabGroup>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import ChatUI from "@/components/chat_ui/ChatUI";
import ChatUI from "@/components/playground/chat_ui/ChatUI";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useState, useEffect } from "react";
import { fetchProxySettings } from "@/utils/proxyUtils";

View file

@ -22,7 +22,7 @@ import ModelHubTable from "@/components/model_hub_table";
import PublicModelHub from "@/components/public_model_hub";
import NewUsagePage from "@/components/new_usage";
import APIReferenceView from "@/app/(dashboard)/api-reference/APIReferenceView";
import ChatUI from "@/components/chat_ui/ChatUI";
import PlaygroundPage from "@/app/(dashboard)/playground/page";
import Usage from "@/components/usage";
import CacheDashboard from "@/components/cache_dashboard";
import { getUiConfig, proxyBaseUrl, setGlobalLitellmHeaderName } from "@/components/networking";
@ -364,17 +364,7 @@ export default function CreateKeyPage() {
teams={teams}
/>
) : page == "llm-playground" ? (
<ChatUI
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
proxySettings={{
PROXY_BASE_URL: proxySettings.PROXY_BASE_URL,
LITELLM_UI_API_DOC_BASE_URL: proxySettings.LITELLM_UI_API_DOC_BASE_URL,
}}
/>
<PlaygroundPage />
) : page == "users" ? (
<ViewUserDashboard
userID={userID}

View file

@ -7,7 +7,7 @@ import { Button } from "@tremor/react";
import { SearchSelect, SearchSelectItem } from "@tremor/react";
import { setCallbacksCall } from "./networking";
import { Modal, Form } from "antd";
import { fetchAvailableModels, ModelGroup } from "./chat_ui/llm_calls/fetch_models";
import { fetchAvailableModels, ModelGroup } from "./playground/llm_calls/fetch_models";
import NotificationManager from "./molecules/notifications_manager";
interface AddFallbacksProps {

View file

@ -6,7 +6,7 @@ import { modelAvailableCall } from "../networking";
import ConnectionErrorDisplay from "./model_connection_test";
import { all_admin_roles } from "@/utils/roles";
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
import { fetchAvailableModels, ModelGroup } from "../chat_ui/llm_calls/fetch_models";
import { fetchAvailableModels, ModelGroup } from "../playground/llm_calls/fetch_models";
import RouterConfigBuilder from "./router_config_builder";
import NotificationManager from "../molecules/notifications_manager";

View file

@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react";
import { Card, Button, Input, InputNumber, Select as AntdSelect, Tooltip, Collapse } from "antd";
import { PlusOutlined, DeleteOutlined, InfoCircleOutlined, DownOutlined } from "@ant-design/icons";
import { Text } from "@tremor/react";
import { ModelGroup } from "../chat_ui/llm_calls/fetch_models";
import { ModelGroup } from "../playground/llm_calls/fetch_models";
const { TextArea } = Input;
const { Panel } = Collapse;

View file

@ -4,7 +4,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { NumberInput, TextInput } from "@tremor/react";
import { Select } from "antd";
import React, { useEffect, useState } from "react";
import { fetchAvailableModels, ModelGroup } from "../chat_ui/llm_calls/fetch_models";
import { fetchAvailableModels, ModelGroup } from "../playground/llm_calls/fetch_models";
import NumericalInput from "../shared/numerical_input";
interface CacheFieldRendererProps {

View file

@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from "react";
import { TextInput, Text } from "@tremor/react";
import { Select } from "antd";
import { RobotOutlined } from "@ant-design/icons";
import { fetchAvailableModels, ModelGroup } from "../chat_ui/llm_calls/fetch_models";
import { fetchAvailableModels, ModelGroup } from "../playground/llm_calls/fetch_models";
interface ModelSelectorProps {
accessToken: string;

View file

@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import { Modal, Form, Button, Select as AntdSelect } from "antd";
import { Text, TextInput } from "@tremor/react";
import { modelAvailableCall, modelPatchUpdateCall } from "../networking";
import { fetchAvailableModels, ModelGroup } from "../chat_ui/llm_calls/fetch_models";
import { fetchAvailableModels, ModelGroup } from "../playground/llm_calls/fetch_models";
import RouterConfigBuilder from "../add_model/router_config_builder";
import NotificationsManager from "../molecules/notifications_manager";

View file

@ -33,7 +33,7 @@ describe("Sidebar (leftnav)", () => {
const topLevelLabels = [
"Virtual Keys",
"Test Key",
"Playground",
"Models + Endpoints",
"Usage",
"Teams",

View file

@ -57,7 +57,7 @@ const Sidebar: React.FC<SidebarProps> = ({ accessToken, setPage, userRole, defau
{
key: "3",
page: "llm-playground",
label: "Test Key",
label: "Playground",
icon: <PlayCircleOutlined style={{ fontSize: "18px" }} />,
roles: rolesWithWriteAccess,
},
@ -148,11 +148,11 @@ const Sidebar: React.FC<SidebarProps> = ({ accessToken, setPage, userRole, defau
roles: all_admin_roles,
},
{
"key": "29",
"page": "agents",
"label": "Agents",
"icon": <RobotOutlined style={{ fontSize: "18px" }} />,
"roles": rolesWithWriteAccess,
key: "29",
page: "agents",
label: "Agents",
icon: <RobotOutlined style={{ fontSize: "18px" }} />,
roles: rolesWithWriteAccess,
},
{
key: "25",
@ -258,7 +258,7 @@ const Sidebar: React.FC<SidebarProps> = ({ accessToken, setPage, userRole, defau
});
return (
<Layout style={{ minHeight: "100vh" }}>
<Layout>
<Sider
theme="light"
width={220}

View file

@ -1,10 +1,10 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ChatUI from "./ChatUI";
import * as fetchModelsModule from "./llm_calls/fetch_models";
import * as fetchModelsModule from "../llm_calls/fetch_models";
// Mock the fetchAvailableModels function
vi.mock("./llm_calls/fetch_models", () => ({
vi.mock("../llm_calls/fetch_models", () => ({
fetchAvailableModels: vi.fn(),
}));
@ -32,7 +32,7 @@ describe("ChatUI", () => {
Element.prototype.scrollIntoView = vi.fn();
// Mock the fetchAvailableModels to return test models
vi.mocked(fetchModelsModule.fetchAvailableModels).mockResolvedValue([
(fetchModelsModule.fetchAvailableModels as any).mockResolvedValue([
{ model_group: "Model 1", mode: "chat" },
{ model_group: "Model 2", mode: "chat" },
{ model_group: "Model 3", mode: "chat" },
@ -134,7 +134,7 @@ describe("ChatUI", () => {
});
it("shows only chat-compatible models when chat endpoint is selected", async () => {
vi.mocked(fetchModelsModule.fetchAvailableModels).mockResolvedValueOnce([
(fetchModelsModule.fetchAvailableModels as any).mockResolvedValueOnce([
{ model_group: "ChatModel", mode: "chat" },
{ model_group: "SpeechModel", mode: "audio_speech" },
{ model_group: "ImageModel", mode: "image_generation" },

View file

@ -1,3 +1,5 @@
"use client";
import {
ApiOutlined,
ArrowUpOutlined,
@ -25,11 +27,11 @@ import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { v4 as uuidv4 } from "uuid";
import { truncateString } from "../../utils/textUtils";
import GuardrailSelector from "../guardrails/GuardrailSelector";
import NotificationsManager from "../molecules/notifications_manager";
import TagSelector from "../tag_management/TagSelector";
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
import { truncateString } from "../../../utils/textUtils";
import GuardrailSelector from "../../guardrails/GuardrailSelector";
import NotificationsManager from "../../molecules/notifications_manager";
import TagSelector from "../../tag_management/TagSelector";
import VectorStoreSelector from "../../vector_store_management/VectorStoreSelector";
import AdditionalModelSettings from "./AdditionalModelSettings";
import AudioRenderer from "./AudioRenderer";
import { OPEN_AI_VOICE_SELECT_OPTIONS, OpenAIVoice } from "./chatConstants";
@ -38,17 +40,17 @@ import ChatImageUpload from "./ChatImageUpload";
import { createChatDisplayMessage, createChatMultimodalMessage } from "./ChatImageUtils";
import { generateCodeSnippet } from "./CodeSnippets";
import EndpointSelector from "./EndpointSelector";
import { makeAnthropicMessagesRequest } from "./llm_calls/anthropic_messages";
import { makeOpenAIAudioSpeechRequest } from "./llm_calls/audio_speech";
import { makeOpenAIAudioTranscriptionRequest } from "./llm_calls/audio_transcriptions";
import { makeOpenAIChatCompletionRequest } from "./llm_calls/chat_completion";
import { makeOpenAIEmbeddingsRequest } from "./llm_calls/embeddings_api";
import type { MCPTool } from "./llm_calls/fetch_mcp_tools";
import { fetchAvailableMCPTools } from "./llm_calls/fetch_mcp_tools";
import { fetchAvailableModels, ModelGroup } from "./llm_calls/fetch_models";
import { makeOpenAIImageEditsRequest } from "./llm_calls/image_edits";
import { makeOpenAIImageGenerationRequest } from "./llm_calls/image_generation";
import { makeOpenAIResponsesRequest } from "./llm_calls/responses_api";
import { makeAnthropicMessagesRequest } from "../llm_calls/anthropic_messages";
import { makeOpenAIAudioSpeechRequest } from "../llm_calls/audio_speech";
import { makeOpenAIAudioTranscriptionRequest } from "../llm_calls/audio_transcriptions";
import { makeOpenAIChatCompletionRequest } from "../llm_calls/chat_completion";
import { makeOpenAIEmbeddingsRequest } from "../llm_calls/embeddings_api";
import type { MCPTool } from "../llm_calls/fetch_mcp_tools";
import { fetchAvailableMCPTools } from "../llm_calls/fetch_mcp_tools";
import { fetchAvailableModels, ModelGroup } from "../llm_calls/fetch_models";
import { makeOpenAIImageEditsRequest } from "../llm_calls/image_edits";
import { makeOpenAIImageGenerationRequest } from "../llm_calls/image_generation";
import { makeOpenAIResponsesRequest } from "../llm_calls/responses_api";
import MCPEventsDisplay, { MCPEvent } from "./MCPEventsDisplay";
import { EndpointType, getEndpointType } from "./mode_endpoint_mapping";
import ReasoningContent from "./ReasoningContent";
@ -467,6 +469,24 @@ const ChatUI: React.FC<ChatUIProps> = ({
});
};
const updateTotalLatency = (totalLatency: number) => {
setChatHistory((prevHistory) => {
const lastMessage = prevHistory[prevHistory.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
return [
...prevHistory.slice(0, prevHistory.length - 1),
{
...lastMessage,
totalLatency,
},
];
}
return prevHistory;
});
};
const updateSearchResults = (searchResults: any[]) => {
console.log("Received search results:", searchResults);
setChatHistory((prevHistory) => {
@ -767,11 +787,12 @@ const ChatUI: React.FC<ChatUIProps> = ({
traceId,
selectedVectorStores.length > 0 ? selectedVectorStores : undefined,
selectedGuardrails.length > 0 ? selectedGuardrails : undefined,
selectedMCPTools, // Pass the selected tools array
updateChatImageUI, // Pass the image callback
updateSearchResults, // Pass the search results callback
useAdvancedParams ? temperature : undefined, // Pass temperature if enabled
useAdvancedParams ? maxTokens : undefined, // Pass max_tokens if enabled
selectedMCPTools,
updateChatImageUI,
updateSearchResults,
useAdvancedParams ? temperature : undefined,
useAdvancedParams ? maxTokens : undefined,
updateTotalLatency,
);
} else if (endpointType === EndpointType.IMAGE) {
// For image generation
@ -973,7 +994,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
const antIcon = <LoadingOutlined style={{ fontSize: 24 }} spin />;
return (
<div className="w-full h-screen p-4 bg-white">
<div className="w-full p-4 pb-0 bg-white">
<Card className="w-full rounded-xl shadow-md overflow-hidden">
<div className="flex h-[80vh] w-full gap-4">
{/* Left Sidebar with Controls */}
@ -1418,13 +1439,15 @@ const ChatUI: React.FC<ChatUIProps> = ({
</>
)}
{message.role === "assistant" && (message.timeToFirstToken || message.usage) && (
<ResponseMetrics
timeToFirstToken={message.timeToFirstToken}
usage={message.usage}
toolName={message.toolName}
/>
)}
{message.role === "assistant" &&
(message.timeToFirstToken || message.totalLatency || message.usage) && (
<ResponseMetrics
timeToFirstToken={message.timeToFirstToken}
totalLatency={message.totalLatency}
usage={message.usage}
toolName={message.toolName}
/>
)}
</div>
</div>
</div>

View file

@ -1,4 +1,4 @@
import { ModelGroup } from "./llm_calls/fetch_models";
import { ModelGroup } from "../llm_calls/fetch_models";
import { EndpointType, getEndpointType } from "./mode_endpoint_mapping";
/**

View file

@ -7,6 +7,7 @@ import {
ExportOutlined,
BulbOutlined,
ToolOutlined,
DollarOutlined,
} from "@ant-design/icons";
export interface TokenUsage {
@ -14,16 +15,18 @@ export interface TokenUsage {
promptTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
cost?: number;
}
interface ResponseMetricsProps {
timeToFirstToken?: number; // in milliseconds
timeToFirstToken?: number;
totalLatency?: number;
usage?: TokenUsage;
toolName?: string;
}
const ResponseMetrics: React.FC<ResponseMetricsProps> = ({ timeToFirstToken, usage, toolName }) => {
if (!timeToFirstToken && !usage) return null;
const ResponseMetrics: React.FC<ResponseMetricsProps> = ({ timeToFirstToken, totalLatency, usage, toolName }) => {
if (!timeToFirstToken && !totalLatency && !usage) return null;
return (
<div className="response-metrics mt-2 pt-2 border-t border-gray-100 text-xs text-gray-500 flex flex-wrap gap-3">
@ -31,7 +34,16 @@ const ResponseMetrics: React.FC<ResponseMetricsProps> = ({ timeToFirstToken, usa
<Tooltip title="Time to first token">
<div className="flex items-center">
<ClockCircleOutlined className="mr-1" />
<span>{(timeToFirstToken / 1000).toFixed(2)}s</span>
<span>TTFT: {(timeToFirstToken / 1000).toFixed(2)}s</span>
</div>
</Tooltip>
)}
{totalLatency !== undefined && (
<Tooltip title="Total latency">
<div className="flex items-center">
<ClockCircleOutlined className="mr-1" />
<span>Total Latency: {(totalLatency / 1000).toFixed(2)}s</span>
</div>
</Tooltip>
)}
@ -72,6 +84,21 @@ const ResponseMetrics: React.FC<ResponseMetricsProps> = ({ timeToFirstToken, usa
</Tooltip>
)}
{usage && (
<Tooltip
title={
usage.cost !== undefined
? "Cost"
: "Cost tracking is disabled. Set include_cost_in_streaming_usage: true in your proxy config to enable cost tracking."
}
>
<div className="flex items-center">
<DollarOutlined className="mr-1" />
<span>{usage.cost !== undefined ? `$${usage.cost.toFixed(6)}` : "Not Tracked"}</span>
</div>
</Tooltip>
)}
{toolName && (
<Tooltip title="Tool used">
<div className="flex items-center">

View file

@ -2,7 +2,7 @@ import React from "react";
import { Switch, Tooltip } from "antd";
import { InfoCircleOutlined, CopyOutlined } from "@ant-design/icons";
import { EndpointType } from "./mode_endpoint_mapping";
import NotificationsManager from "../molecules/notifications_manager";
import NotificationsManager from "../../molecules/notifications_manager";
interface SessionManagementProps {
endpointType: string;

View file

@ -78,14 +78,16 @@ export interface MessageType {
isAudio?: boolean;
reasoningContent?: string;
timeToFirstToken?: number;
totalLatency?: number;
usage?: {
completionTokens?: number;
promptTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
cost?: number;
};
toolName?: string;
imagePreviewUrl?: string; // For storing image preview URL in chat history
imagePreviewUrl?: string;
image?: {
url: string;
detail: string;

View file

@ -0,0 +1,91 @@
import { render, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import CompareUI from "./CompareUI";
vi.mock("../llm_calls/fetch_models", () => ({
fetchAvailableModels: vi.fn().mockResolvedValue([{ model_group: "gpt-4" }, { model_group: "gpt-3.5-turbo" }]),
}));
vi.mock("../llm_calls/chat_completion", () => ({
makeOpenAIChatCompletionRequest: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./components/ComparisonPanel", () => ({
ComparisonPanel: ({ comparison, onRemove }: { comparison: any; onRemove: () => void }) => (
<div data-testid={`comparison-panel-${comparison.id}`}>
<button data-testid={`remove-${comparison.id}`} onClick={onRemove}>
Remove
</button>
</div>
),
}));
vi.mock("./components/MessageInput", () => ({
MessageInput: ({ value, onChange, onSend, disabled }: any) => (
<div data-testid="message-input">
<textarea
data-testid="message-textarea"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
/>
<button data-testid="send-button" onClick={onSend} disabled={disabled}>
Send
</button>
</div>
),
}));
beforeEach(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
});
describe("CompareUI", () => {
it("should render", () => {
const { getByTestId } = render(<CompareUI accessToken="test-token" disabledPersonalKeyCreation={false} />);
expect(getByTestId("comparison-panel-1")).toBeInTheDocument();
expect(getByTestId("comparison-panel-2")).toBeInTheDocument();
expect(getByTestId("message-input")).toBeInTheDocument();
});
it("adds a comparison when Add Comparison button is clicked", async () => {
const user = userEvent.setup();
const { container, getByTestId } = render(
<CompareUI accessToken="test-token" disabledPersonalKeyCreation={false} />,
);
// Verify initial state: 2 comparison panels
expect(getByTestId("comparison-panel-1")).toBeInTheDocument();
expect(getByTestId("comparison-panel-2")).toBeInTheDocument();
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!);
// Wait for the new comparison panel to be added (should have 3 total now)
await waitFor(() => {
comparisonPanels = container.querySelectorAll('[data-testid^="comparison-panel-"]');
expect(comparisonPanels).toHaveLength(3);
});
// Verify the original 2 panels are still there
expect(getByTestId("comparison-panel-1")).toBeInTheDocument();
expect(getByTestId("comparison-panel-2")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,670 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { Select, Input, Tooltip, Button } from "antd";
import { ClearOutlined, PlusOutlined } from "@ant-design/icons";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { fetchAvailableModels } from "../llm_calls/fetch_models";
import { makeOpenAIChatCompletionRequest } from "../llm_calls/chat_completion";
import type { TokenUsage } from "../chat_ui/ResponseMetrics";
import type { MessageType, VectorStoreSearchResponse } from "../chat_ui/types";
import { ComparisonPanel } from "./components/ComparisonPanel";
import { MessageInput } from "./components/MessageInput";
export interface ComparisonInstance {
id: string;
model: string;
messages: MessageType[];
isLoading: boolean;
tags: string[];
mcpTools: string[];
vectorStores: string[];
guardrails: string[];
temperature: number;
maxTokens: number;
applyAcrossModels: boolean;
useAdvancedParams: boolean;
traceId?: string;
}
interface CompareUIProps {
accessToken: string | null;
disabledPersonalKeyCreation: boolean;
}
const GENERIC_FOLLOW_UPS = [
"Can you summarize the key points?",
"What assumptions did you make?",
"What are the next steps?",
];
const SUGGESTED_PROMPTS = ["Write me a poem", "Explain quantum computing", "Draft a polite email requesting a meeting"];
const DEFAULT_ENDPOINT = "/v1/chat/completions";
export default function CompareUI({ accessToken, disabledPersonalKeyCreation }: CompareUIProps) {
const [comparisons, setComparisons] = useState<ComparisonInstance[]>([
{
id: "1",
model: "",
messages: [],
isLoading: false,
tags: [],
mcpTools: [],
vectorStores: [],
guardrails: [],
temperature: 1,
maxTokens: 2048,
applyAcrossModels: false,
useAdvancedParams: false,
},
{
id: "2",
model: "",
messages: [],
isLoading: false,
tags: [],
mcpTools: [],
vectorStores: [],
guardrails: [],
temperature: 1,
maxTokens: 2048,
applyAcrossModels: false,
useAdvancedParams: false,
},
]);
const [modelOptions, setModelOptions] = useState<string[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [inputValue, setInputValue] = useState("");
const [apiKeySource, setApiKeySource] = useState<"session" | "custom">(
disabledPersonalKeyCreation ? "custom" : "session",
);
const [customApiKey, setCustomApiKey] = useState("");
const [debouncedCustomApiKey, setDebouncedCustomApiKey] = useState("");
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedCustomApiKey(customApiKey);
}, 300);
return () => clearTimeout(timer);
}, [customApiKey]);
const effectiveApiKey = useMemo(
() => (apiKeySource === "session" ? accessToken || "" : debouncedCustomApiKey.trim()),
[apiKeySource, accessToken, debouncedCustomApiKey],
);
const haveAllResponses = useMemo(
() =>
comparisons.length > 0 &&
comparisons.every(
(comparison) => !comparison.isLoading && comparison.messages.some((message) => message.role === "assistant"),
),
[comparisons],
);
useEffect(() => {
let active = true;
const loadModels = async () => {
if (!effectiveApiKey) {
setModelOptions([]);
return;
}
setIsLoadingModels(true);
try {
const uniqueModels = await fetchAvailableModels(effectiveApiKey);
if (!active) return;
const nextOptions = Array.from(new Set(uniqueModels.map((model) => model.model_group)));
setModelOptions(nextOptions);
} catch (error) {
console.error("CompareUI: failed to fetch models", error);
if (active) {
setModelOptions([]);
}
} finally {
if (active) {
setIsLoadingModels(false);
}
}
};
loadModels();
return () => {
active = false;
};
}, [effectiveApiKey]);
useEffect(() => {
if (modelOptions.length === 0) {
return;
}
setComparisons((prev) =>
prev.map((comparison, index) => {
return {
...comparison,
temperature: comparison.temperature ?? 1,
maxTokens: comparison.maxTokens ?? 2048,
applyAcrossModels: comparison.applyAcrossModels ?? false,
useAdvancedParams: comparison.useAdvancedParams ?? false,
...(comparison.model
? {}
: {
model: modelOptions[index % modelOptions.length] ?? "",
}),
};
}),
);
}, [modelOptions]);
const maxComparisons = 3;
const addComparison = () => {
if (comparisons.length >= maxComparisons) {
return;
}
const fallback = modelOptions[comparisons.length % (modelOptions.length || 1)] ?? "";
const newComparison: ComparisonInstance = {
id: Date.now().toString(),
model: fallback,
messages: [],
isLoading: false,
tags: [],
mcpTools: [],
vectorStores: [],
guardrails: [],
temperature: 1,
maxTokens: 2048,
applyAcrossModels: false,
useAdvancedParams: false,
};
setComparisons((prev) => [...prev, newComparison]);
};
const removeComparison = (id: string) => {
if (comparisons.length > 1) {
setComparisons((prev) => {
const next = prev.filter((c) => c.id !== id);
return next;
});
}
};
type UpdateOptions = {
applyToAll?: boolean;
keysToApply?: (keyof ComparisonInstance)[];
};
const updateComparison = (id: string, updates: Partial<ComparisonInstance>, options?: UpdateOptions) => {
setComparisons((prev) => {
if (options?.applyToAll && options.keysToApply?.length) {
const sharedUpdates: Partial<ComparisonInstance> = {};
options.keysToApply.forEach((key) => {
const value = updates[key];
if (value !== undefined) {
sharedUpdates[key] = Array.isArray(value) ? ([...value] as any) : (value as any);
}
});
const hasSharedUpdates = Object.keys(sharedUpdates).length > 0;
return prev.map((comparison) => {
if (comparison.id === id) {
return {
...comparison,
...updates,
};
}
if (!hasSharedUpdates) {
return comparison;
}
return {
...comparison,
...sharedUpdates,
};
});
}
return prev.map((comparison) =>
comparison.id === id
? {
...comparison,
...updates,
}
: comparison,
);
});
};
const clearAllChats = () => {
setComparisons((prev) =>
prev.map((comparison) => ({
...comparison,
messages: [],
traceId: undefined,
isLoading: false,
})),
);
setInputValue("");
};
const appendAssistantChunk = (comparisonId: string, chunk: string, model?: string) => {
if (!chunk) {
return;
}
setComparisons((prev) =>
prev.map((comparison) => {
if (comparison.id !== comparisonId) {
return comparison;
}
const messages = [...comparison.messages];
const last = messages[messages.length - 1];
if (last && last.role === "assistant") {
const existingContent = typeof last.content === "string" ? last.content : "";
messages[messages.length - 1] = {
...last,
content: existingContent + chunk,
model: last.model ?? model,
};
} else {
messages.push({
role: "assistant",
content: chunk,
model,
});
}
return {
...comparison,
messages,
};
}),
);
};
const appendReasoningContent = (comparisonId: string, chunk: string) => {
if (!chunk) {
return;
}
setComparisons((prev) =>
prev.map((comparison) => {
if (comparison.id !== comparisonId) {
return comparison;
}
const messages = [...comparison.messages];
const last = messages[messages.length - 1];
if (last && last.role === "assistant") {
messages[messages.length - 1] = {
...last,
reasoningContent: (last.reasoningContent || "") + chunk,
};
} else if (last && last.role === "user") {
messages.push({
role: "assistant",
content: "",
reasoningContent: chunk,
});
}
return {
...comparison,
messages,
};
}),
);
};
const updateTimingDataForComparison = (comparisonId: string, timeToFirstToken: number) => {
setComparisons((prev) =>
prev.map((comparison) => {
if (comparison.id !== comparisonId) {
return comparison;
}
const messages = [...comparison.messages];
const last = messages[messages.length - 1];
if (last && last.role === "assistant") {
messages[messages.length - 1] = {
...last,
timeToFirstToken,
};
} else if (last && last.role === "user") {
messages.push({
role: "assistant",
content: "",
timeToFirstToken,
});
}
return {
...comparison,
messages,
};
}),
);
};
const updateTotalLatencyForComparison = (comparisonId: string, totalLatency: number) => {
setComparisons((prev) =>
prev.map((comparison) => {
if (comparison.id !== comparisonId) {
return comparison;
}
const messages = [...comparison.messages];
const last = messages[messages.length - 1];
if (last && last.role === "assistant") {
messages[messages.length - 1] = {
...last,
totalLatency,
};
} else if (last && last.role === "user") {
messages.push({
role: "assistant",
content: "",
totalLatency,
});
}
return {
...comparison,
messages,
};
}),
);
};
const updateUsageDataForComparison = (comparisonId: string, usage: TokenUsage, toolName?: string) => {
setComparisons((prev) =>
prev.map((comparison) => {
if (comparison.id !== comparisonId) {
return comparison;
}
const messages = [...comparison.messages];
const last = messages[messages.length - 1];
if (last && last.role === "assistant") {
messages[messages.length - 1] = {
...last,
usage,
toolName,
};
}
return {
...comparison,
messages,
};
}),
);
};
const updateSearchResultsForComparison = (comparisonId: string, searchResults: VectorStoreSearchResponse[]) => {
if (!searchResults) {
return;
}
setComparisons((prev) =>
prev.map((comparison) => {
if (comparison.id !== comparisonId) {
return comparison;
}
const messages = [...comparison.messages];
const last = messages[messages.length - 1];
if (last && last.role === "assistant") {
messages[messages.length - 1] = {
...last,
searchResults,
};
}
return {
...comparison,
messages,
};
}),
);
};
const canUseSessionKey = Boolean(accessToken);
const handleSendMessage = (input: string) => {
const trimmed = input.trim();
if (!trimmed) {
return;
}
if (!effectiveApiKey) {
NotificationsManager.fromBackend("Please provide an API key or select Current UI Session");
return;
}
const targetComparisons = comparisons;
if (targetComparisons.length === 0) {
return;
}
if (targetComparisons.some((comparison) => !comparison.model)) {
NotificationsManager.fromBackend("Select a model before sending a message.");
return;
}
const preparedTargets = new Map<
string,
{
id: string;
model: string;
traceId: string;
tags: string[];
vectorStores: string[];
guardrails: string[];
temperature: number;
maxTokens: number;
messages: MessageType[];
}
>();
targetComparisons.forEach((comparison) => {
const traceId = comparison.traceId ?? uuidv4();
const userMessage: MessageType = {
role: "user",
content: trimmed,
};
preparedTargets.set(comparison.id, {
id: comparison.id,
model: comparison.model,
traceId,
tags: comparison.tags,
vectorStores: comparison.vectorStores,
guardrails: comparison.guardrails,
temperature: comparison.temperature,
maxTokens: comparison.maxTokens,
messages: [...comparison.messages, userMessage],
});
});
if (preparedTargets.size === 0) {
return;
}
setComparisons((prev) =>
prev.map((comparison) => {
const prepared = preparedTargets.get(comparison.id);
if (!prepared) {
return comparison;
}
return {
...comparison,
traceId: prepared.traceId,
messages: prepared.messages,
isLoading: true,
};
}),
);
preparedTargets.forEach((prepared) => {
const apiChatHistory = prepared.messages.map(({ role, content }) => ({
role,
content: typeof content === "string" ? content : "",
}));
const tags = prepared.tags.length > 0 ? prepared.tags : undefined;
const vectorStoreIds = prepared.vectorStores.length > 0 ? prepared.vectorStores : undefined;
const guardrails = prepared.guardrails.length > 0 ? prepared.guardrails : undefined;
const comparison = comparisons.find((c) => c.id === prepared.id);
const useAdvancedParams = comparison?.useAdvancedParams ?? false;
makeOpenAIChatCompletionRequest(
apiChatHistory,
(chunk, model) => appendAssistantChunk(prepared.id, chunk, model),
prepared.model,
effectiveApiKey,
tags,
undefined,
(content) => appendReasoningContent(prepared.id, content),
(time) => updateTimingDataForComparison(prepared.id, time),
(usage) => updateUsageDataForComparison(prepared.id, usage),
prepared.traceId,
vectorStoreIds,
guardrails,
undefined,
undefined,
(searchResults) => updateSearchResultsForComparison(prepared.id, searchResults),
useAdvancedParams ? prepared.temperature : undefined,
useAdvancedParams ? prepared.maxTokens : undefined,
(latency) => updateTotalLatencyForComparison(prepared.id, latency),
)
.catch((error) => {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("CompareUI: failed to fetch response", error);
NotificationsManager.fromBackend(errorMessage);
setComparisons((prev) =>
prev.map((comparison) => {
if (comparison.id !== prepared.id) {
return comparison;
}
const messages = [...comparison.messages];
const last = messages[messages.length - 1];
const assistantContent =
last && last.role === "assistant" && typeof last.content === "string" ? last.content : "";
if (last && last.role === "assistant") {
messages[messages.length - 1] = {
...last,
content: assistantContent
? `${assistantContent}\nError fetching response: ${errorMessage}`
: `Error fetching response: ${errorMessage}`,
};
} else {
messages.push({
role: "assistant",
content: `Error fetching response: ${errorMessage}`,
});
}
return {
...comparison,
messages,
};
}),
);
})
.finally(() => {
setComparisons((prev) =>
prev.map((comparison) =>
comparison.id === prepared.id
? {
...comparison,
isLoading: false,
}
: comparison,
),
);
});
});
};
const handleInputChange = (value: string) => {
setInputValue(value);
};
const handleSubmit = () => {
handleSendMessage(inputValue);
setInputValue("");
};
const handleFollowUpSelect = (question: string) => {
setInputValue(question);
};
const hasMessages = comparisons.some((comparison) => comparison.messages.length > 0);
const isAnyComparisonLoading = comparisons.some((comparison) => comparison.isLoading);
const showSuggestedPrompts = !hasMessages && !isAnyComparisonLoading;
return (
<div className="w-full h-full p-4 bg-white">
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm min-h-[calc(100vh-140px)] flex flex-col">
<div className="border-b px-4 py-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-600">API Key Source</span>
<Select
value={apiKeySource}
onChange={(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>
</Select>
{apiKeySource === "custom" && (
<Input.Password
value={customApiKey}
onChange={(event) => setCustomApiKey(event.target.value)}
placeholder="Enter API key"
className="w-56"
/>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-600">Endpoint</span>
<Tooltip title="Other endpoints will be available soon">
<Select value={DEFAULT_ENDPOINT} disabled className="w-56">
<Select.Option value={DEFAULT_ENDPOINT}>{DEFAULT_ENDPOINT}</Select.Option>
</Select>
</Tooltip>
</div>
<div className="flex items-center gap-3">
<Button onClick={clearAllChats} disabled={!hasMessages} icon={<ClearOutlined />}>
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>
</div>
</div>
</div>
<div
className="grid flex-1 min-h-0 auto-rows-[minmax(0,1fr)]"
style={{
gridTemplateColumns: `repeat(${comparisons.length}, minmax(0, 1fr))`,
}}
>
{comparisons.map((comparison) => (
<ComparisonPanel
key={comparison.id}
comparison={comparison}
onUpdate={(updates, options) => updateComparison(comparison.id, updates, options)}
onRemove={() => removeComparison(comparison.id)}
canRemove={comparisons.length > 1}
modelOptions={modelOptions}
isLoadingModels={isLoadingModels}
apiKey={effectiveApiKey}
/>
))}
</div>
<div className="flex justify-center pb-4">
<div className="w-full max-w-3xl px-4">
<div className="border border-gray-200 shadow-lg rounded-xl bg-white p-4">
<div className="flex items-center justify-between gap-4 mb-3 min-h-8">
{showSuggestedPrompts ? (
<div className="flex items-center gap-2 overflow-x-auto">
{SUGGESTED_PROMPTS.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => handleFollowUpSelect(prompt)}
className="shrink-0 rounded-full border border-gray-200 px-3 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 cursor-pointer"
>
{prompt}
</button>
))}
</div>
) : haveAllResponses ? (
<div className="flex items-center gap-2 overflow-x-auto">
{GENERIC_FOLLOW_UPS.map((question) => (
<button
key={question}
type="button"
onClick={() => handleFollowUpSelect(question)}
className="shrink-0 rounded-full border border-gray-200 px-3 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 cursor-pointer"
>
{question}
</button>
))}
</div>
) : isAnyComparisonLoading ? (
<span className="flex items-center gap-2 text-sm text-gray-500">
<span className="h-2 w-2 rounded-full bg-blue-500 animate-pulse" aria-hidden />
Gathering responses from all models...
</span>
) : (
<span className="text-sm text-gray-500">Send a prompt to compare models</span>
)}
</div>
<MessageInput
value={inputValue}
onChange={handleInputChange}
onSend={handleSubmit}
disabled={comparisons.length === 0 || comparisons.every((comparison) => comparison.isLoading)}
/>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,89 @@
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ComparisonInstance } from "../CompareUI";
import { ComparisonPanel } from "./ComparisonPanel";
vi.mock("./MessageDisplay", () => ({
MessageDisplay: () => <div data-testid="message-display">MessageDisplay</div>,
}));
vi.mock("./ModelSelector", () => ({
ModelSelector: ({ value, onChange }: { value: string; onChange: (val: string) => void }) => (
<select data-testid="model-selector" value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">Select model</option>
<option value="gpt-4">gpt-4</option>
</select>
),
}));
vi.mock("../../../tag_management/TagSelector", () => ({
default: () => <div data-testid="tag-selector">TagSelector</div>,
}));
vi.mock("../../../vector_store_management/VectorStoreSelector", () => ({
default: () => <div data-testid="vector-store-selector">VectorStoreSelector</div>,
}));
vi.mock("../../../guardrails/GuardrailSelector", () => ({
default: () => <div data-testid="guardrail-selector">GuardrailSelector</div>,
}));
beforeEach(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
});
const mockComparison: ComparisonInstance = {
id: "1",
model: "gpt-4",
messages: [],
isLoading: false,
tags: [],
mcpTools: [],
vectorStores: [],
guardrails: [],
temperature: 1,
maxTokens: 2048,
applyAcrossModels: false,
useAdvancedParams: false,
};
const mockProps = {
comparison: mockComparison,
onUpdate: vi.fn(),
onRemove: vi.fn(),
canRemove: true,
modelOptions: ["gpt-4", "gpt-3.5-turbo"],
isLoadingModels: false,
apiKey: "test-api-key",
};
describe("ComparisonPanel", () => {
it("should render", () => {
const { getByTestId } = render(<ComparisonPanel {...mockProps} />);
expect(getByTestId("model-selector")).toBeInTheDocument();
expect(getByTestId("message-display")).toBeInTheDocument();
});
it("should call onRemove when remove button is clicked", 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!);
expect(onRemove).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,251 @@
import { Settings, X } from "lucide-react";
import { useState } from "react";
import { ComparisonInstance } from "../CompareUI";
import { MessageDisplay } from "./MessageDisplay";
import { ModelSelector } from "./ModelSelector";
import TagSelector from "../../../tag_management/TagSelector";
import VectorStoreSelector from "../../../vector_store_management/VectorStoreSelector";
import GuardrailSelector from "../../../guardrails/GuardrailSelector";
import { Checkbox, Divider, Popover, Slider } from "antd";
interface ComparisonPanelProps {
comparison: ComparisonInstance;
onUpdate: (
updates: Partial<ComparisonInstance>,
options?: { applyToAll?: boolean; keysToApply?: (keyof ComparisonInstance)[] },
) => void;
onRemove: () => void;
canRemove: boolean;
modelOptions: string[];
isLoadingModels: boolean;
apiKey: string;
}
export function ComparisonPanel({
comparison,
onUpdate,
onRemove,
canRemove,
modelOptions,
isLoadingModels,
apiKey,
}: ComparisonPanelProps) {
const [popoverVisible, setPopoverVisible] = useState(false);
const handleSyncChange = (checked: boolean) => {
if (checked) {
onUpdate(
{
applyAcrossModels: true,
temperature: comparison.temperature,
maxTokens: comparison.maxTokens,
tags: [...comparison.tags],
vectorStores: [...comparison.vectorStores],
guardrails: [...comparison.guardrails],
useAdvancedParams: comparison.useAdvancedParams,
},
{
applyToAll: true,
keysToApply: ["temperature", "maxTokens", "tags", "vectorStores", "guardrails", "useAdvancedParams"],
},
);
} else {
// When unsyncing, just turn off the sync flag - don't reset values
onUpdate({
applyAcrossModels: false,
});
}
};
const handleAdvancedParamsChange = (checked: boolean) => {
onUpdate(
{
useAdvancedParams: checked,
},
comparison.applyAcrossModels ? { applyToAll: true, keysToApply: ["useAdvancedParams"] } : undefined,
);
};
const handleSettingChange = <K extends keyof ComparisonInstance>(key: K, value: ComparisonInstance[K]) => {
onUpdate(
{
[key]: value,
} as Partial<ComparisonInstance>,
comparison.applyAcrossModels ? { applyToAll: true, keysToApply: [key] } : undefined,
);
};
const disabledOpacity = comparison.useAdvancedParams ? 1 : 0.4;
const disabledTextColor = comparison.useAdvancedParams ? "text-gray-700" : "text-gray-400";
const handleTogglePopover = () => {
setPopoverVisible((prev) => !prev);
};
const handleClosePopover = () => {
setPopoverVisible(false);
};
const settingsContent = (
<div className="w-[300px] max-h-[65vh] overflow-y-auto relative">
{/* Close button in top right */}
<button
onClick={handleClosePopover}
className="absolute top-0 right-0 p-1 hover:bg-gray-100 rounded transition-colors text-gray-500 hover:text-gray-700 z-10"
>
<X size={14} />
</button>
<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>
</div>
<Divider className="border-gray-200" />
{/* General Settings */}
<div>
<h4 className="text-xs font-semibold text-gray-700 mb-1.5 uppercase tracking-wide">General Settings</h4>
<div className="space-y-2">
<div>
<label className="text-xs font-medium text-gray-600 block mb-0.5">Tags</label>
<TagSelector
value={comparison.tags}
onChange={(value) => handleSettingChange("tags", value)}
accessToken={apiKey}
/>
</div>
<div>
<label className="text-xs font-medium text-gray-600 block mb-0.5">Vector Stores</label>
<VectorStoreSelector
value={comparison.vectorStores}
onChange={(value) => handleSettingChange("vectorStores", value)}
accessToken={apiKey}
/>
</div>
<div>
<label className="text-xs font-medium text-gray-600 block mb-0.5">Guardrails</label>
<GuardrailSelector
value={comparison.guardrails}
onChange={(value) => handleSettingChange("guardrails", value)}
accessToken={apiKey}
/>
</div>
</div>
</div>
{/* Advanced Settings */}
<div>
<h4 className="text-xs font-semibold text-gray-700 mb-1.5 uppercase tracking-wide">Advanced Settings</h4>
<div className="space-y-2">
<div className="flex items-center gap-2 pb-1">
<Checkbox
checked={comparison.useAdvancedParams}
onChange={(e) => handleAdvancedParamsChange(e.target.checked)}
>
<span className="text-sm font-medium">Use Advanced Parameters</span>
</Checkbox>
</div>
<div className="space-y-2 transition-opacity duration-200" style={{ opacity: disabledOpacity }}>
<div>
<div className="flex items-center justify-between mb-1">
<label className={`text-xs font-medium ${disabledTextColor}`}>Temperature</label>
<span className={`text-xs ${disabledTextColor}`}>{comparison.temperature.toFixed(2)}</span>
</div>
<Slider
min={0}
max={2}
step={0.01}
value={comparison.temperature}
onChange={(value) => {
const nextValue = Array.isArray(value) ? value[0] : value;
const clamped = Math.min(2, Math.max(0, Number(nextValue.toFixed(2))));
handleSettingChange("temperature", clamped);
}}
disabled={!comparison.useAdvancedParams}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className={`text-xs font-medium ${disabledTextColor}`}>Max Tokens</label>
<span className={`text-xs ${disabledTextColor}`}>{comparison.maxTokens}</span>
</div>
<Slider
min={1}
max={32768}
step={1}
value={comparison.maxTokens}
onChange={(value) => {
const nextValue = Array.isArray(value) ? value[0] : value;
const clamped = Math.min(32768, Math.max(1, Math.round(nextValue)));
handleSettingChange("maxTokens", clamped);
}}
disabled={!comparison.useAdvancedParams}
/>
</div>
</div>
</div>
</div>
</div>
</div>
);
return (
<div className="bg-white first:border-l-0 border-l border-gray-200 flex flex-col min-h-0">
<div className="border-b flex items-center justify-between gap-3 px-4 py-3">
<div className="flex items-center gap-3 flex-1">
<ModelSelector
value={comparison.model}
models={modelOptions}
loading={isLoadingModels}
onChange={(model) =>
onUpdate({
model,
})
}
/>
<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>
</Popover>
</div>
</div>
{canRemove && (
<button
onClick={(event) => {
event.stopPropagation();
onRemove();
}}
className="p-2 hover:bg-red-50 text-red-600 rounded-lg transition-colors"
>
<X size={18} />
</button>
)}
</div>
<div className="relative flex-1 flex flex-col min-h-0">
<div className="flex-1 max-h-[calc(100vh-385px)] overflow-auto rounded-b-2xl">
<MessageDisplay messages={comparison.messages} isLoading={comparison.isLoading} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,66 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { MessageType } from "../../chat_ui/types";
import { MessageDisplay } from "./MessageDisplay";
vi.mock("../../chat_ui/ReasoningContent", () => ({
default: ({ reasoningContent }: { reasoningContent: string }) => (
<div data-testid="reasoning-content">{reasoningContent}</div>
),
}));
vi.mock("../../chat_ui/ResponseMetrics", () => ({
default: () => <div data-testid="response-metrics">ResponseMetrics</div>,
}));
vi.mock("../../chat_ui/SearchResultsDisplay", () => ({
SearchResultsDisplay: () => <div data-testid="search-results">SearchResultsDisplay</div>,
}));
describe("MessageDisplay", () => {
it("should render", () => {
const messages: MessageType[] = [
{
role: "user",
content: "Hello",
},
{
role: "assistant",
content: "Hi there!",
model: "gpt-4",
},
];
const { getByText } = render(<MessageDisplay messages={messages} isLoading={false} />);
expect(getByText("Hello")).toBeInTheDocument();
expect(getByText("Hi there!")).toBeInTheDocument();
});
it("displays user and assistant messages with proper grouping and shows loading state", () => {
const messages: MessageType[] = [
{
role: "user",
content: "What is 2+2?",
},
{
role: "assistant",
content: "2+2 equals 4",
model: "gpt-4",
toolName: "calculator",
timeToFirstToken: 100,
totalLatency: 500,
usage: {
completionTokens: 10,
promptTokens: 20,
totalTokens: 30,
},
},
];
const { getByText, getByTestId } = render(<MessageDisplay messages={messages} isLoading={false} />);
expect(getByText("You")).toBeInTheDocument();
expect(getByText("What is 2+2?")).toBeInTheDocument();
expect(getByText("gpt-4")).toBeInTheDocument();
expect(getByText("calculator")).toBeInTheDocument();
expect(getByText("2+2 equals 4")).toBeInTheDocument();
expect(getByTestId("response-metrics")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,169 @@
import React from "react";
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Bot, Loader2, UserRound } from "lucide-react";
import ReasoningContent from "../../chat_ui/ReasoningContent";
import ResponseMetrics from "../../chat_ui/ResponseMetrics";
import { SearchResultsDisplay } from "../../chat_ui/SearchResultsDisplay";
import type { MessageType } from "../../chat_ui/types";
interface MessageDisplayProps {
messages: MessageType[];
isLoading: boolean;
}
export function MessageDisplay({ messages, isLoading }: MessageDisplayProps) {
if (messages.length === 0) {
return <div className="h-full" />;
}
const conversationBlocks: Array<{
user?: MessageType;
assistant?: MessageType;
}> = [];
let index = 0;
while (index < messages.length) {
const current = messages[index];
if (current.role === "user") {
const next = messages[index + 1];
if (next?.role === "assistant") {
conversationBlocks.push({
user: current,
assistant: next,
});
index += 2;
continue;
}
conversationBlocks.push({
user: current,
});
} else if (current.role === "assistant") {
conversationBlocks.push({
assistant: current,
});
}
index += 1;
}
const renderMessageBody = (message: MessageType) => (
<div
className="whitespace-pre-wrap break-words"
style={{
wordWrap: "break-word",
overflowWrap: "break-word",
wordBreak: "break-word",
hyphens: "auto",
}}
>
<ReactMarkdown
components={{
code({
node,
inline,
className,
children,
...props
}: React.ComponentPropsWithoutRef<"code"> & {
inline?: boolean;
node?: any;
}) {
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<SyntaxHighlighter
style={coy as any}
language={match[1]}
PreTag="div"
className="rounded-md my-2"
wrapLines={true}
wrapLongLines={true}
{...props}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code className={`${className} px-1.5 py-0.5 rounded bg-gray-100 text-sm font-mono`} {...props}>
{children}
</code>
);
},
pre: ({ node, ...props }) => <pre style={{ overflowX: "auto", maxWidth: "100%" }} {...props} />,
}}
>
{typeof message.content === "string" ? message.content : ""}
</ReactMarkdown>
</div>
);
return (
<div className="flex flex-col gap-6 min-w-0 w-full p-4">
{conversationBlocks.map((block, blockIndex) => {
const assistantMessage = block.assistant;
const displayModel = assistantMessage?.model || "Assistant";
return (
<div key={blockIndex} className="space-y-4">
{block.user && (
<div className="space-y-2 min-w-0">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-100 text-blue-600">
<UserRound size={16} />
</div>
<div className="text-sm font-semibold text-gray-700">You</div>
</div>
{renderMessageBody(block.user)}
</div>
)}
<div className="border-t border-gray-200" />
{assistantMessage ? (
<div className="space-y-3 min-w-0">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600">
<Bot size={16} />
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-700">{displayModel}</span>
{assistantMessage.toolName && (
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
{assistantMessage.toolName}
</span>
)}
</div>
</div>
{assistantMessage.reasoningContent && (
<ReasoningContent reasoningContent={assistantMessage.reasoningContent} />
)}
{assistantMessage.searchResults && (
<SearchResultsDisplay searchResults={assistantMessage.searchResults} />
)}
{renderMessageBody(assistantMessage)}
{(assistantMessage.timeToFirstToken || assistantMessage.totalLatency || assistantMessage.usage) && (
<ResponseMetrics
timeToFirstToken={assistantMessage.timeToFirstToken}
totalLatency={assistantMessage.totalLatency}
usage={assistantMessage.usage}
toolName={assistantMessage.toolName}
/>
)}
</div>
) : isLoading && blockIndex === conversationBlocks.length - 1 ? (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 size={18} className="animate-spin" />
<span>Generating response...</span>
</div>
) : (
<div className="text-sm text-gray-500">Waiting for a response...</div>
)}
</div>
);
})}
{isLoading && conversationBlocks.length === 0 && (
<div className="flex items-center gap-2 text-gray-500">
<Loader2 size={18} className="animate-spin" />
<span>Generating response...</span>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,24 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MessageInput } from "./MessageInput";
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("should disable send button initially", () => {
const onChange = vi.fn();
const onSend = vi.fn();
const { container } = render(<MessageInput value="" onChange={onChange} onSend={onSend} />);
const button = container.querySelector("button") as HTMLButtonElement;
expect(button).toBeDisabled();
});
});

View file

@ -0,0 +1,49 @@
import React from "react";
import { Input, Button } from "antd";
import { ArrowUpOutlined } from "@ant-design/icons";
const { TextArea } = Input;
interface MessageInputProps {
value: string;
onChange: (value: string) => void;
onSend: () => void;
disabled?: boolean;
}
export function MessageInput({ value, onChange, onSend, disabled }: MessageInputProps) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!disabled && value.trim()) {
onSend();
}
}
};
return (
<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]">
<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",
}}
/>
<Button onClick={onSend} disabled={disabled || !value.trim()} icon={<ArrowUpOutlined />} shape="circle" />
</div>
</div>
);
}

View file

@ -0,0 +1,43 @@
import { render, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ModelSelector } from "./ModelSelector";
describe("ModelSelector", () => {
it("should render", () => {
const onChange = vi.fn();
const models = ["gpt-4", "gpt-3.5-turbo"];
const { container } = render(<ModelSelector value="" onChange={onChange} models={models} />);
const select = container.querySelector(".ant-select");
expect(select).toBeInTheDocument();
});
it("allows selecting a model and displays custom values", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const models = ["gpt-4", "gpt-3.5-turbo"];
const { container } = render(<ModelSelector value="" onChange={onChange} models={models} />);
const select = container.querySelector(".ant-select-selector") as HTMLElement;
await user.click(select);
await waitFor(() => {
const gpt4Option = document.querySelector('[title="gpt-4"].ant-select-item-option') as HTMLElement;
expect(gpt4Option).toBeInTheDocument();
});
const gpt4Option = document.querySelector('[title="gpt-4"].ant-select-item-option') as HTMLElement;
await user.click(gpt4Option);
expect(onChange).toHaveBeenCalledWith("gpt-4");
const { container: container2, rerender } = render(
<ModelSelector value="custom-model-123" onChange={onChange} models={models} />,
);
const selectedValue = container2.querySelector(".ant-select-selection-item");
expect(selectedValue).toHaveTextContent("custom-model-123");
rerender(<ModelSelector value="custom-model-123" onChange={onChange} models={models} disabled={true} />);
const selectElement = container2.querySelector(".ant-select");
expect(selectElement).toHaveClass("ant-select-disabled");
});
});

View file

@ -0,0 +1,88 @@
import React, { useMemo, useState } from "react";
import { Select } from "antd";
import { TextInput } from "@tremor/react";
interface ModelSelectorProps {
value: string;
onChange: (value: string) => void;
models: string[];
loading?: boolean;
disabled?: boolean;
}
export function ModelSelector({ value, onChange, models, loading, disabled }: ModelSelectorProps) {
const [isAddingCustom, setIsAddingCustom] = useState(false);
const [customValue, setCustomValue] = useState("");
const options = useMemo(() => Array.from(new Set(models)).sort(), [models]);
const displayOptions = useMemo(() => {
if (value && !options.includes(value)) {
return [value, ...options];
}
return options;
}, [options, value]);
const selectValue = isAddingCustom ? "__custom__" : value || undefined;
const handleSelectChange = (selected: string) => {
if (selected === "__custom__") {
setIsAddingCustom(true);
if (value && !options.includes(value)) {
setCustomValue(value);
} else {
setCustomValue("");
}
return;
}
setIsAddingCustom(false);
setCustomValue("");
onChange(selected);
};
const commitCustomValue = () => {
const trimmed = customValue.trim();
if (!trimmed) {
setIsAddingCustom(false);
setCustomValue("");
return;
}
onChange(trimmed);
setIsAddingCustom(false);
setCustomValue("");
};
return (
<div className="flex-1 min-w-0">
<Select<string>
value={selectValue}
onChange={handleSelectChange}
disabled={disabled}
loading={loading}
placeholder={loading ? "Loading models..." : "Select a model"}
className="w-full rounded-md"
showSearch
optionFilterProp="children"
>
{displayOptions.map((model) => (
<Select.Option key={model} value={model}>
{model}
</Select.Option>
))}
<Select.Option value="__custom__">+ Add custom model</Select.Option>
</Select>
{isAddingCustom && (
<TextInput
className="mt-2"
placeholder="Custom Model Name (Enter to add)"
value={customValue}
onValueChange={setCustomValue}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commitCustomValue();
}
}}
onBlur={commitCustomValue}
autoFocus
/>
)}
</div>
);
}

View file

@ -1,6 +1,6 @@
import Anthropic from "@anthropic-ai/sdk";
import { MessageType } from "../types";
import { TokenUsage } from "../ResponseMetrics";
import { MessageType } from "../chat_ui/types";
import { TokenUsage } from "../chat_ui/ResponseMetrics";
import { getProxyBaseUrl } from "@/components/networking";
import NotificationManager from "@/components/molecules/notifications_manager";

View file

@ -1,7 +1,7 @@
import openai from "openai";
import { getProxyBaseUrl } from "@/components/networking";
import NotificationManager from "@/components/molecules/notifications_manager";
import type { OpenAIVoice } from "../chatConstants";
import type { OpenAIVoice } from "../chat_ui/chatConstants";
export async function makeOpenAIAudioSpeechRequest(
input: string,

View file

@ -1,7 +1,7 @@
import openai from "openai";
import { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import { TokenUsage } from "../ResponseMetrics";
import { VectorStoreSearchResponse } from "../types";
import { TokenUsage } from "../chat_ui/ResponseMetrics";
import { VectorStoreSearchResponse } from "../chat_ui/types";
import { getProxyBaseUrl } from "@/components/networking";
export async function makeOpenAIChatCompletionRequest(
@ -22,6 +22,7 @@ export async function makeOpenAIChatCompletionRequest(
onSearchResults?: (searchResults: VectorStoreSearchResponse[]) => void,
temperature?: number,
max_tokens?: number,
onTotalLatency?: (latency: number) => void,
) {
// base url should be the current base_url
const isLocal = process.env.NODE_ENV === "development";
@ -154,9 +155,20 @@ export async function makeOpenAIChatCompletionRequest(
usageData.reasoningTokens = chunkWithUsage.usage.completion_tokens_details.reasoning_tokens;
}
// Extract cost from usage object if available
if (chunkWithUsage.usage.cost !== undefined && chunkWithUsage.usage.cost !== null) {
usageData.cost = parseFloat(chunkWithUsage.usage.cost);
}
onUsageData(usageData);
}
}
const endTime = Date.now();
const totalLatency = endTime - startTime;
if (onTotalLatency) {
onTotalLatency(totalLatency);
}
} catch (error) {
if (signal?.aborted) {
console.log("Chat completion request was cancelled");

View file

@ -1,4 +1,4 @@
import { TokenUsage } from "../ResponseMetrics";
import { TokenUsage } from "../chat_ui/ResponseMetrics";
export interface StreamingResponse {
id: string;

View file

@ -1,9 +1,9 @@
import openai from "openai";
import { MessageType } from "../types";
import { TokenUsage } from "../ResponseMetrics";
import { MessageType } from "../chat_ui/types";
import { TokenUsage } from "../chat_ui/ResponseMetrics";
import { getProxyBaseUrl } from "@/components/networking";
import NotificationManager from "@/components/molecules/notifications_manager";
import { MCPEvent } from "../MCPEventsDisplay";
import { MCPEvent } from "../chat_ui/MCPEventsDisplay";
export async function makeOpenAIResponsesRequest(
messages: MessageType[],

View file

@ -7,9 +7,9 @@ import { Tag, Tooltip, Modal, Select, Tabs } from "antd";
import { ExternalLinkIcon, SearchIcon } from "@heroicons/react/outline";
import { Copy, Info } from "lucide-react";
import { Table as TableInstance } from "@tanstack/react-table";
import { generateCodeSnippet } from "./chat_ui/CodeSnippets";
import { getEndpointType } from "./chat_ui/mode_endpoint_mapping";
import { MessageType } from "./chat_ui/types";
import { generateCodeSnippet } from "./playground/chat_ui/CodeSnippets";
import { getEndpointType } from "./playground/chat_ui/mode_endpoint_mapping";
import { MessageType } from "./playground/chat_ui/types";
import { getProviderLogoAndName } from "./provider_info_helpers";
import Navbar from "./navbar";
import { ThemeProvider } from "@/contexts/ThemeContext";
@ -985,12 +985,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
{/* Tabs for Models and Agents */}
<Card className="p-8 bg-white border border-gray-200 rounded-lg shadow-sm">
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
size="large"
className="public-hub-tabs"
>
<Tabs activeKey={activeTab} onChange={setActiveTab} size="large" className="public-hub-tabs">
{/* Models Tab */}
<TabPane tab="Model Hub" key="models">
<div className="flex justify-between items-center mb-8">
@ -1124,10 +1119,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<div>
<div className="flex items-center space-x-2 mb-3">
<Text className="text-sm font-medium text-gray-700">Search Agents:</Text>
<Tooltip
title="Search agents by name or description"
placement="top"
>
<Tooltip title="Search agents by name or description" placement="top">
<Info className="w-4 h-4 text-gray-400 cursor-help" />
</Tooltip>
</div>
@ -1622,13 +1614,13 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
{/* A2A Usage Example */}
<div>
<Text className="text-lg font-semibold mb-4">Usage Example (A2A Protocol)</Text>
{/* Step 1: Retrieve Agent Card */}
<div className="mb-4">
<Text className="text-sm font-medium mb-2 text-gray-700">Step 1: Retrieve Agent Card</Text>
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<pre className="text-xs">
{`base_url = '${selectedAgent.url}'
{`base_url = '${selectedAgent.url}'
resolver = A2ACardResolver(
httpx_client=httpx_client,
@ -1723,7 +1715,7 @@ if _public_card.supports_authenticated_extended_card:
<Text className="text-sm font-medium mb-2 text-gray-700">Step 2: Call the Agent</Text>
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<pre className="text-xs">
{`client = A2AClient(
{`client = A2AClient(
httpx_client=httpx_client, agent_card=final_agent_card_to_use
)