import React, { useState, useImperativeHandle, forwardRef, useEffect, useRef, useCallback } from 'react'; import Typography from '@mui/material/Typography'; import Tooltip from '@mui/material/Tooltip'; import Button from '@mui/material/Button'; import Box from '@mui/material/Box'; import SendIcon from '@mui/icons-material/Send'; import { SxProps, Theme } from '@mui/material'; import PropagateLoader from "react-spinners/PropagateLoader"; import { Message, MessageList, MessageData } from './Message'; import { SetSnackType } from './Snack'; import { ContextStatus } from './ContextStatus'; import { Scrollable } from './Scrollable'; import { DeleteConfirmation } from './DeleteConfirmation'; import { QueryOptions } from './ChatQuery'; import './Conversation.css'; import { BackstoryTextField } from './BackstoryTextField'; const loadingMessage: MessageData = { "role": "status", "content": "Establishing connection with server..." }; type ConversationMode = 'chat' | 'job_description' | 'resume' | 'fact_check'; interface ConversationHandle { submitQuery: (prompt: string, options?: QueryOptions) => void; fetchHistory: () => void; } interface BackstoryMessage { prompt: string; preamble: {}; status: string; full_content: string; response: string; // Set when status === 'done' or 'error' chunk: string; // Used when status === 'streaming' metadata: { rag: { documents: [] }; tools: string[]; eval_count: number; eval_duration: number; prompt_eval_count: number; prompt_eval_duration: number; }; actions: string[]; timestamp: string; }; interface ConversationProps { className?: string, // Override default className type: ConversationMode, // Type of Conversation chat prompt?: string, // Prompt to display in TextField input actionLabel?: string, // Label to put on the primary button resetAction?: () => void, // Callback when Reset is pressed multiline?: boolean, // Render TextField as multiline or not resetLabel?: string, // Label to put on Reset button connectionBase: string, // Base URL for fetch() calls sessionId?: string, // Session ID for fetch() calls setSnack: SetSnackType, // Callback to display snack popups defaultPrompts?: React.ReactElement[], // Set of Elements to display after the TextField defaultQuery?: string, // Default text to populate the TextField input emptyPrompt?: string, // If input is not shown and an action is taken, send this prompt preamble?: MessageList, // Messages to display at start of Conversation until Action has been invoked hidePreamble?: boolean, // Whether to hide the preamble after an Action has been invoked hideDefaultPrompts?: boolean, // Whether to hide the defaultPrompts after an Action has been invoked messageFilter?: ((messages: MessageList) => MessageList) | undefined, // Filter callback to determine which Messages to display in Conversation messages?: MessageList, // sx?: SxProps, onResponse?: ((message: MessageData) => void) | undefined, // Event called when a query completes (provides messages) }; const Conversation = forwardRef(({ className, type, prompt, emptyPrompt, actionLabel, resetAction, multiline, resetLabel, connectionBase, sessionId, setSnack, defaultPrompts, hideDefaultPrompts, defaultQuery, preamble, hidePreamble, messageFilter, messages, sx, onResponse }: ConversationProps, ref) => { const [query, setQuery] = useState(""); const [contextUsedPercentage, setContextUsedPercentage] = useState(0); const [processing, setProcessing] = useState(false); const [countdown, setCountdown] = useState(0); const [conversation, setConversation] = useState([]); const [filteredConversation, setFilteredConversation] = useState([]); const [processingMessage, setProcessingMessage] = useState(undefined); const [streamingMessage, setStreamingMessage] = useState(undefined); const timerRef = useRef(null); const [contextStatus, setContextStatus] = useState({ context_used: 0, max_context: 0 }); const [contextWarningShown, setContextWarningShown] = useState(false); const [noInteractions, setNoInteractions] = useState(true); const conversationRef = useRef([]); const viewableElementRef = useRef(null); // Keep the ref updated whenever items changes useEffect(() => { conversationRef.current = conversation; }, [conversation]); // Update the context status const updateContextStatus = useCallback(() => { const fetchContextStatus = async () => { try { const response = await fetch(connectionBase + `/api/context-status/${sessionId}/${type}`, { method: 'GET', headers: { 'Content-Type': 'application/json', }, }); if (!response.ok) { throw new Error(`Server responded with ${response.status}: ${response.statusText}`); } const data = await response.json(); setContextStatus(data); } catch (error) { console.error('Error getting context status:', error); setSnack("Unable to obtain context status.", "error"); } }; fetchContextStatus(); }, [setContextStatus, connectionBase, setSnack, sessionId, type]); /* Transform the 'Conversation' by filtering via callback, then adding * preamble and messages based on whether the conversation * has any elements yet */ useEffect(() => { let filtered = []; if (messageFilter === undefined) { filtered = conversation; // console.log('No message filter provided. Using all messages.', filtered); } else { //console.log('Filtering conversation...') filtered = messageFilter(conversation); /* Do not copy conversation or useEffect will loop forever */ //console.log(`${conversation.length - filtered.length} messages filtered out.`); } if (filtered.length === 0) { setFilteredConversation([ ...(preamble || []), ...(messages || []), ]); } else { setFilteredConversation([ ...(hidePreamble ? [] : (preamble || [])), ...(messages || []), ...filtered, ]); }; }, [conversation, setFilteredConversation, messageFilter, preamble, messages, hidePreamble]); const fetchHistory = useCallback(async () => { let retries = 5; while (--retries > 0) { try { const response = await fetch(connectionBase + `/api/history/${sessionId}/${type}`, { method: 'GET', headers: { 'Content-Type': 'application/json', }, }); if (!response.ok) { throw new Error(`Server responded with ${response.status}: ${response.statusText}`); } const { messages } = await response.json(); if (messages === undefined || messages.length === 0) { console.log(`History returned for ${type} from server with 0 entries`) setConversation([]) setNoInteractions(true); } else { console.log(`History returned for ${type} from server with ${messages.length} entries:`, messages) const backstoryMessages: BackstoryMessage[] = messages; setConversation(backstoryMessages.flatMap((backstoryMessage: BackstoryMessage) => [{ role: 'user', content: backstoryMessage.prompt || "", }, { ...backstoryMessage, role: backstoryMessage.status === "done" ? "assistant" : backstoryMessage.status, content: backstoryMessage.response || "", }] as MessageList)); setNoInteractions(false); } setProcessingMessage(undefined); setStreamingMessage(undefined); updateContextStatus(); return; } catch (error) { console.error('Error generating session ID:', error); setProcessingMessage({ role: "error", content: `Unable to obtain history from server. Retrying in 3 seconds (${retries} remain.)` }); setTimeout(() => { setProcessingMessage(undefined); }, 3000); await new Promise(resolve => setTimeout(resolve, 3000)); setSnack("Unable to obtain chat history.", "error"); } }; }, [setConversation, updateContextStatus, connectionBase, setSnack, type, sessionId]); // Set the initial chat history to "loading" or the welcome message if loaded. useEffect(() => { if (sessionId === undefined) { setProcessingMessage(loadingMessage); return; } fetchHistory(); }, [fetchHistory, sessionId, setProcessing]); const startCountdown = (seconds: number) => { if (timerRef.current) clearInterval(timerRef.current); setCountdown(seconds); timerRef.current = setInterval(() => { setCountdown((prev) => { if (prev <= 1) { clearInterval(timerRef.current); timerRef.current = null; return 0; } return prev - 1; }); }, 1000); }; const stopCountdown = () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; setCountdown(0); } }; const handleKeyPress = (event: any) => { if (event.key === 'Enter' && !event.shiftKey) { sendQuery(query); } }; useImperativeHandle(ref, () => ({ submitQuery: (query: string, tunables?: QueryOptions) => { sendQuery(query, tunables); }, fetchHistory: () => { return fetchHistory(); } })); // If context status changes, show a warning if necessary. If it drops // back below the threshold, clear the warning trigger useEffect(() => { const context_used_percentage = Math.round(100 * contextStatus.context_used / contextStatus.max_context); if (context_used_percentage >= 90 && !contextWarningShown) { setSnack(`${context_used_percentage}% of context used. You may wish to start a new chat.`, "warning"); setContextWarningShown(true); } if (context_used_percentage < 90 && contextWarningShown) { setContextWarningShown(false); } setContextUsedPercentage(context_used_percentage) }, [contextStatus, setContextWarningShown, contextWarningShown, setContextUsedPercentage, setSnack]); const reset = async () => { try { const response = await fetch(connectionBase + `/api/reset/${sessionId}/${type}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: JSON.stringify({ reset: ['history'] }) }); if (!response.ok) { throw new Error(`Server responded with ${response.status}: ${response.statusText}`); } if (!response.body) { throw new Error('Response body is null'); } setConversation([]) setNoInteractions(true); } catch (e) { setSnack("Error resetting history", "error") console.error('Error resetting history:', e); } }; const sendQuery = async (request: string, options?: QueryOptions) => { request = request.trim(); // If the query was empty, a default query was provided, // and there is no prompt for the user, send the default query. if (!request && defaultQuery && !prompt) { request = defaultQuery.trim(); } // Do not send an empty query. if (!request) { return; } setNoInteractions(false); setConversation([ ...conversationRef.current, { role: 'user', origin: type, content: request, disableCopy: true } ]); // Add a small delay to ensure React has time to update the UI await new Promise(resolve => setTimeout(resolve, 0)); // Clear input setQuery(''); try { setProcessing(true); // Create a unique ID for the processing message const processingId = Date.now().toString(); // Add initial processing message setProcessingMessage( { role: 'status', content: 'Submitting request...', id: processingId, isProcessing: true } ); // Add a small delay to ensure React has time to update the UI await new Promise(resolve => setTimeout(resolve, 0)); // Make the fetch request with proper headers let query; if (options) { query = { options: options, prompt: request.trim() } } else { query = { prompt: request.trim() } } const response = await fetch(connectionBase + `/api/chat/${sessionId}/${type}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: JSON.stringify(query) }); setSnack(`Query sent.`, "info"); if (!response.ok) { throw new Error(`Server responded with ${response.status}: ${response.statusText}`); } if (!response.body) { throw new Error('Response body is null'); } let streaming_response = "" // Set up stream processing with explicit chunking const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; const process_line = async (line: string) => { let update = JSON.parse(line); switch (update.status) { case 'done': console.log('Done processing:', update); stopCountdown(); setStreamingMessage(undefined); setProcessingMessage(undefined); const backstoryMessage: BackstoryMessage = update; setConversation([ ...conversationRef.current, { ...backstoryMessage, role: 'assistant', origin: type, content: backstoryMessage.response || "", }] as MessageList); // Add a small delay to ensure React has time to update the UI await new Promise(resolve => setTimeout(resolve, 0)); const metadata = update.metadata; if (metadata) { updateContextStatus(); } if (onResponse) { onResponse(update); } break; case 'error': // Show error setConversation([ ...conversationRef.current, { ...update, role: 'error', origin: type, content: update.response || "", }] as MessageList); setProcessing(false); stopCountdown(); // Add a small delay to ensure React has time to update the UI await new Promise(resolve => setTimeout(resolve, 0)); break; default: // Force an immediate state update based on the message type // Update processing message with immediate re-render if (update.status === "streaming") { streaming_response += update.chunk setStreamingMessage({ role: update.status, content: streaming_response }); } else { setProcessingMessage({ role: update.status, content: update.response }); /* Reset stream on non streaming message */ streaming_response = "" } startCountdown(Math.ceil(update.remaining_time)); // Add a small delay to ensure React has time to update the UI await new Promise(resolve => setTimeout(resolve, 0)); break; } } while (true) { const { done, value } = await reader.read(); if (done) { break; } const chunk = decoder.decode(value, { stream: true }); // Process each complete line immediately buffer += chunk; let lines = buffer.split('\n'); buffer = lines.pop() || ''; // Keep incomplete line in buffer for (const line of lines) { if (!line.trim()) continue; try { await process_line(line); } catch (e) { setSnack("Error processing query", "error") console.error(e); } } } // Process any remaining buffer content if (buffer.trim()) { try { await process_line(buffer); } catch (e) { setSnack("Error processing query", "error") console.error(e); } } stopCountdown(); setProcessing(false); } catch (error) { console.error('Fetch error:', error); setSnack("Unable to process query", "error"); setProcessingMessage({ role: 'error', content: "Unable to process query" }); setTimeout(() => { setProcessingMessage(undefined); }, 5000); setProcessing(false); stopCountdown(); // Add a small delay to ensure React has time to update the UI await new Promise(resolve => setTimeout(resolve, 0)); } }; return ( { filteredConversation.map((message, index) => ) } { processingMessage !== undefined && } { streamingMessage !== undefined && } {processing === true && countdown > 0 && ( Response will be stopped in: {countdown}s )} {prompt &&
setQuery(e.target.value)} onKeyDown={handleKeyPress} placeholder={prompt} />
} { reset(); resetAction && resetAction(); }} />
{(noInteractions || !hideDefaultPrompts) && defaultPrompts !== undefined && defaultPrompts.length && { defaultPrompts.map((element, index) => { return ({element}); }) } } Context used: {contextUsedPercentage}% {contextStatus.context_used}/{contextStatus.max_context} { contextUsedPercentage >= 90 ? WARNING: Context almost exhausted. You should start a new chat. : (contextUsedPercentage >= 50 ? NOTE: Context is getting long. Queries will be slower, and the LLM may stop issuing tool calls. : <>) }
); }); export type { ConversationProps, ConversationHandle, }; export { Conversation };