Compare commits

..

3 Commits

3 changed files with 543 additions and 76 deletions

View File

@ -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<ConversationHandle, TransientChatProps>(
(props, ref): JSX.Element => {
const { id } = props;
const { apiClient, user } = useAuth();
const [processingMessage, setProcessingMessage] = useState<
ChatMessageStatus | ChatMessageError | null
>(null);
const [streamingMessage, setStreamingMessage] = useState<ChatMessage | null>(null);
const { setSnack } = useAppState();
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streaming, setStreaming] = useState<boolean>(false);
const messagesEndRef = useRef<HTMLDivElement>(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<void> => {
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<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]);
return (
<Box
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',
}}
>
{/* 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.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>
);
}
);
TransientChat.displayName = 'TransientChat';
export { TransientChat };

View File

@ -104,7 +104,7 @@ const capitalize = (str: string): string => {
};
// Main component
const JobAnalysisPage: React.FC<BackstoryPageProps> = (_props: BackstoryPageProps) => {
const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
const theme = useTheme();
const { user, guest } = useAuth();
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();

View File

@ -13,7 +13,6 @@ import {
Tab,
useMediaQuery,
CircularProgress,
Snackbar,
Alert,
Card,
CardContent,
@ -44,13 +43,15 @@ import {
Phone,
Email,
AccountCircle,
LiveHelp,
} from '@mui/icons-material';
import { useTheme } from '@mui/material/styles';
import { useAuth } from 'hooks/AuthContext';
import * as Types from 'types/types';
import { ComingSoon } from 'components/ui/ComingSoon';
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')({
@ -67,22 +68,22 @@ const VisuallyHiddenInput = styled('input')({
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
value: string;
active: string;
}
function TabPanel(props: TabPanelProps): JSX.Element {
const { children, value, index, ...other } = props;
const { children, value, active, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`profile-tabpanel-${index}`}
aria-labelledby={`profile-tab-${index}`}
hidden={active !== value}
id={`profile-tabpanel-${value}`}
aria-labelledby={`profile-tab-${value}`}
{...other}
>
{value === index && (
{value === active && (
<Box
sx={{
p: { xs: 1, sm: 3 },
@ -97,28 +98,20 @@ function TabPanel(props: TabPanelProps): JSX.Element {
);
}
const CandidateProfile: React.FC<BackstoryPageProps> = (_props: BackstoryPageProps) => {
const { setSnack } = useAppState();
const CandidateProfile: React.FC = () => {
const theme = useTheme();
const { setSnack } = useAppState();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const { user, updateUserData, apiClient } = useAuth();
const chatRef = React.useRef<ConversationHandle>(null);
// Check if user is a candidate
const candidate = user?.userType === 'candidate' ? (user as Types.Candidate) : null;
// State management
const [tabValue, setTabValue] = useState(0);
const [tabValue, setTabValue] = useState<string>('info');
const [editMode, setEditMode] = useState<{ [key: string]: boolean }>({});
const [loading, setLoading] = useState(false);
const [snackbar, setSnackbar] = useState<{
open: boolean;
message: string;
severity: 'success' | 'error' | 'info' | 'warning';
}>({
open: false,
message: '',
severity: 'success',
});
// Form data state
const [formData, setFormData] = useState<Partial<Types.Candidate>>({});
@ -126,6 +119,7 @@ const CandidateProfile: React.FC<BackstoryPageProps> = (_props: BackstoryPagePro
// Dialog states
const [skillDialog, setSkillDialog] = useState(false);
const [questionDialog, setQuestionDialog] = useState(false);
const [experienceDialog, setExperienceDialog] = useState(false);
// New item states
@ -135,6 +129,11 @@ const CandidateProfile: React.FC<BackstoryPageProps> = (_props: BackstoryPagePro
level: 'beginner',
yearsOfExperience: 0,
});
const [newQuestion, setNewQuestion] = useState<Partial<Types.CandidateQuestion>>({
question: '',
});
const [newExperience, setNewExperience] = useState<Partial<Types.WorkExperience>>({
companyName: '',
position: '',
@ -153,7 +152,6 @@ const CandidateProfile: React.FC<BackstoryPageProps> = (_props: BackstoryPagePro
} else {
setProfileImage('');
}
console.log({ isPublic: candidate.isPublic });
}
}, [candidate]);
@ -166,7 +164,7 @@ const CandidateProfile: React.FC<BackstoryPageProps> = (_props: BackstoryPagePro
}
// Handle tab change
const handleTabChange = (event: React.SyntheticEvent, newValue: number): void => {
const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => {
setTabValue(newValue);
};
@ -193,19 +191,11 @@ const CandidateProfile: React.FC<BackstoryPageProps> = (_props: BackstoryPagePro
console.log(`Set profile image to: ${candidate.profileImage}`);
updateUserData(candidate);
} else {
setSnackbar({
open: true,
message: 'Failed to upload profile image. Please try again.',
severity: 'error',
});
setSnack('Failed to upload profile image. Please try again.', 'error');
}
} catch (error) {
console.error('Error uploading profile image:', error);
setSnackbar({
open: true,
message: 'Failed to upload profile image. Please try again.',
severity: 'error',
});
setSnack('Failed to upload profile image. Please try again.', 'error');
}
};
@ -228,11 +218,7 @@ const CandidateProfile: React.FC<BackstoryPageProps> = (_props: BackstoryPagePro
toggleEditMode(section);
}
} catch (error) {
setSnackbar({
open: true,
message: 'Failed to update profile. Please try again.',
severity: 'error',
});
setSnack('Failed to update profile. Please try again.', 'error');
} finally {
setLoading(false);
}
@ -256,6 +242,7 @@ const CandidateProfile: React.FC<BackstoryPageProps> = (_props: BackstoryPagePro
yearsOfExperience: 0,
});
setSkillDialog(false);
setSnack('Skill added successfully!');
}
};
@ -263,6 +250,30 @@ const CandidateProfile: React.FC<BackstoryPageProps> = (_props: BackstoryPagePro
const handleRemoveSkill = (index: number): void => {
const updatedSkills = (formData.skills || []).filter((_, i) => i !== index);
setFormData({ ...formData, skills: updatedSkills });
setSnack('Skill removed successfully!');
};
// Add new question
const handleAddQuestion = (): void => {
if (newQuestion.question?.trim()) {
const questionToAdd: Types.CandidateQuestion = {
question: newQuestion.question.trim(),
};
const updatedQuestions = [...(formData.questions || []), questionToAdd];
setFormData({ ...formData, questions: updatedQuestions });
setNewQuestion({
question: '',
});
setQuestionDialog(false);
setSnack('Question added successfully!');
}
};
// Remove question
const handleRemoveQuestion = (index: number): void => {
const updatedQuestions = (formData.questions || []).filter((_, i) => i !== index);
setFormData({ ...formData, questions: updatedQuestions });
setSnack('Question removed successfully!');
};
// Add new work experience
@ -283,6 +294,7 @@ const CandidateProfile: React.FC<BackstoryPageProps> = (_props: BackstoryPagePro
location: { city: '', country: '' },
});
setExperienceDialog(false);
setSnack('Experience added successfully!');
}
};
@ -290,6 +302,7 @@ const CandidateProfile: React.FC<BackstoryPageProps> = (_props: BackstoryPagePro
const handleRemoveExperience = (index: number): void => {
const updatedExperience = (formData.experience || []).filter((_, i) => i !== index);
setFormData({ ...formData, experience: updatedExperience });
setSnack('Experience removed successfully!');
};
// Basic Information Tab
@ -630,6 +643,104 @@ const CandidateProfile: React.FC<BackstoryPageProps> = (_props: BackstoryPagePro
</Box>
);
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 => (
<Box>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
justifyContent: 'space-between',
alignItems: { xs: 'stretch', sm: 'center' },
mb: { xs: 2, sm: 3 },
gap: { xs: 1, sm: 0 },
}}
>
<Typography variant={isMobile ? 'subtitle1' : 'h6'}>Questions for Chat</Typography>
<Button
variant="outlined"
startIcon={<Add />}
onClick={(): void => setQuestionDialog(true)}
fullWidth={isMobile}
size={isMobile ? 'small' : 'medium'}
>
Add Question
</Button>
</Box>
<Box>See how Backstory answers the question about you...</Box>
<TransientChat id={Date()} ref={chatRef} />
<Grid container spacing={{ xs: 1, sm: 2 }} sx={{ maxWidth: '100%' }}>
{(formData.questions || []).map((question, index) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={index}>
<Card variant="outlined" sx={{ height: '100%' }}>
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<BackstoryQuery question={question} submitQuery={handleSubmitQuestion} />
{/* Display question text
<Typography
variant={isMobile ? 'subtitle2' : 'h6'}
component="div"
sx={{
fontSize: { xs: '0.9rem', sm: '1.25rem' },
wordBreak: 'break-word',
mb: 1,
}}
>
{question.question}
</Typography> */}
{/* {question.category && (
<Chip
size="small"
label={question.category}
color="secondary"
variant="outlined"
sx={{
fontSize: { xs: '0.65rem', sm: '0.75rem' },
height: { xs: 20, sm: 24 },
}}
/>
)} */}
</Box>
<IconButton
size="small"
onClick={(): void => handleRemoveQuestion(index)}
color="error"
sx={{ ml: 1 }}
>
<Delete sx={{ fontSize: { xs: 16, sm: 20 } }} />
</IconButton>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
{(!formData.questions || formData.questions.length === 0) && (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
No questions added yet. Click &quot;Add Question&quot; to get started.
</Typography>
)}
</Box>
);
// Experience Tab
const renderExperience = (): JSX.Element => (
<Box>
@ -781,19 +892,17 @@ const CandidateProfile: React.FC<BackstoryPageProps> = (_props: BackstoryPagePro
</Button>
</Box>
{(!formData.experience || formData.experience.length === 0) && (
<Typography
variant="body2"
color="text.secondary"
sx={{
textAlign: 'center',
py: { xs: 2, sm: 4 },
fontSize: { xs: '0.8rem', sm: '0.875rem' },
}}
>
No work experience added yet. Click &quot;Add Experience&quot; to get started.
</Typography>
)}
<Typography
variant="body2"
color="text.secondary"
sx={{
textAlign: 'center',
py: { xs: 2, sm: 4 },
fontSize: { xs: '0.8rem', sm: '0.875rem' },
}}
>
No education added yet. Click &quot;Add Education&quot; to get started.
</Typography>
</Box>
);
@ -833,41 +942,55 @@ const CandidateProfile: React.FC<BackstoryPageProps> = (_props: BackstoryPagePro
>
<Tab
label={isMobile ? 'Info' : 'Basic Info'}
value="info"
icon={<AccountCircle sx={{ fontSize: { xs: 18, sm: 24 } }} />}
iconPosition={isMobile ? 'top' : 'start'}
/>
<Tab
label="Questions"
value="questions"
icon={<LiveHelp sx={{ fontSize: { xs: 18, sm: 24 } }} />}
iconPosition={isMobile ? 'top' : 'start'}
/>
<Tab
label="Skills"
value="skills"
icon={<EmojiEvents sx={{ fontSize: { xs: 18, sm: 24 } }} />}
iconPosition={isMobile ? 'top' : 'start'}
/>
<Tab
label={isMobile ? 'Work' : 'Experience'}
value="experience"
icon={<Work sx={{ fontSize: { xs: 18, sm: 24 } }} />}
iconPosition={isMobile ? 'top' : 'start'}
/>
<Tab
label={isMobile ? 'Edu' : 'Education'}
value="education"
icon={<School sx={{ fontSize: { xs: 18, sm: 24 } }} />}
iconPosition={isMobile ? 'top' : 'start'}
/>
</Tabs>
</Box>
<TabPanel value={tabValue} index={0}>
<TabPanel value="info" active={tabValue}>
{renderBasicInfo()}
</TabPanel>
<TabPanel value={tabValue} index={1}>
<ComingSoon>{renderSkills()}</ComingSoon>
<TabPanel value="questions" active={tabValue}>
{renderQuestions()}
</TabPanel>
<TabPanel value={tabValue} index={2}>
<ComingSoon>{renderExperience()}</ComingSoon>
<TabPanel value="skills" active={tabValue}>
{renderSkills()}
</TabPanel>
<TabPanel value={tabValue} index={3}>
<ComingSoon>{renderEducation()}</ComingSoon>
<TabPanel value="experience" active={tabValue}>
{renderExperience()}
</TabPanel>
<TabPanel value="education" active={tabValue}>
{renderEducation()}
</TabPanel>
</Paper>
@ -924,7 +1047,7 @@ const CandidateProfile: React.FC<BackstoryPageProps> = (_props: BackstoryPagePro
onChange={(e): void =>
setNewSkill({
...newSkill,
level: e.target.value as Types.SkillLevel,
level: e.target.value as 'beginner' | 'intermediate' | 'advanced' | 'expert',
})
}
label="Proficiency Level"
@ -978,6 +1101,104 @@ const CandidateProfile: React.FC<BackstoryPageProps> = (_props: BackstoryPagePro
</DialogActions>
</Dialog>
{/* Add Question Dialog */}
<Dialog
open={questionDialog}
onClose={(): void => setQuestionDialog(false)}
maxWidth="sm"
fullWidth
fullScreen={isMobile}
PaperProps={{
sx: {
...(isMobile && {
margin: 0,
width: '100%',
height: '100%',
maxHeight: '100%',
}),
},
}}
>
<DialogTitle sx={{ pb: { xs: 1, sm: 2 } }}>Add New Question</DialogTitle>
<DialogContent
sx={{
overflow: 'auto',
pt: { xs: 1, sm: 2 },
}}
>
<Grid container spacing={{ xs: 1.5, sm: 2 }} sx={{ mt: 0.5, maxWidth: '100%' }}>
<Grid size={{ xs: 12 }}>
<TextField
fullWidth
multiline
rows={isMobile ? 3 : 4}
label="Question"
value={newQuestion.question || ''}
onChange={(e): void => setNewQuestion({ ...newQuestion, question: e.target.value })}
placeholder="What would you potential employers to ask about you?"
size={isMobile ? 'small' : 'medium'}
/>
</Grid>
{/* <Grid size={{ xs: 12, sm: 8 }}>
<TextField
fullWidth
label="Category (Optional)"
value={newQuestion.category || ''}
onChange={(e): void => setNewQuestion({ ...newQuestion, category: e.target.value })}
placeholder="e.g., General, Technical, Behavioral"
size={isMobile ? 'small' : 'medium'}
/>
</Grid> */}
{/* <Grid size={{ xs: 12, sm: 4 }}>
<FormControlLabel
control={
<Switch
checked={newQuestion.isActive !== false}
onChange={(e): void =>
setNewQuestion({
...newQuestion,
isActive: e.target.checked,
})
}
size={isMobile ? 'small' : 'medium'}
/>
}
label="Active"
sx={{
'& .MuiFormControlLabel-label': {
fontSize: { xs: '0.875rem', sm: '1rem' },
},
}}
/>
</Grid> */}
</Grid>
</DialogContent>
<DialogActions
sx={{
p: { xs: 1.5, sm: 3 },
flexDirection: { xs: 'column', sm: 'row' },
gap: { xs: 1, sm: 0 },
}}
>
<Button
onClick={(): void => setQuestionDialog(false)}
fullWidth={isMobile}
size={isMobile ? 'small' : 'medium'}
>
Cancel
</Button>
<Button
onClick={handleAddQuestion}
variant="contained"
fullWidth={isMobile}
size={isMobile ? 'small' : 'medium'}
disabled={!newQuestion.question?.trim()}
>
Add Question
</Button>
</DialogActions>
</Dialog>
{/* Add Experience Dialog */}
<Dialog
open={experienceDialog}
@ -1113,21 +1334,6 @@ const CandidateProfile: React.FC<BackstoryPageProps> = (_props: BackstoryPagePro
</Button>
</DialogActions>
</Dialog>
{/* Snackbar for notifications */}
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
onClose={(): void => setSnackbar({ ...snackbar, open: false })}
>
<Alert
onClose={(): void => setSnackbar({ ...snackbar, open: false })}
severity={snackbar.severity}
sx={{ width: '100%' }}
>
{snackbar.message}
</Alert>
</Snackbar>
</Container>
);
};