Single chat session per candidate
This commit is contained in:
parent
f286868cbe
commit
0994c95f91
@ -16,6 +16,7 @@ interface CandidateInfoProps {
|
||||
sx?: SxProps;
|
||||
action?: string;
|
||||
elevation?: number;
|
||||
variant?: "small" | "normal" | null
|
||||
};
|
||||
|
||||
const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps) => {
|
||||
@ -23,7 +24,8 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
|
||||
const {
|
||||
sx,
|
||||
action = '',
|
||||
elevation = 1
|
||||
elevation = 1,
|
||||
variant = "normal"
|
||||
} = props;
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
@ -107,23 +109,25 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
|
||||
{candidate.description}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
{variant !== "small" && <>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{candidate.location &&
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<strong>Location:</strong> {candidate.location.city}, {candidate.location.state || candidate.location.country}
|
||||
</Typography>
|
||||
}
|
||||
{candidate.email &&
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<strong>Email:</strong> {candidate.email}
|
||||
</Typography>
|
||||
}
|
||||
{ candidate.phone && <Typography variant="body2">
|
||||
{candidate.location &&
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<strong>Location:</strong> {candidate.location.city}, {candidate.location.state || candidate.location.country}
|
||||
</Typography>
|
||||
}
|
||||
{candidate.email &&
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<strong>Email:</strong> {candidate.email}
|
||||
</Typography>
|
||||
}
|
||||
{candidate.phone && <Typography variant="body2">
|
||||
<strong>Phone:</strong> {candidate.phone}
|
||||
</Typography> }
|
||||
</Grid>
|
||||
|
||||
</Typography>
|
||||
}
|
||||
</>}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
@ -2,64 +2,35 @@ import React, { forwardRef, useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Chip,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Card,
|
||||
CardContent,
|
||||
Avatar,
|
||||
useTheme,
|
||||
useMediaQuery,
|
||||
Fab,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Backdrop
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Chat as ChatIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon
|
||||
Send as SendIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useAuth } from 'hooks/AuthContext';
|
||||
import { ChatMessageBase, ChatMessage, ChatSession, ChatStatusType, ChatMessageType, ChatMessageUser } from 'types/types';
|
||||
import { ChatMessageBase, ChatMessage, ChatSession, ChatMessageUser } from 'types/types';
|
||||
import { ConversationHandle } from 'components/Conversation';
|
||||
import { BackstoryPageProps } from 'components/BackstoryTab';
|
||||
import { Message } from 'components/Message';
|
||||
import { DeleteConfirmation } from 'components/DeleteConfirmation';
|
||||
import { CandidateSessionsResponse } from 'services/api-client';
|
||||
import { CandidateInfo } from 'components/CandidateInfo';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSelectedCandidate } from 'hooks/GlobalContext';
|
||||
import PropagateLoader from 'react-spinners/PropagateLoader';
|
||||
import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField';
|
||||
|
||||
const DRAWER_WIDTH = 300;
|
||||
const FAB_WIDTH = 48;
|
||||
const FAB_HEIGHT = 64;
|
||||
|
||||
const defaultMessage: ChatMessage = {
|
||||
type: "preparing", status: "done", sender: "system", sessionId: "", timestamp: new Date(), content: ""
|
||||
};
|
||||
|
||||
type HandlePosition = 'center' | 'top' | 'bottom';
|
||||
|
||||
const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((props: BackstoryPageProps, ref) => {
|
||||
const { apiClient } = useAuth();
|
||||
const { selectedCandidate } = useSelectedCandidate()
|
||||
const navigate = useNavigate();
|
||||
const { selectedCandidate } = useSelectedCandidate()
|
||||
const theme = useTheme();
|
||||
const isMdUp = useMediaQuery(theme.breakpoints.up('md'));
|
||||
const [processingMessage, setProcessingMessage] = useState<ChatMessage | null>(null);
|
||||
const [streamingMessage, setStreamingMessage] = useState<ChatMessage | null>(null);
|
||||
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
|
||||
@ -69,84 +40,12 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
||||
submitQuery,
|
||||
} = props;
|
||||
|
||||
const [sessions, setSessions] = useState<CandidateSessionsResponse | null>(null);
|
||||
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);
|
||||
|
||||
// Drawer state - defaults to open on md+ screens
|
||||
const [drawerOpen, setDrawerOpen] = useState(() => isMdUp);
|
||||
|
||||
// Handle position state for mobile scroll behavior
|
||||
const [handlePosition, setHandlePosition] = useState<HandlePosition>('center');
|
||||
const lastScrollY = useRef(0);
|
||||
const scrollTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Edit session state
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [editingSession, setEditingSession] = useState<ChatSession | null>(null);
|
||||
const [editSessionTitle, setEditSessionTitle] = useState('');
|
||||
|
||||
// Delete confirmation state
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [sessionToDelete, setSessionToDelete] = useState<ChatSession | null>(null);
|
||||
|
||||
// Update drawer state when screen size changes
|
||||
useEffect(() => {
|
||||
if (isMdUp && !drawerOpen) {
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
}, [isMdUp]);
|
||||
|
||||
// Scroll event handler for mobile handle positioning
|
||||
useEffect(() => {
|
||||
if (isMdUp) return; // Only for mobile
|
||||
|
||||
const handleScroll = () => {
|
||||
const currentScrollY = window.scrollY;
|
||||
const scrollDirection = currentScrollY > lastScrollY.current ? 'down' : 'up';
|
||||
|
||||
// Clear existing timeout
|
||||
if (scrollTimeout.current) {
|
||||
clearTimeout(scrollTimeout.current);
|
||||
}
|
||||
|
||||
// Update handle position based on scroll direction
|
||||
if (scrollDirection === 'down' && currentScrollY > 50) {
|
||||
setHandlePosition('top');
|
||||
} else if (scrollDirection === 'up' && currentScrollY > 50) {
|
||||
setHandlePosition('bottom');
|
||||
}
|
||||
|
||||
lastScrollY.current = currentScrollY;
|
||||
|
||||
// Reset to center after scrolling stops (with debounce)
|
||||
scrollTimeout.current = setTimeout(() => {
|
||||
if (currentScrollY <= 50) {
|
||||
setHandlePosition('center');
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
if (scrollTimeout.current) {
|
||||
clearTimeout(scrollTimeout.current);
|
||||
}
|
||||
};
|
||||
}, [isMdUp]);
|
||||
|
||||
// Close drawer when clicking outside on mobile only
|
||||
const handleBackdropClick = () => {
|
||||
if (!isMdUp) {
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load sessions for the selectedCandidate
|
||||
const loadSessions = async () => {
|
||||
if (!selectedCandidate) return;
|
||||
@ -154,9 +53,20 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await apiClient.getCandidateChatSessions(selectedCandidate.username);
|
||||
setSessions(result);
|
||||
let session = null;
|
||||
if (result.sessions.data.length === 0) {
|
||||
session = await apiClient.createCandidateChatSession(
|
||||
selectedCandidate.username,
|
||||
'candidate_chat',
|
||||
`Backstory chat about ${selectedCandidate.fullName}`
|
||||
);
|
||||
} else {
|
||||
session = result.sessions.data[0];
|
||||
}
|
||||
setChatSession(session);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load sessions:', error);
|
||||
setSnack('Unable to load chat session', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -178,76 +88,15 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
||||
}
|
||||
};
|
||||
|
||||
// Create new session
|
||||
const createNewSession = async () => {
|
||||
if (!selectedCandidate) { return }
|
||||
try {
|
||||
setLoading(true);
|
||||
const newSession = await apiClient.createCandidateChatSession(
|
||||
selectedCandidate.username,
|
||||
'candidate_chat',
|
||||
`Backstory chat about ${selectedCandidate.fullName}`
|
||||
);
|
||||
setChatSession(newSession);
|
||||
setMessages([]);
|
||||
setProcessingMessage(null);
|
||||
setStreamingMessage(null);
|
||||
await loadSessions(); // Refresh sessions list
|
||||
} catch (error) {
|
||||
console.error('Failed to create session:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const onDelete = async (session: ChatSession) => {
|
||||
if (!session.id) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Edit session
|
||||
const handleEditSession = (session: ChatSession, event: React.MouseEvent) => {
|
||||
event.stopPropagation(); // Prevent session selection
|
||||
setEditingSession(session);
|
||||
setEditSessionTitle(session.title || '');
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingSession?.id || !editSessionTitle.trim()) return;
|
||||
|
||||
try {
|
||||
// Assuming there's an API method to update session title
|
||||
await apiClient.updateChatSession(editingSession.id, { title: editSessionTitle.trim() });
|
||||
await loadSessions(); // Refresh sessions list
|
||||
setEditDialogOpen(false);
|
||||
setEditingSession(null);
|
||||
setEditSessionTitle('');
|
||||
setSnack('Session title updated successfully', 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to update session:', error);
|
||||
setSnack('Failed to update session title', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Delete session
|
||||
const handleDeleteSession = (session: ChatSession, event: React.MouseEvent) => {
|
||||
event.stopPropagation(); // Prevent session selection
|
||||
setSessionToDelete(session);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!sessionToDelete?.id) return;
|
||||
|
||||
try {
|
||||
await apiClient.deleteChatSession(sessionToDelete.id);
|
||||
await loadSessions(); // Refresh sessions list
|
||||
|
||||
await apiClient.resetChatSession(session.id);
|
||||
// If we're deleting the currently selected session, clear it
|
||||
if (chatSession?.id === sessionToDelete.id) {
|
||||
setChatSession(null);
|
||||
setMessages([]);
|
||||
}
|
||||
|
||||
setDeleteDialogOpen(false);
|
||||
setSessionToDelete(null);
|
||||
setSnack('Session deleted successfully', 'success');
|
||||
setMessages([]);
|
||||
setSnack('Session reset succeeded', 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete session:', error);
|
||||
setSnack('Failed to delete session', 'error');
|
||||
@ -276,8 +125,8 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
||||
});
|
||||
|
||||
try {
|
||||
await apiClient.sendMessageStream(chatMessage, {
|
||||
onMessage: (msg: ChatMessage) => {
|
||||
apiClient.sendMessageStream(chatMessage, {
|
||||
onMessage: (msg: ChatMessage) => {
|
||||
console.log(`onMessage: ${msg.type} ${msg.content}`, msg);
|
||||
if (msg.type === "response") {
|
||||
setMessages(prev => {
|
||||
@ -289,30 +138,30 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
||||
} else {
|
||||
setProcessingMessage(msg);
|
||||
}
|
||||
},
|
||||
onError: (error: string | ChatMessageBase) => {
|
||||
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 as ChatMessage);
|
||||
} else {
|
||||
setProcessingMessage({ ...defaultMessage, content: error as string });
|
||||
}
|
||||
setStreaming(false);
|
||||
},
|
||||
onStreaming: (chunk: ChatMessageBase) => {
|
||||
// console.log("onStreaming:", chunk);
|
||||
setStreamingMessage({ ...defaultMessage, ...chunk });
|
||||
},
|
||||
},
|
||||
onError: (error: string | ChatMessageBase) => {
|
||||
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 as ChatMessage);
|
||||
} else {
|
||||
setProcessingMessage({ ...defaultMessage, content: error as string });
|
||||
}
|
||||
setStreaming(false);
|
||||
},
|
||||
onStreaming: (chunk: ChatMessageBase) => {
|
||||
// console.log("onStreaming:", chunk);
|
||||
setStreamingMessage({ ...defaultMessage, ...chunk });
|
||||
},
|
||||
onStatusChange: (status: string) => {
|
||||
console.log(`onStatusChange: ${status}`);
|
||||
},
|
||||
onComplete: () => {
|
||||
console.log("onComplete");
|
||||
setStreamingMessage(null);
|
||||
setProcessingMessage(null);
|
||||
setStreaming(false);
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
console.log("onComplete");
|
||||
setStreamingMessage(null);
|
||||
setProcessingMessage(null);
|
||||
setStreaming(false);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
@ -339,412 +188,123 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
||||
|
||||
if (!selectedCandidate) {
|
||||
navigate('/find-a-candidate');
|
||||
return (<></>);
|
||||
}
|
||||
|
||||
// Get handle positioning styles based on current position
|
||||
const getHandleStyles = () => {
|
||||
const baseStyles = {
|
||||
position: 'absolute' as const,
|
||||
left: DRAWER_WIDTH,
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
};
|
||||
|
||||
switch (handlePosition) {
|
||||
case 'top':
|
||||
return {
|
||||
...baseStyles,
|
||||
position: 'fixed' as const,
|
||||
top: 20,
|
||||
transform: 'translateY(0)',
|
||||
};
|
||||
case 'bottom':
|
||||
return {
|
||||
...baseStyles,
|
||||
position: 'fixed' as const,
|
||||
bottom: 20,
|
||||
transform: 'translateY(0)',
|
||||
};
|
||||
case 'center':
|
||||
default:
|
||||
return {
|
||||
...baseStyles,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
};
|
||||
}
|
||||
const welcomeMessage: ChatMessage = {
|
||||
sessionId: chatSession?.id || '',
|
||||
type: "info",
|
||||
status: "done",
|
||||
sender: "system",
|
||||
timestamp: new Date(),
|
||||
content: `Welcome to the Backstory Chat about ${selectedCandidate.fullName}. Ask any questions you have about ${selectedCandidate.firstName}.`
|
||||
};
|
||||
|
||||
const drawerContent = (
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden' // Prevent overall drawer from scrolling
|
||||
}}>
|
||||
{/* Fixed Header Section */}
|
||||
<Box sx={{ p: 2, flexShrink: 0 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Chat Sessions
|
||||
{sessions && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${sessions.sessions.total} total`}
|
||||
sx={{ ml: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={createNewSession}
|
||||
disabled={loading || !selectedCandidate}
|
||||
sx={{ mb: 2, width: '100%' }}
|
||||
>
|
||||
New Session
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Scrollable Sessions List */}
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
overflow: 'auto',
|
||||
px: 2,
|
||||
pb: 2,
|
||||
// Custom scrollbar styling
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
backgroundColor: theme.palette.grey[100],
|
||||
borderRadius: '4px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: theme.palette.grey[400],
|
||||
borderRadius: '4px',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.grey[600],
|
||||
},
|
||||
},
|
||||
}}>
|
||||
{sessions ? (
|
||||
<List sx={{ padding: 0 }}>
|
||||
{sessions.sessions.data.map((session: any) => (
|
||||
<ListItem
|
||||
key={session.id}
|
||||
onClick={() => {
|
||||
setChatSession(session);
|
||||
// Auto-close drawer on mobile only when session is selected
|
||||
if (!isMdUp) {
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
mb: 1,
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: chatSession?.id === session.id ? 'orange' : 'divider',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'transparent',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover'
|
||||
}
|
||||
}}
|
||||
secondaryAction={
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<IconButton
|
||||
edge="end"
|
||||
size="small"
|
||||
onClick={(e) => handleEditSession(session, e)}
|
||||
sx={{
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
color: 'primary.main'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
edge="end"
|
||||
size="small"
|
||||
onClick={(e) => handleDeleteSession(session, e)}
|
||||
sx={{
|
||||
'&:hover': {
|
||||
backgroundColor: 'error.light',
|
||||
color: 'error.main'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={session.title}
|
||||
secondary={`${new Date(session.lastActivity).toLocaleDateString()} • ${session.context.type}`}
|
||||
sx={{ pr: 2 }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Typography color="text.secondary" align="center" sx={{ mt: 4 }}>
|
||||
Enter a username and click "Load Sessions"
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box ref={ref} sx={{
|
||||
width: "100%",
|
||||
height: "100vh", // Fixed viewport height
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: 'hidden' // Prevent page-level scrolling
|
||||
gap: 1,
|
||||
}}>
|
||||
{selectedCandidate && (
|
||||
<CandidateInfo
|
||||
action={`Chat with Backstory about ${selectedCandidate.firstName}`}
|
||||
elevation={4}
|
||||
candidate={selectedCandidate}
|
||||
sx={{ flexShrink: 0 }} // Prevent header from shrinking
|
||||
/>
|
||||
)}
|
||||
<CandidateInfo
|
||||
action={`Chat with Backstory about ${selectedCandidate.firstName}`}
|
||||
elevation={4}
|
||||
candidate={selectedCandidate}
|
||||
variant="small"
|
||||
sx={{ flexShrink: 0 }} // Prevent header from shrinking
|
||||
/>
|
||||
|
||||
<Box sx={{
|
||||
display: "flex",
|
||||
mt: 1,
|
||||
gap: 1,
|
||||
flexGrow: 1,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
minHeight: 0 // Important for flex child to shrink
|
||||
}}>
|
||||
|
||||
{/* Mobile Backdrop */}
|
||||
{!isMdUp && drawerOpen && (
|
||||
<Backdrop
|
||||
open={drawerOpen}
|
||||
onClick={handleBackdropClick}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
zIndex: theme.zIndex.drawer - 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drawer Container - Different behavior for mobile vs desktop */}
|
||||
<Box
|
||||
sx={{
|
||||
position: isMdUp ? 'relative' : 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: isMdUp ? DRAWER_WIDTH : DRAWER_WIDTH + FAB_WIDTH,
|
||||
height: '100%',
|
||||
transform: isMdUp ? 'none' : (drawerOpen ? 'translateX(0)' : `translateX(-${DRAWER_WIDTH}px)`),
|
||||
transition: isMdUp ? 'none' : theme.transitions.create('transform', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.standard,
|
||||
}),
|
||||
zIndex: theme.zIndex.drawer,
|
||||
}}
|
||||
>
|
||||
{/* Drawer Content */}
|
||||
<Paper
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: DRAWER_WIDTH,
|
||||
height: '100%',
|
||||
borderRight: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 0,
|
||||
overflow: 'hidden',
|
||||
display: isMdUp ? 'block' : (drawerOpen ? 'block' : 'none'),
|
||||
}}
|
||||
>
|
||||
{drawerContent}
|
||||
</Paper>
|
||||
|
||||
{/* Integrated Fab Handle - Mobile Only */}
|
||||
{!isMdUp && (
|
||||
<Box sx={getHandleStyles()}>
|
||||
<Fab
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => setDrawerOpen(!drawerOpen)}
|
||||
sx={{
|
||||
borderRadius: '0 50% 50% 0',
|
||||
width: FAB_WIDTH,
|
||||
height: FAB_HEIGHT,
|
||||
minHeight: FAB_HEIGHT,
|
||||
boxShadow: 2,
|
||||
transition: theme.transitions.create(['box-shadow', 'background-color'], {
|
||||
duration: theme.transitions.duration.short,
|
||||
}),
|
||||
'&:hover': {
|
||||
boxShadow: 4,
|
||||
}
|
||||
}}
|
||||
>
|
||||
{drawerOpen ? <ChevronLeft /> : <ChatIcon />}
|
||||
</Fab>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Chat Interface */}
|
||||
<Paper
|
||||
sx={{
|
||||
{/* Chat Interface */}
|
||||
<Paper
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
width: '100%',
|
||||
minHeight: 'max-content'
|
||||
}}
|
||||
>
|
||||
{/* Scrollable Messages Area */}
|
||||
{chatSession && <>
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
marginLeft: isMdUp ? 0 : 0,
|
||||
width: isMdUp ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%',
|
||||
overflow: 'hidden',
|
||||
minHeight: 0, // Important for flex child
|
||||
}}
|
||||
>
|
||||
{chatSession?.id ? (
|
||||
<>
|
||||
{/* Scrollable Messages Area */}
|
||||
minHeight: 'max-content', // Important for flex child
|
||||
// Custom scrollbar styling
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
backgroundColor: theme.palette.grey[100],
|
||||
borderRadius: '4px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: theme.palette.grey[400],
|
||||
borderRadius: '4px',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.grey[600],
|
||||
},
|
||||
},
|
||||
}}>
|
||||
{messages.length === 0 && <Message {...{ chatSession, message: welcomeMessage, setSnack, submitQuery }} />}
|
||||
{messages.map((message: ChatMessageBase) => (
|
||||
<Message key={message.id} {...{ chatSession, message, setSnack, submitQuery }} />
|
||||
))}
|
||||
{processingMessage !== null && (
|
||||
<Message {...{ chatSession, message: processingMessage, setSnack, submitQuery }} />
|
||||
)}
|
||||
{streamingMessage !== null && (
|
||||
<Message {...{ chatSession, message: streamingMessage, setSnack, submitQuery }} />
|
||||
)}
|
||||
{streaming && (
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
overflow: 'auto',
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0, // Important for flex child
|
||||
// Custom scrollbar styling
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
backgroundColor: theme.palette.grey[100],
|
||||
borderRadius: '4px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: theme.palette.grey[400],
|
||||
borderRadius: '4px',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.grey[600],
|
||||
},
|
||||
},
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
m: 1,
|
||||
}}>
|
||||
{messages.map((message: ChatMessageBase) => (
|
||||
<Message key={message.id} {...{ chatSession, message, setSnack, submitQuery }} />
|
||||
))}
|
||||
{processingMessage !== null && (
|
||||
<Message {...{ chatSession, message: processingMessage, setSnack, submitQuery }} />
|
||||
)}
|
||||
{streamingMessage !== null && (
|
||||
<Message {...{ chatSession, message: streamingMessage, setSnack, submitQuery }} />
|
||||
)}
|
||||
{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} />
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Fixed Message Input */}
|
||||
<Box sx={{ p: 2, display: 'flex', gap: 1, flexShrink: 0 }}>
|
||||
<BackstoryTextField
|
||||
placeholder="Type your message about the candidate..."
|
||||
ref={backstoryTextRef}
|
||||
onEnter={sendMessage}
|
||||
disabled={streaming}
|
||||
<PropagateLoader
|
||||
size="10px"
|
||||
loading={streaming}
|
||||
aria-label="Loading Spinner"
|
||||
data-testid="loader"
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => { sendMessage((backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || ""); }}
|
||||
disabled={streaming}
|
||||
sx={{ minWidth: 'auto', px: 2 }}
|
||||
>
|
||||
▶
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Select a session to start chatting
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" align="center">
|
||||
Create a new session or choose from existing ones to begin discussing the candidate
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</Box>
|
||||
</>}
|
||||
</Paper>
|
||||
|
||||
{/* Fixed Message Input */}
|
||||
<Box sx={{ display: 'flex', flexShrink: 0, gap: 1 }}>
|
||||
<DeleteConfirmation
|
||||
onDelete={() => { chatSession && onDelete(chatSession); }}
|
||||
disabled={!chatSession}
|
||||
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}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => { sendMessage((backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || ""); }}
|
||||
disabled={streaming || loading}
|
||||
sx={{ minWidth: 'auto', px: 2 }}
|
||||
>
|
||||
<SendIcon />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Edit Session Dialog */}
|
||||
<Dialog open={editDialogOpen} onClose={() => setEditDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Edit Session Title</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Session Title"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={editSessionTitle}
|
||||
onChange={(e) => setEditSessionTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSaveEdit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setEditDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSaveEdit} variant="contained" disabled={!editSessionTitle.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<DeleteConfirmation
|
||||
open={deleteDialogOpen}
|
||||
action="delete"
|
||||
label="chat session"
|
||||
hideButton={true}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete Chat Session"
|
||||
message={`Are you sure you want to delete the session "${sessionToDelete?.title}"? This action cannot be undone.`}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
@ -932,6 +932,15 @@ class ApiClient {
|
||||
return handleApiResponse<{ success: boolean; message: string }>(response);
|
||||
}
|
||||
|
||||
async resetChatSession(id: string): Promise<{ success: boolean; message: string }> {
|
||||
const response = await fetch(`${this.baseUrl}/chat/sessions/${id}/reset`, {
|
||||
method: 'PATCH',
|
||||
headers: this.defaultHeaders
|
||||
});
|
||||
|
||||
return handleApiResponse<{ success: boolean; message: string }>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message with streaming response support and date conversion
|
||||
*/
|
||||
|
@ -3065,6 +3065,64 @@ async def delete_chat_session(
|
||||
content=create_error_response("DELETE_ERROR", str(e))
|
||||
)
|
||||
|
||||
@api_router.patch("/chat/sessions/{session_id}/reset")
|
||||
async def reset_chat_session(
|
||||
session_id: str = Path(...),
|
||||
current_user = Depends(get_current_user),
|
||||
database: RedisDatabase = Depends(get_database)
|
||||
):
|
||||
"""Delete a chat session and all its messages"""
|
||||
try:
|
||||
# Get the session to verify it exists and check ownership
|
||||
session_data = await database.get_chat_session(session_id)
|
||||
if not session_data:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=create_error_response("NOT_FOUND", "Chat session not found")
|
||||
)
|
||||
|
||||
session = ChatSession.model_validate(session_data)
|
||||
|
||||
# Check authorization - user can only delete their own sessions
|
||||
if session.user_id != current_user.id:
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content=create_error_response("FORBIDDEN", "Cannot reset another user's chat session")
|
||||
)
|
||||
|
||||
# Delete all messages associated with this session
|
||||
try:
|
||||
chat_messages = await database.get_chat_messages(session_id)
|
||||
message_count = len(chat_messages)
|
||||
|
||||
# Delete each message
|
||||
for message_data in chat_messages:
|
||||
message_id = message_data.get("id")
|
||||
if message_id:
|
||||
await database.delete_chat_message(session_id, message_id)
|
||||
|
||||
logger.info(f"🗑️ Deleted {message_count} messages from session {session_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Error deleting messages for session {session_id}: {e}")
|
||||
# Continue with session deletion even if message deletion fails
|
||||
|
||||
|
||||
logger.info(f"🗑️ Chat session {session_id} reset by user {current_user.id}")
|
||||
|
||||
return create_success_response({
|
||||
"success": True,
|
||||
"message": "Chat session reset successfully",
|
||||
"sessionId": session_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Reset chat session error: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=create_error_response("RESET_ERROR", str(e))
|
||||
)
|
||||
|
||||
@api_router.get("/candidates/{username}/chat-sessions")
|
||||
async def get_candidate_chat_sessions(
|
||||
username: str = Path(...),
|
||||
|
Loading…
x
Reference in New Issue
Block a user