mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
[Feature] UI - Model Compare (#16855)
* Temp commit for branch switching, Compare WIP * Model Compare UI
This commit is contained in:
parent
65ff1eff56
commit
d22ea6f15f
63 changed files with 1736 additions and 92 deletions
58
ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx
Normal file
58
ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ describe("Sidebar (leftnav)", () => {
|
|||
|
||||
const topLevelLabels = [
|
||||
"Virtual Keys",
|
||||
"Test Key",
|
||||
"Playground",
|
||||
"Models + Endpoints",
|
||||
"Usage",
|
||||
"Teams",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
||||
/**
|
||||
|
|
@ -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">
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
@ -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");
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { TokenUsage } from "../ResponseMetrics";
|
||||
import { TokenUsage } from "../chat_ui/ResponseMetrics";
|
||||
|
||||
export interface StreamingResponse {
|
||||
id: string;
|
||||
|
|
@ -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[],
|
||||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue