351 lines
11 KiB
TypeScript
351 lines
11 KiB
TypeScript
import React, { forwardRef, useState, useEffect, useRef } from 'react';
|
|
import { Box, Paper, Button, Divider, useTheme, useMediaQuery, Tooltip } from '@mui/material';
|
|
import { Send as SendIcon } from '@mui/icons-material';
|
|
import { useAuth } from 'hooks/AuthContext';
|
|
import {
|
|
ChatMessage,
|
|
ChatSession,
|
|
ChatMessageUser,
|
|
ChatMessageError,
|
|
ChatMessageStreaming,
|
|
ChatMessageStatus,
|
|
} from 'types/types';
|
|
import { ConversationHandle } from 'components/Conversation';
|
|
import { BackstoryPageProps } from 'components/BackstoryTab';
|
|
import { Message } from 'components/Message';
|
|
import { DeleteConfirmation } from 'components/DeleteConfirmation';
|
|
import { CandidateInfo } from 'components/ui/CandidateInfo';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useAppState, useSelectedCandidate } 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';
|
|
|
|
const defaultMessage: ChatMessage = {
|
|
status: 'done',
|
|
type: 'text',
|
|
sessionId: '',
|
|
timestamp: new Date(),
|
|
content: '',
|
|
role: 'user',
|
|
metadata: null as any,
|
|
};
|
|
|
|
const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>(
|
|
(props: BackstoryPageProps, ref) => {
|
|
const { apiClient } = useAuth();
|
|
const navigate = useNavigate();
|
|
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();
|
|
const theme = useTheme();
|
|
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(null);
|
|
|
|
// Load messages for current session
|
|
const loadMessages = async () => {
|
|
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);
|
|
}
|
|
};
|
|
|
|
const onDelete = async (session: ChatSession) => {
|
|
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) => {
|
|
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(),
|
|
};
|
|
|
|
setProcessingMessage({
|
|
...defaultMessage,
|
|
status: 'status',
|
|
activity: 'info',
|
|
content: `Establishing connection with ${selectedCandidate.firstName}'s chat session.`,
|
|
});
|
|
|
|
setMessages(prev => {
|
|
const filtered = prev.filter((m: any) => m.id !== chatMessage.id);
|
|
return [...filtered, chatMessage] as any;
|
|
});
|
|
|
|
try {
|
|
apiClient.sendMessageStream(chatMessage, {
|
|
onMessage: (msg: ChatMessage) => {
|
|
setMessages(prev => {
|
|
const filtered = prev.filter((m: any) => m.id !== msg.id);
|
|
return [...filtered, msg] as any;
|
|
});
|
|
setStreamingMessage(null);
|
|
setProcessingMessage(null);
|
|
},
|
|
onError: (error: string | ChatMessageError) => {
|
|
console.log('onError:', error);
|
|
let message: string;
|
|
// Type-guard to determine if this is a ChatMessageBase or a string
|
|
if (typeof error === 'object' && error !== null && 'content' in error) {
|
|
setProcessingMessage(error);
|
|
message = error.content as string;
|
|
} else {
|
|
setProcessingMessage({
|
|
...defaultMessage,
|
|
status: 'error',
|
|
content: error,
|
|
});
|
|
}
|
|
setStreaming(false);
|
|
},
|
|
onStreaming: (chunk: ChatMessageStreaming) => {
|
|
// console.log("onStreaming:", chunk);
|
|
setStreamingMessage({
|
|
...chunk,
|
|
role: 'assistant',
|
|
metadata: null as any,
|
|
});
|
|
},
|
|
onStatus: (status: ChatMessageStatus) => {
|
|
setProcessingMessage(status);
|
|
},
|
|
onComplete: () => {
|
|
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 as any)?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [messages]);
|
|
|
|
// Load sessions when username changes
|
|
useEffect(() => {
|
|
if (!selectedCandidate) return;
|
|
try {
|
|
setLoading(true);
|
|
apiClient
|
|
.getOrCreateChatSession(
|
|
selectedCandidate,
|
|
`Backstory chat with ${selectedCandidate.fullName}`,
|
|
'candidate_chat'
|
|
)
|
|
.then(session => {
|
|
setChatSession(session);
|
|
setLoading(false);
|
|
});
|
|
} catch (error) {
|
|
setSnack('Unable to load chat session', 'error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [selectedCandidate]);
|
|
|
|
// Load messages when session changes
|
|
useEffect(() => {
|
|
if (chatSession?.id) {
|
|
loadMessages();
|
|
}
|
|
}, [chatSession]);
|
|
|
|
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}. Ask any questions you have about ${selectedCandidate.firstName}.`,
|
|
metadata: null as any,
|
|
};
|
|
|
|
return (
|
|
<Box
|
|
ref={ref}
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
height: '100%' /* Restrict to main-container's height */,
|
|
width: '100%',
|
|
minHeight: 0 /* Prevent flex overflow */,
|
|
maxHeight: 'min-content',
|
|
'& > *:not(.Scrollable)': {
|
|
flexShrink: 0 /* Prevent shrinking */,
|
|
},
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<Paper elevation={2} sx={{ m: 1, p: 1 }}>
|
|
<CandidateInfo
|
|
key={selectedCandidate.username}
|
|
action={`Chat with Backstory about ${selectedCandidate.firstName}`}
|
|
elevation={4}
|
|
candidate={selectedCandidate}
|
|
variant="small"
|
|
sx={{
|
|
flexShrink: 1,
|
|
width: '100%',
|
|
maxHeight: 0,
|
|
minHeight: 'min-content',
|
|
}} // Prevent header from shrinking
|
|
/>
|
|
<Button
|
|
sx={{ maxWidth: 'max-content' }}
|
|
onClick={() => {
|
|
setSelectedCandidate(null);
|
|
}}
|
|
variant="contained"
|
|
>
|
|
Change Candidates
|
|
</Button>
|
|
</Paper>
|
|
{/* Chat Interface */}
|
|
{/* Scrollable Messages Area */}
|
|
{chatSession && (
|
|
<Scrollable
|
|
sx={{
|
|
position: 'relative',
|
|
maxHeight: '100%',
|
|
width: '100%',
|
|
display: 'flex',
|
|
flexGrow: 1,
|
|
flex: 1 /* Take remaining space in some-container */,
|
|
overflowY: 'auto' /* Scroll if content overflows */,
|
|
pt: 2,
|
|
pl: 1,
|
|
pr: 1,
|
|
pb: 2,
|
|
}}
|
|
>
|
|
{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>
|
|
)}
|
|
{selectedCandidate.questions?.length !== 0 &&
|
|
selectedCandidate.questions?.map(q => <BackstoryQuery question={q} />)}
|
|
{/* Fixed Message Input */}
|
|
<Box sx={{ display: 'flex', flexShrink: 1, gap: 1 }}>
|
|
<DeleteConfirmation
|
|
onDelete={() => {
|
|
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={() => {
|
|
sendMessage(
|
|
(backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || ''
|
|
);
|
|
}}
|
|
disabled={streaming || loading}
|
|
>
|
|
<SendIcon />
|
|
</Button>
|
|
</span>
|
|
</Tooltip>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
);
|
|
|
|
export { CandidateChatPage };
|