import React, { forwardRef, useState, useEffect, useRef, JSX } from 'react'; import { Box, Button, Tooltip, SxProps } from '@mui/material'; import { Send as SendIcon } from '@mui/icons-material'; import { useAuth } from 'hooks/AuthContext'; import { ChatMessage, ChatSession, ChatMessageUser, ChatMessageError, ChatMessageStreaming, ChatMessageStatus, ChatMessageMetaData, CandidateQuestion, Resume, } from 'types/types'; import { ConversationHandle } from 'components/Conversation'; import { Message } from 'components/Message'; import { DeleteConfirmation } from 'components/DeleteConfirmation'; import { CandidateInfo } from 'components/ui/CandidateInfo'; import { useAppState, useSelectedCandidate, useSelectedJob } from 'hooks/GlobalContext'; import PropagateLoader from 'react-spinners/PropagateLoader'; import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField'; import { BackstoryQuery } from 'components/BackstoryQuery'; import { CandidatePicker } from 'components/ui/CandidatePicker'; import { Scrollable } from 'components/Scrollable'; import { useParams } from 'react-router-dom'; import ResumePreview from 'components/ui/ResumePreview'; import { formatDate } from 'utils/formatDate'; const emptyMetadata: ChatMessageMetaData = { model: 'qwen2.5', temperature: 0, maxTokens: 0, topP: 0, frequencyPenalty: 0, presencePenalty: 0, stopSequences: [], usage: { evalCount: 0, evalDuration: 0, promptEvalCount: 0, promptEvalDuration: 0, }, }; const defaultMessage: ChatMessage = { status: 'done', type: 'text', sessionId: '', timestamp: new Date(), content: '', role: 'user', metadata: emptyMetadata, }; const defaultQuestion: CandidateQuestion = { question: 'How well does the resume align with the job description? What are the three key strengths and two greatest weaknesses?', }; interface CandidateChatPageProps { sx?: SxProps; // Optional styles for the component } const CandidateChatPage = forwardRef( (props: CandidateChatPageProps, ref): JSX.Element => { const { resumeId } = useParams<{ resumeId?: string }>(); const { selectedJob, setSelectedJob } = useSelectedJob(); const [resume, setResume] = useState(null); const { sx } = props; const { apiClient } = useAuth(); const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate(); const [processingMessage, setProcessingMessage] = useState< ChatMessageStatus | ChatMessageError | null >(null); const [streamingMessage, setStreamingMessage] = useState(null); const backstoryTextRef = useRef(null); const { setSnack } = useAppState(); const [chatSession, setChatSession] = useState(null); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(false); const [streaming, setStreaming] = useState(false); const messagesEndRef = useRef(null); useEffect(() => { if (!resumeId || resume) return; apiClient .getResume(resumeId) .then(resume => { setResume(resume); if (resume.candidate && resume.candidate !== selectedCandidate) { setSelectedCandidate(resume.candidate); } if (resume.job && resume.job !== selectedJob) { setSelectedJob(resume.job); } }) .catch(error => { console.error('Failed to load resume:', error); setSnack('Failed to load resume', 'error'); }); }, [resumeId, resume, apiClient, setSnack]); const onDelete = async (session: ChatSession): Promise => { if (!session.id) { return; } try { await apiClient.resetChatSession(session.id); // If we're deleting the currently selected session, clear it setMessages([]); setSnack('Session reset succeeded', 'success'); } catch (error) { console.error('Failed to delete session:', error); setSnack('Failed to delete session', 'error'); } }; // Send message const sendMessage = async (message: string): Promise => { if (!message.trim() || !chatSession?.id || streaming || !selectedCandidate) return; const messageContent = message; setStreaming(true); const chatMessage: ChatMessageUser = { sessionId: chatSession.id, role: 'user', content: messageContent, status: 'done', type: 'text', timestamp: new Date(), extraContext: { candidateId: resume?.candidate?.id, jobId: resume?.job?.id, resumeId: resume?.id, }, }; setProcessingMessage({ ...defaultMessage, status: 'status', activity: 'info', content: `Establishing connection with ${selectedCandidate.firstName}'s chat session.`, }); setMessages(prev => { const filtered = prev.filter(m => m.id !== chatMessage.id); return [...filtered, chatMessage] as ChatMessage[]; }); try { apiClient.sendMessageStream(chatMessage, { onMessage: (msg: ChatMessage): void => { setMessages(prev => { const filtered = prev.filter(m => m.id !== msg.id); return [...filtered, msg] as ChatMessage[]; }); setStreamingMessage(null); setProcessingMessage(null); }, onError: (error: string | ChatMessageError): void => { console.log('onError:', error); // Type-guard to determine if this is a ChatMessageBase or a string if (typeof error === 'object' && error !== null && 'error' in error) { setSnack(`Error: ${error.error}`, 'error'); } else if (typeof error === 'string') { setSnack(`Error: ${error}`, 'error'); } else { setSnack(`An unknown error occurred: ${JSON.stringify(error)}`, 'error'); } setStreaming(false); }, onStreaming: (chunk: ChatMessageStreaming): void => { // console.log("onStreaming:", chunk); setStreamingMessage({ ...chunk, role: 'assistant', metadata: emptyMetadata, }); }, onStatus: (status: ChatMessageStatus): void => { setProcessingMessage(status); }, onComplete: (): void => { console.log('onComplete'); setStreamingMessage(null); setProcessingMessage(null); setStreaming(false); }, }); } catch (error) { console.error('Failed to send message:', error); setStreaming(false); } }; // Auto-scroll to bottom when new messages arrive useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); // Load sessions when username changes useEffect(() => { if (!selectedCandidate) return; try { setLoading(true); apiClient .getOrCreateChatSession( selectedCandidate, resumeId ? `Backstory Resume chat ${resumeId}` : `Backstory Chat with ${selectedCandidate.fullName}`, resumeId ? 'resume_chat' : 'candidate_chat' ) .then(session => { setChatSession(session); setLoading(false); }); } catch (error) { setSnack('Unable to load chat session', 'error'); } finally { setLoading(false); } }, [selectedCandidate, apiClient, setSnack]); // Load messages when session changes useEffect(() => { const loadMessages = async (): Promise => { if (!chatSession?.id) return; try { const result = await apiClient.getChatMessages(chatSession.id); const chatMessages: ChatMessage[] = result.data; setMessages(chatMessages); setProcessingMessage(null); setStreamingMessage(null); console.log(`getChatMessages returned ${chatMessages.length} messages.`, chatMessages); } catch (error) { console.error('Failed to load messages:', error); } }; if (chatSession?.id) { loadMessages(); } }, [chatSession, apiClient]); if (!selectedCandidate) { return ; } const welcomeMessage: ChatMessage = { sessionId: chatSession?.id || '', role: 'information', type: 'text', status: 'done', timestamp: new Date(), content: `Welcome to the Backstory Chat about ${selectedCandidate.fullName}` + (resume && ` and the ${resume.job?.title} position at ${resume.job?.company}`) + `. Enter any questions you have about ${selectedCandidate.firstName}'${ selectedCandidate.firstName.slice(-1) !== 's' ? 's' : '' } resume or skills, or select from the available questions.`, metadata: emptyMetadata, }; const handleSubmitQuestion = (question: CandidateQuestion): void => { sendMessage(question.question); }; return ( *:not(.Scrollable)': { flexShrink: 0 /* Prevent shrinking */, }, ...sx, p: 0, m: 0, backgroundColor: '#D3CDBF' /* Warm Gray */, }} > {resume && ( {resume.job?.title} at  {resume.job?.company}. Last updated{' '} {formatDate(resume.updatedAt, false, true)}. )} {!resume && ( )} {/* Chat Interface */} {/* Scrollable Messages Area */} {chatSession && ( {messages.length === 0 && } {messages.map((message: ChatMessage) => ( ))} {processingMessage !== null && ( )} {streamingMessage !== null && ( )} {streaming && ( )}
)} {selectedCandidate.questions?.map((q, i) => ( ))} {resume && ( )} {/* Fixed Message Input */} { chatSession && onDelete(chatSession); }} disabled={!chatSession} sx={{ minWidth: 'auto', px: 2, maxHeight: 'min-content' }} action="reset" label="chat session" title="Reset Chat Session" message={`Are you sure you want to reset the session? This action cannot be undone.`} /> ); } ); CandidateChatPage.displayName = 'CandidateChatPage'; export { CandidateChatPage };