diff --git a/ui/litellm-dashboard/src/components/chat_ui.tsx b/ui/litellm-dashboard/src/components/chat_ui.tsx index c505a954b8..7f2b0e1a26 100644 --- a/ui/litellm-dashboard/src/components/chat_ui.tsx +++ b/ui/litellm-dashboard/src/components/chat_ui.tsx @@ -20,15 +20,28 @@ import { SelectItem, TextInput, Button, + Divider, } from "@tremor/react"; -import { message, Select } from "antd"; -import { modelAvailableCall } from "./networking"; -import openai from "openai"; -import { ChatCompletionMessageParam } from "openai/resources/chat/completions"; +import { message, Select, Spin, Typography, Tooltip } from "antd"; +import { makeOpenAIChatCompletionRequest } from "./chat_ui/llm_calls/chat_completion"; +import { makeOpenAIImageGenerationRequest } from "./chat_ui/llm_calls/image_generation"; +import { fetchAvailableModels, ModelGroup } from "./chat_ui/llm_calls/fetch_models"; +import { litellmModeMapping, ModelMode, EndpointType, getEndpointType } from "./chat_ui/mode_endpoint_mapping"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { Typography } from "antd"; import { coy } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import EndpointSelector from "./chat_ui/EndpointSelector"; +import { determineEndpointType } from "./chat_ui/EndpointUtils"; +import { + SendOutlined, + ApiOutlined, + KeyOutlined, + ClearOutlined, + RobotOutlined, + UserOutlined, + DeleteOutlined, + LoadingOutlined +} from "@ant-design/icons"; interface ChatUIProps { accessToken: string | null; @@ -38,45 +51,6 @@ interface ChatUIProps { disabledPersonalKeyCreation: boolean; } -async function generateModelResponse( - chatHistory: { role: string; content: string }[], - updateUI: (chunk: string, model: string) => void, - selectedModel: string, - accessToken: string -) { - // base url should be the current base_url - const isLocal = process.env.NODE_ENV === "development"; - if (isLocal !== true) { - console.log = function () {}; - } - console.log("isLocal:", isLocal); - const proxyBaseUrl = isLocal - ? "http://localhost:4000" - : window.location.origin; - const client = new openai.OpenAI({ - apiKey: accessToken, // Replace with your OpenAI API key - baseURL: proxyBaseUrl, // Replace with your OpenAI API base URL - dangerouslyAllowBrowser: true, // using a temporary litellm proxy key - }); - - try { - const response = await client.chat.completions.create({ - model: selectedModel, - stream: true, - messages: chatHistory as ChatCompletionMessageParam[], - }); - - for await (const chunk of response) { - console.log(chunk); - if (chunk.choices[0].delta.content) { - updateUI(chunk.choices[0].delta.content, chunk.model); - } - } - } catch (error) { - message.error(`Error occurred while generating model response. Please try again. Error: ${error}`, 20); - } -} - const ChatUI: React.FC = ({ accessToken, token, @@ -89,63 +63,55 @@ const ChatUI: React.FC = ({ ); const [apiKey, setApiKey] = useState(""); const [inputMessage, setInputMessage] = useState(""); - const [chatHistory, setChatHistory] = useState<{ role: string; content: string; model?: string }[]>([]); + const [chatHistory, setChatHistory] = useState<{ role: string; content: string; model?: string; isImage?: boolean }[]>([]); const [selectedModel, setSelectedModel] = useState( undefined ); const [showCustomModelInput, setShowCustomModelInput] = useState(false); - const [modelInfo, setModelInfo] = useState([]); + const [modelInfo, setModelInfo] = useState([]); const customModelTimeout = useRef(null); + const [endpointType, setEndpointType] = useState(EndpointType.CHAT); + const [isLoading, setIsLoading] = useState(false); + const abortControllerRef = useRef(null); const chatEndRef = useRef(null); useEffect(() => { - let useApiKey = apiKeySource === 'session' ? accessToken : apiKey; - console.log("useApiKey:", useApiKey); - if (!useApiKey || !token || !userRole || !userID) { - console.log("useApiKey or token or userRole or userID is missing = ", useApiKey, token, userRole, userID); + let userApiKey = apiKeySource === 'session' ? accessToken : apiKey; + if (!userApiKey || !token || !userRole || !userID) { + console.log("userApiKey or token or userRole or userID is missing = ", userApiKey, token, userRole, userID); return; } - - // Fetch model info and set the default selected model - const fetchModelInfo = async () => { + const loadModels = async () => { try { - const fetchedAvailableModels = await modelAvailableCall( - useApiKey ?? '', // Use empty string if useApiKey is null, - userID, - userRole + if (!userApiKey) { + console.log("userApiKey is missing"); + return; + } + const uniqueModels = await fetchAvailableModels( + userApiKey, ); - console.log("model_info:", fetchedAvailableModels); + console.log("Fetched models:", uniqueModels); - if (fetchedAvailableModels?.data.length > 0) { - // Create a Map to store unique models using the model ID as key - const uniqueModelsMap = new Map(); - - fetchedAvailableModels["data"].forEach((item: { id: string }) => { - uniqueModelsMap.set(item.id, { - value: item.id, - label: item.id - }); - }); - - // Convert Map values back to array - const uniqueModels = Array.from(uniqueModelsMap.values()); - - // Sort models alphabetically - uniqueModels.sort((a, b) => a.label.localeCompare(b.label)); - + if (uniqueModels.length > 0) { setModelInfo(uniqueModels); - setSelectedModel(uniqueModels[0].value); + setSelectedModel(uniqueModels[0].model_group); + + // Auto-set endpoint based on the first model's mode + if (uniqueModels[0].mode) { + const initialEndpointType = determineEndpointType(uniqueModels[0].model_group, uniqueModels); + setEndpointType(initialEndpointType); + } } } catch (error) { console.error("Error fetching model info:", error); } }; - fetchModelInfo(); + loadModels(); }, [accessToken, userID, userRole, apiKeySource, apiKey]); @@ -162,11 +128,11 @@ const ChatUI: React.FC = ({ } }, [chatHistory]); - const updateUI = (role: string, chunk: string, model?: string) => { + const updateTextUI = (role: string, chunk: string, model?: string) => { setChatHistory((prevHistory) => { const lastMessage = prevHistory[prevHistory.length - 1]; - if (lastMessage && lastMessage.role === role) { + if (lastMessage && lastMessage.role === role && !lastMessage.isImage) { return [ ...prevHistory.slice(0, prevHistory.length - 1), { role, content: lastMessage.content + chunk, model }, @@ -177,12 +143,28 @@ const ChatUI: React.FC = ({ }); }; + const updateImageUI = (imageUrl: string, model: string) => { + setChatHistory((prevHistory) => [ + ...prevHistory, + { role: "assistant", content: imageUrl, model, isImage: true } + ]); + }; + const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { handleSendMessage(); } }; + const handleCancelRequest = () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + setIsLoading(false); + message.info("Request cancelled"); + } + }; + const handleSendMessage = async () => { if (inputMessage.trim() === "") return; @@ -197,27 +179,52 @@ const ChatUI: React.FC = ({ return; } + // Create new abort controller for this request + abortControllerRef.current = new AbortController(); + const signal = abortControllerRef.current.signal; + // Create message object without model field for API call const newUserMessage = { role: "user", content: inputMessage }; - // Create chat history for API call - strip out model field - const apiChatHistory = [...chatHistory.map(({ role, content }) => ({ role, content })), newUserMessage]; - - // Update UI with full message object (including model field for display) + // Update UI with full message object setChatHistory([...chatHistory, newUserMessage]); + setIsLoading(true); try { if (selectedModel) { - await generateModelResponse( - apiChatHistory, - (chunk, model) => updateUI("assistant", chunk, model), - selectedModel, - effectiveApiKey - ); + // Use EndpointType enum for comparison + if (endpointType === EndpointType.CHAT) { + // Create chat history for API call - strip out model field and isImage field + const apiChatHistory = [...chatHistory.filter(msg => !msg.isImage).map(({ role, content }) => ({ role, content })), newUserMessage]; + + await makeOpenAIChatCompletionRequest( + apiChatHistory, + (chunk, model) => updateTextUI("assistant", chunk, model), + selectedModel, + effectiveApiKey, + signal + ); + } else if (endpointType === EndpointType.IMAGE) { + // For image generation + await makeOpenAIImageGenerationRequest( + inputMessage, + (imageUrl, model) => updateImageUI(imageUrl, model), + selectedModel, + effectiveApiKey, + signal + ); + } } } catch (error) { - console.error("Error fetching model response", error); - updateUI("assistant", "Error fetching model response"); + if (signal.aborted) { + console.log("Request was cancelled"); + } else { + console.error("Error fetching response", error); + updateTextUI("assistant", "Error fetching response"); + } + } finally { + setIsLoading(false); + abortControllerRef.current = null; } setInputMessage(""); @@ -238,193 +245,240 @@ const ChatUI: React.FC = ({ ); } - const onChange = (value: string) => { + const onModelChange = (value: string) => { console.log(`selected ${value}`); setSelectedModel(value); + + // Use the utility function to determine the endpoint type + if (value !== 'custom') { + const newEndpointType = determineEndpointType(value, modelInfo); + setEndpointType(newEndpointType); + } + setShowCustomModelInput(value === 'custom'); }; - return ( -
- - - - - - Chat - - - -
- - - API Key Source - - {showCustomModelInput && ( - { - // Using setTimeout to create a simple debounce effect - if (customModelTimeout.current) { - clearTimeout(customModelTimeout.current); - } - - customModelTimeout.current = setTimeout(() => { - setSelectedModel(value); - }, 500); // 500ms delay after typing stops - }} - /> - )} - - + const handleEndpointChange = (value: string) => { + setEndpointType(value); + }; - {/* Clear Chat Button */} - -
- - - - - {/* Chat */} - - - - - {chatHistory.map((message, index) => ( - - -
- {message.role} - {message.role === "assistant" && message.model && ( - - {message.model} - - )} -
-
- & { - inline?: boolean; - node?: any; - }) { - const match = /language-(\w+)/.exec(className || ''); - return !inline && match ? ( - - {String(children).replace(/\n$/, '')} - - ) : ( - - {children} - - ); - } - }} - > - {message.content} - -
-
-
- ))} - - -
- - - -
-
-
- setInputMessage(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Type your message..." - /> - + const antIcon = ; + + return ( +
+ +
+ {/* Left Sidebar with Controls */} +
+
+
+
+ + API Key Source + + ({ + value: option.model_group, + label: option.model_group + })), + { value: 'custom', label: 'Enter custom model' } + ]} + style={{ width: "100%" }} + showSearch={true} + className="rounded-md" + /> + {showCustomModelInput && ( + { + // Using setTimeout to create a simple debounce effect + if (customModelTimeout.current) { + clearTimeout(customModelTimeout.current); + } + + customModelTimeout.current = setTimeout(() => { + setSelectedModel(value); + }, 500); // 500ms delay after typing stops + }} + /> + )} +
+ +
+ + Endpoint Type + + +
+ + +
+
+
+ + {/* Main Chat Area */} +
+
+ {chatHistory.length === 0 && ( +
+ + Start a conversation or generate an image +
+ )} + + {chatHistory.map((message, index) => ( +
+
+
+
+ {message.role === "user" ? + : + + } +
+ {message.role} + {message.role === "assistant" && message.model && ( + + {message.model} + + )} +
+
+ {message.isImage ? ( + Generated image + ) : ( + & { + inline?: boolean; + node?: any; + }) { + const match = /language-(\w+)/.exec(className || ''); + return !inline && match ? ( + + {String(children).replace(/\n$/, '')} + + ) : ( + + {children} + + ); + } + }} + > + {message.content} + + )}
- - - - - - +
+ ))} + {isLoading && ( +
+ +
+ )} +
+
+ +
+
+ setInputMessage(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={ + endpointType === EndpointType.CHAT + ? "Type your message..." + : "Describe the image you want to generate..." + } + disabled={isLoading} + className="flex-1" + /> + {isLoading ? ( + + ) : ( + + )} +
+
+
+
+
); }; diff --git a/ui/litellm-dashboard/src/components/chat_ui/EndpointSelector.tsx b/ui/litellm-dashboard/src/components/chat_ui/EndpointSelector.tsx new file mode 100644 index 0000000000..49b1df3e97 --- /dev/null +++ b/ui/litellm-dashboard/src/components/chat_ui/EndpointSelector.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { Select } from "antd"; +import { Text } from "@tremor/react"; +import { EndpointType } from "./mode_endpoint_mapping"; + +interface EndpointSelectorProps { + endpointType: string; // Accept string to avoid type conflicts + onEndpointChange: (value: string) => void; + className?: string; +} + +/** + * A reusable component for selecting API endpoints + */ +const EndpointSelector: React.FC = ({ + endpointType, + onEndpointChange, + className, +}) => { + // Map endpoint types to their display labels + const endpointOptions = [ + { value: EndpointType.CHAT, label: '/chat/completions' }, + { value: EndpointType.IMAGE, label: '/images/generations' } + ]; + + return ( +
+ Endpoint Type: +