Spaces:
Paused
Paused
| import React, { useState, useEffect, useRef } from "react"; | |
| import ReactMarkdown from "react-markdown"; | |
| import { | |
| Card, | |
| Title, | |
| Table, | |
| TableHead, | |
| TableRow, | |
| TableCell, | |
| TableBody, | |
| Grid, | |
| Tab, | |
| TabGroup, | |
| TabList, | |
| TabPanel, | |
| TabPanels, | |
| Metric, | |
| Col, | |
| Text, | |
| SelectItem, | |
| TextInput, | |
| Button, | |
| Divider, | |
| } from "@tremor/react"; | |
| import { v4 as uuidv4 } from 'uuid'; | |
| import { message, Select, Spin, Typography, Tooltip, Input } from "antd"; | |
| import { makeOpenAIChatCompletionRequest } from "./chat_ui/llm_calls/chat_completion"; | |
| import { makeOpenAIImageGenerationRequest } from "./chat_ui/llm_calls/image_generation"; | |
| import { makeOpenAIResponsesRequest } from "./chat_ui/llm_calls/responses_api"; | |
| 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 { coy } from 'react-syntax-highlighter/dist/esm/styles/prism'; | |
| import EndpointSelector from "./chat_ui/EndpointSelector"; | |
| import TagSelector from "./tag_management/TagSelector"; | |
| import VectorStoreSelector from "./vector_store_management/VectorStoreSelector"; | |
| import { determineEndpointType } from "./chat_ui/EndpointUtils"; | |
| import { MessageType } from "./chat_ui/types"; | |
| import ReasoningContent from "./chat_ui/ReasoningContent"; | |
| import ResponseMetrics, { TokenUsage } from "./chat_ui/ResponseMetrics"; | |
| import { | |
| SendOutlined, | |
| ApiOutlined, | |
| KeyOutlined, | |
| ClearOutlined, | |
| RobotOutlined, | |
| UserOutlined, | |
| DeleteOutlined, | |
| LoadingOutlined, | |
| TagsOutlined, | |
| DatabaseOutlined, | |
| InfoCircleOutlined | |
| } from "@ant-design/icons"; | |
| const { TextArea } = Input; | |
| interface ChatUIProps { | |
| accessToken: string | null; | |
| token: string | null; | |
| userRole: string | null; | |
| userID: string | null; | |
| disabledPersonalKeyCreation: boolean; | |
| } | |
| const ChatUI: React.FC<ChatUIProps> = ({ | |
| accessToken, | |
| token, | |
| userRole, | |
| userID, | |
| disabledPersonalKeyCreation, | |
| }) => { | |
| const [apiKeySource, setApiKeySource] = useState<'session' | 'custom'>( | |
| disabledPersonalKeyCreation ? 'custom' : 'session' | |
| ); | |
| const [apiKey, setApiKey] = useState(""); | |
| const [inputMessage, setInputMessage] = useState(""); | |
| const [chatHistory, setChatHistory] = useState<MessageType[]>([]); | |
| const [selectedModel, setSelectedModel] = useState<string | undefined>( | |
| undefined | |
| ); | |
| const [showCustomModelInput, setShowCustomModelInput] = useState<boolean>(false); | |
| const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]); | |
| const customModelTimeout = useRef<NodeJS.Timeout | null>(null); | |
| const [endpointType, setEndpointType] = useState<string>(EndpointType.CHAT); | |
| const [isLoading, setIsLoading] = useState<boolean>(false); | |
| const abortControllerRef = useRef<AbortController | null>(null); | |
| const [selectedTags, setSelectedTags] = useState<string[]>([]); | |
| const [selectedVectorStores, setSelectedVectorStores] = useState<string[]>([]); | |
| const [messageTraceId, setMessageTraceId] = useState<string | null>(null); | |
| const chatEndRef = useRef<HTMLDivElement>(null); | |
| useEffect(() => { | |
| 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 loadModels = async () => { | |
| try { | |
| if (!userApiKey) { | |
| console.log("userApiKey is missing"); | |
| return; | |
| } | |
| const uniqueModels = await fetchAvailableModels( | |
| userApiKey, | |
| ); | |
| console.log("Fetched models:", uniqueModels); | |
| if (uniqueModels.length > 0) { | |
| setModelInfo(uniqueModels); | |
| 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); | |
| } | |
| }; | |
| loadModels(); | |
| }, [accessToken, userID, userRole, apiKeySource, apiKey]); | |
| useEffect(() => { | |
| // Scroll to the bottom of the chat whenever chatHistory updates | |
| if (chatEndRef.current) { | |
| // Add a small delay to ensure content is rendered | |
| setTimeout(() => { | |
| chatEndRef.current?.scrollIntoView({ | |
| behavior: "smooth", | |
| block: "end" // Keep the scroll position at the end | |
| }); | |
| }, 100); | |
| } | |
| }, [chatHistory]); | |
| const updateTextUI = (role: string, chunk: string, model?: string) => { | |
| console.log("updateTextUI called with:", role, chunk, model); | |
| setChatHistory((prev) => { | |
| const last = prev[prev.length - 1]; | |
| // if the last message is already from this same role, append | |
| if (last && last.role === role && !last.isImage) { | |
| // build a new object, but only set `model` if it wasn't there already | |
| const updated: MessageType = { | |
| ...last, | |
| content: last.content + chunk, | |
| model: last.model ?? model, // ← only use the passed‐in model on the first chunk | |
| }; | |
| return [...prev.slice(0, -1), updated]; | |
| } else { | |
| // otherwise start a brand new assistant bubble | |
| return [ | |
| ...prev, | |
| { | |
| role, | |
| content: chunk, | |
| model, // model set exactly once here | |
| }, | |
| ]; | |
| } | |
| }); | |
| }; | |
| const updateReasoningContent = (chunk: string) => { | |
| setChatHistory((prevHistory) => { | |
| const lastMessage = prevHistory[prevHistory.length - 1]; | |
| if (lastMessage && lastMessage.role === "assistant" && !lastMessage.isImage) { | |
| return [ | |
| ...prevHistory.slice(0, prevHistory.length - 1), | |
| { | |
| ...lastMessage, | |
| reasoningContent: (lastMessage.reasoningContent || "") + chunk | |
| }, | |
| ]; | |
| } else { | |
| // If there's no assistant message yet, we'll create one with empty content | |
| // but with reasoning content | |
| if (prevHistory.length > 0 && prevHistory[prevHistory.length - 1].role === "user") { | |
| return [ | |
| ...prevHistory, | |
| { | |
| role: "assistant", | |
| content: "", | |
| reasoningContent: chunk | |
| } | |
| ]; | |
| } | |
| return prevHistory; | |
| } | |
| }); | |
| }; | |
| const updateTimingData = (timeToFirstToken: number) => { | |
| console.log("updateTimingData called with:", timeToFirstToken); | |
| setChatHistory((prevHistory) => { | |
| const lastMessage = prevHistory[prevHistory.length - 1]; | |
| console.log("Current last message:", lastMessage); | |
| if (lastMessage && lastMessage.role === "assistant") { | |
| console.log("Updating assistant message with timeToFirstToken:", timeToFirstToken); | |
| const updatedHistory = [ | |
| ...prevHistory.slice(0, prevHistory.length - 1), | |
| { | |
| ...lastMessage, | |
| timeToFirstToken | |
| }, | |
| ]; | |
| console.log("Updated chat history:", updatedHistory); | |
| return updatedHistory; | |
| } | |
| // If the last message is a user message and no assistant message exists yet, | |
| // create a new assistant message with empty content | |
| else if (lastMessage && lastMessage.role === "user") { | |
| console.log("Creating new assistant message with timeToFirstToken:", timeToFirstToken); | |
| return [ | |
| ...prevHistory, | |
| { | |
| role: "assistant", | |
| content: "", | |
| timeToFirstToken | |
| } | |
| ]; | |
| } | |
| console.log("No appropriate message found to update timing"); | |
| return prevHistory; | |
| }); | |
| }; | |
| const updateUsageData = (usage: TokenUsage) => { | |
| console.log("Received usage data:", usage); | |
| setChatHistory((prevHistory) => { | |
| const lastMessage = prevHistory[prevHistory.length - 1]; | |
| if (lastMessage && lastMessage.role === "assistant") { | |
| console.log("Updating message with usage data:", usage); | |
| const updatedMessage = { | |
| ...lastMessage, | |
| usage | |
| }; | |
| console.log("Updated message:", updatedMessage); | |
| return [ | |
| ...prevHistory.slice(0, prevHistory.length - 1), | |
| updatedMessage | |
| ]; | |
| } | |
| return prevHistory; | |
| }); | |
| }; | |
| const updateImageUI = (imageUrl: string, model: string) => { | |
| setChatHistory((prevHistory) => [ | |
| ...prevHistory, | |
| { role: "assistant", content: imageUrl, model, isImage: true } | |
| ]); | |
| }; | |
| const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => { | |
| if (event.key === 'Enter' && !event.shiftKey) { | |
| event.preventDefault(); // Prevent default to avoid newline | |
| handleSendMessage(); | |
| } | |
| // If Shift+Enter is pressed, the default behavior (inserting a newline) will occur | |
| }; | |
| const handleCancelRequest = () => { | |
| if (abortControllerRef.current) { | |
| abortControllerRef.current.abort(); | |
| abortControllerRef.current = null; | |
| setIsLoading(false); | |
| message.info("Request cancelled"); | |
| } | |
| }; | |
| const handleSendMessage = async () => { | |
| if (inputMessage.trim() === "") return; | |
| if (!token || !userRole || !userID) { | |
| return; | |
| } | |
| const effectiveApiKey = apiKeySource === 'session' ? accessToken : apiKey; | |
| if (!effectiveApiKey) { | |
| message.error("Please provide an API key or select Current UI Session"); | |
| 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 }; | |
| // Generate new trace ID for a new conversation or use existing one | |
| const traceId = messageTraceId || uuidv4(); | |
| if (!messageTraceId) { | |
| setMessageTraceId(traceId); | |
| } | |
| // Update UI with full message object | |
| setChatHistory([...chatHistory, newUserMessage]); | |
| setIsLoading(true); | |
| try { | |
| if (selectedModel) { | |
| 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, | |
| selectedTags, | |
| signal, | |
| updateReasoningContent, | |
| updateTimingData, | |
| updateUsageData, | |
| traceId, | |
| selectedVectorStores.length > 0 ? selectedVectorStores : undefined | |
| ); | |
| } else if (endpointType === EndpointType.IMAGE) { | |
| // For image generation | |
| await makeOpenAIImageGenerationRequest( | |
| inputMessage, | |
| (imageUrl, model) => updateImageUI(imageUrl, model), | |
| selectedModel, | |
| effectiveApiKey, | |
| selectedTags, | |
| signal | |
| ); | |
| } else if (endpointType === EndpointType.RESPONSES) { | |
| // 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 makeOpenAIResponsesRequest( | |
| apiChatHistory, | |
| (role, delta, model) => updateTextUI(role, delta, model), | |
| selectedModel, | |
| effectiveApiKey, | |
| selectedTags, | |
| signal, | |
| updateReasoningContent, | |
| updateTimingData, | |
| updateUsageData, | |
| traceId, | |
| selectedVectorStores.length > 0 ? selectedVectorStores : undefined | |
| ); | |
| } | |
| } | |
| } catch (error) { | |
| 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(""); | |
| }; | |
| const clearChatHistory = () => { | |
| setChatHistory([]); | |
| setMessageTraceId(null); | |
| message.success("Chat history cleared."); | |
| }; | |
| if (userRole && userRole === "Admin Viewer") { | |
| const { Title, Paragraph } = Typography; | |
| return ( | |
| <div> | |
| <Title level={1}>Access Denied</Title> | |
| <Paragraph>Ask your proxy admin for access to test models</Paragraph> | |
| </div> | |
| ); | |
| } | |
| 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'); | |
| }; | |
| const handleEndpointChange = (value: string) => { | |
| setEndpointType(value); | |
| }; | |
| const antIcon = <LoadingOutlined style={{ fontSize: 24 }} spin />; | |
| return ( | |
| <div className="w-full h-screen p-4 bg-white"> | |
| <Card className="w-full rounded-xl shadow-md overflow-hidden"> | |
| <div className="flex h-[80vh] w-full"> | |
| {/* Left Sidebar with Controls */} | |
| <div className="w-1/4 p-4 border-r border-gray-200 bg-gray-50"> | |
| <div className="mb-6"> | |
| <div className="space-y-6"> | |
| <div> | |
| <Text className="font-medium block mb-2 text-gray-700 flex items-center"> | |
| <KeyOutlined className="mr-2" /> API Key Source | |
| </Text> | |
| <Select | |
| disabled={disabledPersonalKeyCreation} | |
| defaultValue="session" | |
| style={{ width: "100%" }} | |
| onChange={(value) => setApiKeySource(value as "session" | "custom")} | |
| options={[ | |
| { value: 'session', label: 'Current UI Session' }, | |
| { value: 'custom', label: 'Virtual Key' }, | |
| ]} | |
| className="rounded-md" | |
| /> | |
| {apiKeySource === 'custom' && ( | |
| <TextInput | |
| className="mt-2" | |
| placeholder="Enter custom API key" | |
| type="password" | |
| onValueChange={setApiKey} | |
| value={apiKey} | |
| icon={KeyOutlined} | |
| /> | |
| )} | |
| </div> | |
| <div> | |
| <Text className="font-medium block mb-2 text-gray-700 flex items-center"> | |
| <RobotOutlined className="mr-2" /> Select Model | |
| </Text> | |
| <Select | |
| placeholder="Select a Model" | |
| onChange={onModelChange} | |
| options={[ | |
| ...Array.from(new Set(modelInfo.map(option => option.model_group))) | |
| .map((model_group, index) => ({ | |
| value: model_group, | |
| label: model_group, | |
| key: index | |
| })), | |
| { value: 'custom', label: 'Enter custom model', key: 'custom' } | |
| ]} | |
| style={{ width: "100%" }} | |
| showSearch={true} | |
| className="rounded-md" | |
| /> | |
| {showCustomModelInput && ( | |
| <TextInput | |
| className="mt-2" | |
| placeholder="Enter custom model name" | |
| onValueChange={(value) => { | |
| // 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 | |
| }} | |
| /> | |
| )} | |
| </div> | |
| <div> | |
| <Text className="font-medium block mb-2 text-gray-700 flex items-center"> | |
| <ApiOutlined className="mr-2" /> Endpoint Type | |
| </Text> | |
| <EndpointSelector | |
| endpointType={endpointType} | |
| onEndpointChange={handleEndpointChange} | |
| className="mb-4" | |
| /> | |
| </div> | |
| <div> | |
| <Text className="font-medium block mb-2 text-gray-700 flex items-center"> | |
| <TagsOutlined className="mr-2" /> Tags | |
| </Text> | |
| <TagSelector | |
| value={selectedTags} | |
| onChange={setSelectedTags} | |
| className="mb-4" | |
| accessToken={accessToken || ""} | |
| /> | |
| </div> | |
| <div> | |
| <Text className="font-medium block mb-2 text-gray-700 flex items-center"> | |
| <DatabaseOutlined className="mr-2" /> Vector Store | |
| <Tooltip | |
| className="ml-1" | |
| title={ | |
| <span> | |
| Select vector store(s) to use for this LLM API call. You can set up your vector store <a href="?page=vector-stores" style={{ color: '#1890ff' }}>here</a>. | |
| </span> | |
| }> | |
| <InfoCircleOutlined /> | |
| </Tooltip> | |
| </Text> | |
| <VectorStoreSelector | |
| value={selectedVectorStores} | |
| onChange={setSelectedVectorStores} | |
| className="mb-4" | |
| accessToken={accessToken || ""} | |
| /> | |
| </div> | |
| <Button | |
| onClick={clearChatHistory} | |
| className="w-full bg-gray-100 hover:bg-gray-200 text-gray-700 border-gray-300 mt-4" | |
| icon={ClearOutlined} | |
| > | |
| Clear Chat | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Main Chat Area */} | |
| <div className="w-3/4 flex flex-col bg-white"> | |
| <div className="flex-1 overflow-auto p-4 pb-0"> | |
| {chatHistory.length === 0 && ( | |
| <div className="h-full flex flex-col items-center justify-center text-gray-400"> | |
| <RobotOutlined style={{ fontSize: '48px', marginBottom: '16px' }} /> | |
| <Text>Start a conversation or generate an image</Text> | |
| </div> | |
| )} | |
| {chatHistory.map((message, index) => ( | |
| <div | |
| key={index} | |
| className={`mb-4 ${message.role === "user" ? "text-right" : "text-left"}`} | |
| > | |
| <div className="inline-block max-w-[80%] rounded-lg shadow-sm p-3.5 px-4" style={{ | |
| backgroundColor: message.role === "user" ? '#f0f8ff' : '#ffffff', | |
| border: message.role === "user" ? '1px solid #e6f0fa' : '1px solid #f0f0f0', | |
| textAlign: 'left' | |
| }}> | |
| <div className="flex items-center gap-2 mb-1.5"> | |
| <div className="flex items-center justify-center w-6 h-6 rounded-full mr-1" style={{ | |
| backgroundColor: message.role === "user" ? '#e6f0fa' : '#f5f5f5', | |
| }}> | |
| {message.role === "user" ? | |
| <UserOutlined style={{ fontSize: '12px', color: '#2563eb' }} /> : | |
| <RobotOutlined style={{ fontSize: '12px', color: '#4b5563' }} /> | |
| } | |
| </div> | |
| <strong className="text-sm capitalize">{message.role}</strong> | |
| {message.role === "assistant" && message.model && ( | |
| <span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600 font-normal"> | |
| {message.model} | |
| </span> | |
| )} | |
| </div> | |
| {message.reasoningContent && ( | |
| <ReasoningContent reasoningContent={message.reasoningContent} /> | |
| )} | |
| <div className="whitespace-pre-wrap break-words max-w-full message-content" | |
| style={{ | |
| wordWrap: 'break-word', | |
| overflowWrap: 'break-word', | |
| wordBreak: 'break-word', | |
| hyphens: 'auto' | |
| }}> | |
| {message.isImage ? ( | |
| <img | |
| src={message.content} | |
| alt="Generated image" | |
| className="max-w-full rounded-md border border-gray-200 shadow-sm" | |
| style={{ maxHeight: '500px' }} | |
| /> | |
| ) : ( | |
| <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`} style={{ wordBreak: 'break-word' }} {...props}> | |
| {children} | |
| </code> | |
| ); | |
| }, | |
| pre: ({ node, ...props }) => ( | |
| <pre style={{ overflowX: 'auto', maxWidth: '100%' }} {...props} /> | |
| ) | |
| }} | |
| > | |
| {message.content} | |
| </ReactMarkdown> | |
| )} | |
| {message.role === "assistant" && (message.timeToFirstToken || message.usage) && ( | |
| <ResponseMetrics | |
| timeToFirstToken={message.timeToFirstToken} | |
| usage={message.usage} | |
| /> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| {isLoading && ( | |
| <div className="flex justify-center items-center my-4"> | |
| <Spin indicator={antIcon} /> | |
| </div> | |
| )} | |
| <div ref={chatEndRef} style={{ height: "1px" }} /> | |
| </div> | |
| <div className="p-4 border-t border-gray-200 bg-white"> | |
| <div className="flex items-center"> | |
| <TextArea | |
| value={inputMessage} | |
| onChange={(e) => setInputMessage(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder={ | |
| endpointType === EndpointType.CHAT || endpointType === EndpointType.RESPONSES | |
| ? "Type your message... (Shift+Enter for new line)" | |
| : "Describe the image you want to generate..." | |
| } | |
| disabled={isLoading} | |
| className="flex-1" | |
| autoSize={{ minRows: 1, maxRows: 6 }} | |
| style={{ resize: 'none', paddingRight: '10px', paddingLeft: '10px' }} | |
| /> | |
| {isLoading ? ( | |
| <Button | |
| onClick={handleCancelRequest} | |
| className="ml-2 bg-red-50 hover:bg-red-100 text-red-600 border-red-200" | |
| icon={DeleteOutlined} | |
| > | |
| Cancel | |
| </Button> | |
| ) : ( | |
| <Button | |
| onClick={handleSendMessage} | |
| className="ml-2 text-white" | |
| icon={endpointType === EndpointType.CHAT ? SendOutlined : RobotOutlined} | |
| > | |
| {endpointType === EndpointType.CHAT ? "Send" : "Generate"} | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </Card> | |
| </div> | |
| ); | |
| }; | |
| export default ChatUI; | |