backstory/frontend/src/pages/CandidateChatPage.tsx

446 lines
15 KiB
TypeScript

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<ConversationHandle, CandidateChatPageProps>(
(props: CandidateChatPageProps, ref): JSX.Element => {
const { resumeId } = useParams<{ resumeId?: string }>();
const { selectedJob, setSelectedJob } = useSelectedJob();
const [resume, setResume] = useState<Resume | null>(null);
const { sx } = props;
const { apiClient } = useAuth();
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();
const [processingMessage, setProcessingMessage] = useState<
ChatMessageStatus | ChatMessageError | null
>(null);
const [streamingMessage, setStreamingMessage] = useState<ChatMessage | null>(null);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
const { setSnack } = useAppState();
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [streaming, setStreaming] = useState<boolean>(false);
const messagesEndRef = useRef<HTMLDivElement>(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<void> => {
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<void> => {
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<void> => {
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 <CandidatePicker />;
}
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 (
<Box
ref={ref}
sx={{
display: 'flex',
flexDirection: 'row',
height: '100%' /* Restrict to main-container's height */,
width: '100%',
minHeight: 0 /* Prevent flex overflow */,
maxHeight: '100%',
'& > *:not(.Scrollable)': {
flexShrink: 0 /* Prevent shrinking */,
},
...sx,
p: 0,
m: 0,
backgroundColor: '#D3CDBF' /* Warm Gray */,
}}
>
{resume && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
width: '50%',
maxHeight: '100%',
p: 0,
m: 0,
position: 'relative',
backgroundColor: '#f5f5f5',
}}
>
<Box
sx={{
position: 'relative',
display: 'flex',
justifyContent: 'center',
p: 1,
}}
>
<strong>{resume.job?.title}</strong>&nbsp;at&nbsp;
<strong>{resume.job?.company}</strong>. Last updated{' '}
{formatDate(resume.updatedAt, false, true)}.
</Box>
<Scrollable sx={{ m: 0 }}>
<ResumePreview shadeMargins={false} resume={resume} />
</Scrollable>
</Box>
)}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
width: '50%',
p: 1,
m: resume ? 0 : '0 auto',
position: 'relative',
backgroundColor: 'background.paper',
}}
>
{!resume && (
<CandidateInfo
key={selectedCandidate.username}
action={`Chat with Backstory about ${selectedCandidate.firstName}`}
elevation={4}
candidate={selectedCandidate}
variant="normal"
sx={{
width: '100%',
maxHeight: 0,
minHeight: 'min-content',
}} // Prevent header from shrinking
/>
)}
{/* Chat Interface */}
{/* Scrollable Messages Area */}
{chatSession && (
<Scrollable
sx={{
width: '100%',
}}
>
<Box sx={{ display: 'flex', flexGrow: 1 }}></Box>
{messages.length === 0 && <Message {...{ chatSession, message: welcomeMessage }} />}
{messages.map((message: ChatMessage) => (
<Message key={message.id} {...{ chatSession, message }} />
))}
{processingMessage !== null && (
<Message {...{ chatSession, message: processingMessage }} />
)}
{streamingMessage !== null && (
<Message {...{ chatSession, message: streamingMessage }} />
)}
{streaming && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
m: 1,
}}
>
<PropagateLoader
size="10px"
loading={streaming}
aria-label="Loading Spinner"
data-testid="loader"
/>
</Box>
)}
<div ref={messagesEndRef} />
</Scrollable>
)}
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1, p: 1, flex: 0 }}>
{selectedCandidate.questions?.map((q, i) => (
<BackstoryQuery key={i} question={q} submitQuery={handleSubmitQuestion} />
))}
{resume && (
<BackstoryQuery question={defaultQuestion} submitQuery={handleSubmitQuestion} />
)}
</Box>
{/* Fixed Message Input */}
<Box sx={{ display: 'flex', gap: 1 }}>
<DeleteConfirmation
onDelete={(): void => {
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.`}
/>
<BackstoryTextField
placeholder="Type your message about the candidate..."
ref={backstoryTextRef}
onEnter={sendMessage}
disabled={streaming || loading}
/>
<Tooltip title="Send">
<span
style={{
minWidth: 'auto',
maxHeight: 'min-content',
alignSelf: 'center',
}}
>
<Button
variant="contained"
onClick={(): void => {
sendMessage(
(backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) ||
''
);
}}
disabled={streaming || loading}
>
<SendIcon />
</Button>
</span>
</Tooltip>
</Box>
</Box>
</Box>
);
}
);
CandidateChatPage.displayName = 'CandidateChatPage';
export { CandidateChatPage };