diff --git a/frontend/src/components/ui/TransientChat.tsx b/frontend/src/components/ui/TransientChat.tsx new file mode 100644 index 0000000..c88fa00 --- /dev/null +++ b/frontend/src/components/ui/TransientChat.tsx @@ -0,0 +1,261 @@ +import React, { forwardRef, useState, useEffect, useRef, JSX, useImperativeHandle } from 'react'; +import { Box } from '@mui/material'; +import { useAuth } from 'hooks/AuthContext'; +import { + ChatMessage, + ChatSession, + ChatMessageUser, + ChatMessageError, + ChatMessageStreaming, + ChatMessageStatus, + ChatMessageMetaData, + Candidate, + ChatQuery, +} from 'types/types'; +import { ConversationHandle } from 'components/Conversation'; +import { Message } from 'components/Message'; +import { useAppState } from 'hooks/GlobalContext'; +import PropagateLoader from 'react-spinners/PropagateLoader'; +import { Scrollable } from 'components/Scrollable'; + +const emptyMetadata: ChatMessageMetaData = { + model: 'qwen2.5', + temperature: 0, + maxTokens: 0, + topP: 0, + frequencyPenalty: 0, + presencePenalty: 0, + stopSequences: [], + evalCount: 0, + evalDuration: 0, + promptEvalCount: 0, + promptEvalDuration: 0, +}; + +const defaultMessage: ChatMessage = { + status: 'done', + type: 'text', + sessionId: '', + timestamp: new Date(), + content: '', + role: 'user', + metadata: emptyMetadata, +}; + +interface TransientChatProps { + id: string; +} + +const TransientChat = forwardRef( + (props, ref): JSX.Element => { + const { id } = props; + const { apiClient, user } = useAuth(); + const [processingMessage, setProcessingMessage] = useState< + ChatMessageStatus | ChatMessageError | null + >(null); + const [streamingMessage, setStreamingMessage] = useState(null); + + const { setSnack } = useAppState(); + + const [chatSession, setChatSession] = useState(null); + const [messages, setMessages] = useState([]); + const [streaming, setStreaming] = useState(false); + const messagesEndRef = useRef(null); + + useImperativeHandle(ref, () => ({ + submitQuery: (query: ChatQuery): void => { + sendMessage(query.prompt); + }, + fetchHistory: (): void => { + console.log('fetchHistory called, but not implemented'); + }, + })); + + const chatHandlers = { + 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 && 'content' in error) { + setProcessingMessage(error); + } else { + setProcessingMessage({ + ...defaultMessage, + status: 'error', + content: 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); + }, + }; + + // Send message + const sendMessage = async (message: string): Promise => { + if (!message.trim() || !chatSession?.id || streaming) 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 chat session.`, + }); + + setMessages(prev => { + const filtered = prev.filter(m => m.id !== chatMessage.id); + return [...filtered, chatMessage] as ChatMessage[]; + }); + + try { + apiClient.sendMessageStream(chatMessage, chatHandlers); + } 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 (!user) return; + try { + apiClient + .getOrCreateChatSession(user as Candidate, `Transient chat - ${id}`, 'candidate_chat') + .then(session => { + setChatSession(session); + }); + } catch (error) { + setSnack('Unable to load chat session', 'error'); + } + }, [user, 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]); + + return ( + *:not(.Scrollable)': { + flexShrink: 0 /* Prevent shrinking */, + }, + position: 'relative', + }} + > + {/* Chat Interface */} + {/* Scrollable Messages Area */} + {chatSession && ( + + {messages.map((message: ChatMessage) => ( + + ))} + {processingMessage !== null && ( + + )} + {streamingMessage !== null && ( + + )} + {streaming && ( + + + + )} +
+ + )} + + ); + } +); +TransientChat.displayName = 'TransientChat'; +export { TransientChat }; diff --git a/frontend/src/pages/candidate/Profile.tsx b/frontend/src/pages/candidate/Profile.tsx index 9eb5f4e..038e41e 100644 --- a/frontend/src/pages/candidate/Profile.tsx +++ b/frontend/src/pages/candidate/Profile.tsx @@ -48,8 +48,10 @@ import { import { useTheme } from '@mui/material/styles'; import { useAuth } from 'hooks/AuthContext'; import * as Types from 'types/types'; -import { BackstoryPageProps } from 'components/BackstoryTab'; import { useAppState } from 'hooks/GlobalContext'; +import { BackstoryQuery } from 'components/BackstoryQuery'; +import { TransientChat } from 'components/ui/TransientChat'; +import { ConversationHandle } from 'components/Conversation'; // Styled components const VisuallyHiddenInput = styled('input')({ @@ -101,6 +103,7 @@ const CandidateProfile: React.FC = () => { const { setSnack } = useAppState(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const { user, updateUserData, apiClient } = useAuth(); + const chatRef = React.useRef(null); // Check if user is a candidate const candidate = user?.userType === 'candidate' ? (user as Types.Candidate) : null; @@ -640,6 +643,14 @@ const CandidateProfile: React.FC = () => { ); + const handleSubmitQuestion = (question: Types.CandidateQuestion): void => { + console.log('Submitting question:', question); + const query: Types.ChatQuery = { + prompt: question.question, + }; + chatRef.current?.submitQuery(query); + }; + // Questions Tab const renderQuestions = (): JSX.Element => ( @@ -665,6 +676,9 @@ const CandidateProfile: React.FC = () => { + See how Backstory answers the question about you... + + {(formData.questions || []).map((question, index) => ( @@ -678,6 +692,8 @@ const CandidateProfile: React.FC = () => { }} > + + {/* Display question text { }} > {question.question} - + */} {/* {question.category && (