import React, { useState, useImperativeHandle, forwardRef, useEffect, useRef, useCallback } from 'react'; import Typography from '@mui/material/Typography'; import Tooltip from '@mui/material/Tooltip'; import IconButton from '@mui/material/IconButton'; import Button from '@mui/material/Button'; import Box from '@mui/material/Box'; import SendIcon from '@mui/icons-material/Send'; import CancelIcon from '@mui/icons-material/Cancel'; import { SxProps, Theme } from '@mui/material'; import PropagateLoader from "react-spinners/PropagateLoader"; import { Message, MessageList, BackstoryMessage } from './Message'; import { ContextStatus } from './ContextStatus'; import { Scrollable } from './Scrollable'; import { DeleteConfirmation } from './DeleteConfirmation'; import { Query } from './ChatQuery'; import './Conversation.css'; import { BackstoryTextField, BackstoryTextFieldRef } from './BackstoryTextField'; import { BackstoryElementProps } from './BackstoryTab'; import { connectionBase } from '../Global'; const loadingMessage: BackstoryMessage = { "role": "status", "content": "Establishing connection with server..." }; type ConversationMode = 'chat' | 'job_description' | 'resume' | 'fact_check'; interface ConversationHandle { submitQuery: (query: Query) => void; fetchHistory: () => void; } interface ConversationProps extends BackstoryElementProps { className?: string, // Override default className type: ConversationMode, // Type of Conversation chat placeholder?: string, // Prompt to display in TextField input actionLabel?: string, // Label to put on the primary button resetAction?: () => void, // Callback when Reset is pressed resetLabel?: string, // Label to put on Reset button defaultPrompts?: React.ReactElement[], // Set of Elements to display after the TextField defaultQuery?: string, // Default text to populate the TextField input 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: BackstoryMessage) => void) | undefined, // Event called when a query completes (provides messages) }; const Conversation = forwardRef((props: ConversationProps, ref) => { const { sessionId, actionLabel, className, defaultPrompts, defaultQuery, hideDefaultPrompts, hidePreamble, messageFilter, messages, onResponse, placeholder, preamble, resetAction, resetLabel, setSnack, submitQuery, sx, type, } = props; 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); const backstoryTextRef = useRef(null); const stopRef = useRef(false); // 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, 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) => { if (backstoryMessage.status === "partial") { return [{ ...backstoryMessage, role: "assistant", content: backstoryMessage.response || "", expanded: false, expandable: true, }] } return [{ role: 'user', content: backstoryMessage.prompt || "", }, { ...backstoryMessage, role: ['done'].includes(backstoryMessage.status || "") ? "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, 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 handleEnter = (value: string) => { const query: Query = { prompt: value } sendQuery(query); }; useImperativeHandle(ref, () => ({ submitQuery: (query: Query) => { sendQuery(query); }, 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'); } setProcessingMessage(undefined); setStreamingMessage(undefined); setConversation([]); setNoInteractions(true); } catch (e) { setSnack("Error resetting history", "error") console.error('Error resetting history:', e); } }; const cancelQuery = () => { console.log("Stop query"); stopRef.current = true; }; const sendQuery = async (query: Query) => { query.prompt = query.prompt.trim(); // If the request was empty, a default request was provided, // and there is no prompt for the user, send the default request. if (!query.prompt && defaultQuery && !prompt) { query.prompt = defaultQuery.trim(); } // Do not send an empty request. if (!query.prompt) { return; } stopRef.current = false; setNoInteractions(false); setConversation([ ...conversationRef.current, { role: 'user', origin: type, content: query.prompt, disableCopy: true } ]); // Add a small delay to ensure React has time to update the UI await new Promise(resolve => setTimeout(resolve, 0)); try { setProcessing(true); // Add initial processing message setProcessingMessage( { role: 'status', content: 'Submitting request...', disableCopy: true } ); // Add a small delay to ensure React has time to update the UI await new Promise(resolve => setTimeout(resolve, 0)); let data: any = query; if (type === "job_description") { data = { prompt: "", agent_options: { job_description: query.prompt, } } } const response = await fetch(connectionBase + `/api/${type}/${sessionId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: JSON.stringify(data) }); 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': case 'partial': if (update.status === 'done') stopCountdown(); if (update.status === 'done') setStreamingMessage(undefined); if (update.status === 'done') setProcessingMessage(undefined); const backstoryMessage: BackstoryMessage = update; setConversation([ ...conversationRef.current, { ...backstoryMessage, role: 'assistant', origin: type, prompt: ['done', 'partial'].includes(update.status) ? update.prompt : '', content: backstoryMessage.response || "", expanded: update.status === "done" ? true : false, expandable: update.status === "done" ? false : true, }] 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, disableCopy: true }); } else { setProcessingMessage({ role: update.status, content: update.response, disableCopy: true }); /* 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 (!stopRef.current) { 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); } } if (stopRef.current) { await reader.cancel(); setProcessingMessage(undefined); setStreamingMessage(undefined); setSnack("Processing cancelled", "warning"); } stopCountdown(); setProcessing(false); stopRef.current = false; } catch (error) { console.error('Fetch error:', error); setSnack("Unable to process query", "error"); setProcessingMessage({ role: 'error', content: "Unable to process query", disableCopy: true }); setTimeout(() => { setProcessingMessage(undefined); }, 5000); stopRef.current = false; setProcessing(false); stopCountdown(); return; } }; return ( // { filteredConversation.map((message, index) => ) } { processingMessage !== undefined && } { streamingMessage !== undefined && } {processing === true && countdown > 0 && ( Response will be stopped in: {countdown}s )} {placeholder && } { reset(); resetAction && resetAction(); }} /> { /* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */} { cancelQuery(); }} sx={{ display: "flex", margin: 'auto 0px' }} size="large" edge="start" disabled={stopRef.current || sessionId === undefined || processing === false} > {(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 };