Compare commits
2 Commits
8f0ff5da68
...
e1c1bcf097
Author | SHA1 | Date | |
---|---|---|---|
e1c1bcf097 | |||
40ab58fffe |
@ -14,37 +14,94 @@ import { useTheme } from '@mui/material/styles';
|
|||||||
import ResetIcon from '@mui/icons-material/History';
|
import ResetIcon from '@mui/icons-material/History';
|
||||||
|
|
||||||
interface DeleteConfirmationProps {
|
interface DeleteConfirmationProps {
|
||||||
onDelete: () => void,
|
// Legacy props for backward compatibility (uncontrolled mode)
|
||||||
disabled?: boolean,
|
onDelete?: () => void;
|
||||||
label?: string,
|
disabled?: boolean;
|
||||||
color?: "inherit" | "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning" | undefined
|
label?: string;
|
||||||
};
|
action?: "delete" | "reset";
|
||||||
|
color?: "inherit" | "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning" | undefined;
|
||||||
|
|
||||||
const DeleteConfirmation = (props : DeleteConfirmationProps) => {
|
// New props for controlled mode
|
||||||
const { onDelete, disabled, label, color } = props;
|
open?: boolean;
|
||||||
const [open, setOpen] = useState(false);
|
onClose?: () => void;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
|
||||||
|
// Optional props for button customization in controlled mode
|
||||||
|
hideButton?: boolean;
|
||||||
|
confirmButtonText?: string;
|
||||||
|
cancelButtonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalizeFirstLetter(str: string) {
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteConfirmation = (props: DeleteConfirmationProps) => {
|
||||||
|
const {
|
||||||
|
// Legacy props
|
||||||
|
onDelete,
|
||||||
|
disabled,
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
action = "delete",
|
||||||
|
// New props
|
||||||
|
open: controlledOpen,
|
||||||
|
onClose: controlledOnClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
hideButton = false,
|
||||||
|
confirmButtonText,
|
||||||
|
cancelButtonText = "Cancel"
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// Internal state for uncontrolled mode
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const fullScreen = useMediaQuery(theme.breakpoints.down('md'));
|
const fullScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
|
||||||
|
// Determine if we're in controlled or uncontrolled mode
|
||||||
|
const isControlled = controlledOpen !== undefined;
|
||||||
|
const isOpen = isControlled ? controlledOpen : internalOpen;
|
||||||
|
|
||||||
const handleClickOpen = () => {
|
const handleClickOpen = () => {
|
||||||
setOpen(true);
|
if (!isControlled) {
|
||||||
|
setInternalOpen(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setOpen(false);
|
if (isControlled) {
|
||||||
|
controlledOnClose?.();
|
||||||
|
} else {
|
||||||
|
setInternalOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmReset = () => {
|
const handleConfirm = () => {
|
||||||
onDelete();
|
if (isControlled) {
|
||||||
setOpen(false);
|
onConfirm?.();
|
||||||
|
} else {
|
||||||
|
onDelete?.();
|
||||||
|
setInternalOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Determine dialog content based on mode
|
||||||
|
const dialogTitle = title || "Confirm Reset";
|
||||||
|
const dialogMessage = message || `This action will permanently ${capitalizeFirstLetter(action)} ${label ? label.toLowerCase() : "all data"} without the ability to recover it. Are you sure you want to continue?`;
|
||||||
|
const confirmText = confirmButtonText || `${capitalizeFirstLetter(action)} ${label || "Everything"}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tooltip title={label ? `Reset ${label}` : "Reset"} >
|
{/* Only show button if not hidden (for controlled mode) */}
|
||||||
<span style={{ display: "flex" }}> { /* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */}
|
{!hideButton && (
|
||||||
|
<Tooltip title={label ? `${capitalizeFirstLetter(action)} ${label}` : "Reset"}>
|
||||||
|
<span style={{ display: "flex" }}> {/* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */}
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label="reset"
|
aria-label={action}
|
||||||
onClick={handleClickOpen}
|
onClick={handleClickOpen}
|
||||||
color={color || "inherit"}
|
color={color || "inherit"}
|
||||||
sx={{ display: "flex", margin: 'auto 0px' }}
|
sx={{ display: "flex", margin: 'auto 0px' }}
|
||||||
@ -56,28 +113,28 @@ const DeleteConfirmation = (props : DeleteConfirmationProps) => {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
fullScreen={fullScreen}
|
fullScreen={fullScreen}
|
||||||
open={open}
|
open={isOpen}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
aria-labelledby="responsive-dialog-title"
|
aria-labelledby="responsive-dialog-title"
|
||||||
>
|
>
|
||||||
<DialogTitle id="responsive-dialog-title">
|
<DialogTitle id="responsive-dialog-title">
|
||||||
{"Confirm Reset"}
|
{dialogTitle}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText>
|
<DialogContentText>
|
||||||
This action will permanently reset { label ? label.toLocaleLowerCase() : "all data" } without the ability to recover it.
|
{dialogMessage}
|
||||||
Are you sure you want to continue?
|
|
||||||
</DialogContentText>
|
</DialogContentText>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button autoFocus onClick={handleClose}>
|
<Button autoFocus onClick={handleClose}>
|
||||||
Cancel
|
{cancelButtonText}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleConfirmReset} color="error" variant="contained">
|
<Button onClick={handleConfirm} color="error" variant="contained">
|
||||||
Reset { label || "Everything" }
|
{confirmText}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
@ -15,15 +15,21 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
Avatar,
|
Avatar,
|
||||||
Drawer,
|
|
||||||
useTheme,
|
useTheme,
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
Fab
|
Fab,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Backdrop
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Chat as ChatIcon
|
Chat as ChatIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
Delete as DeleteIcon
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useAuth } from 'hooks/AuthContext';
|
import { useAuth } from 'hooks/AuthContext';
|
||||||
import { ChatMessageBase, ChatMessage, ChatSession, ChatStatusType, ChatMessageType, ChatMessageUser } from 'types/types';
|
import { ChatMessageBase, ChatMessage, ChatSession, ChatStatusType, ChatMessageType, ChatMessageUser } from 'types/types';
|
||||||
@ -36,14 +42,18 @@ import { CandidateInfo } from 'components/CandidateInfo';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useSelectedCandidate } from 'hooks/GlobalContext';
|
import { useSelectedCandidate } from 'hooks/GlobalContext';
|
||||||
import PropagateLoader from 'react-spinners/PropagateLoader';
|
import PropagateLoader from 'react-spinners/PropagateLoader';
|
||||||
|
import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField';
|
||||||
|
|
||||||
const DRAWER_WIDTH = 300;
|
const DRAWER_WIDTH = 300;
|
||||||
const HANDLE_WIDTH = 48;
|
const FAB_WIDTH = 48;
|
||||||
|
const FAB_HEIGHT = 64;
|
||||||
|
|
||||||
const defaultMessage: ChatMessage = {
|
const defaultMessage: ChatMessage = {
|
||||||
type: "preparing", status: "done", sender: "system", sessionId: "", timestamp: new Date(), content: ""
|
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 CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((props: BackstoryPageProps, ref) => {
|
||||||
const { apiClient } = useAuth();
|
const { apiClient } = useAuth();
|
||||||
const { selectedCandidate } = useSelectedCandidate()
|
const { selectedCandidate } = useSelectedCandidate()
|
||||||
@ -52,6 +62,7 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
|||||||
const isMdUp = useMediaQuery(theme.breakpoints.up('md'));
|
const isMdUp = useMediaQuery(theme.breakpoints.up('md'));
|
||||||
const [processingMessage, setProcessingMessage] = useState<ChatMessage | null>(null);
|
const [processingMessage, setProcessingMessage] = useState<ChatMessage | null>(null);
|
||||||
const [streamingMessage, setStreamingMessage] = useState<ChatMessage | null>(null);
|
const [streamingMessage, setStreamingMessage] = useState<ChatMessage | null>(null);
|
||||||
|
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
setSnack,
|
setSnack,
|
||||||
@ -61,18 +72,80 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
|||||||
const [sessions, setSessions] = useState<CandidateSessionsResponse | null>(null);
|
const [sessions, setSessions] = useState<CandidateSessionsResponse | null>(null);
|
||||||
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
|
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [newMessage, setNewMessage] = useState<string>('');
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [streaming, setStreaming] = useState<boolean>(false);
|
const [streaming, setStreaming] = useState<boolean>(false);
|
||||||
const messagesEndRef = useRef(null);
|
const messagesEndRef = useRef(null);
|
||||||
|
|
||||||
// Drawer state - defaults to open on md+ screens or when no session is selected
|
// Drawer state - defaults to open on md+ screens
|
||||||
const [drawerOpen, setDrawerOpen] = useState(() => isMdUp || !chatSession);
|
const [drawerOpen, setDrawerOpen] = useState(() => isMdUp);
|
||||||
|
|
||||||
// Update drawer state when screen size or session changes
|
// 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(() => {
|
useEffect(() => {
|
||||||
setDrawerOpen(isMdUp || !chatSession);
|
if (isMdUp && !drawerOpen) {
|
||||||
}, [isMdUp, chatSession]);
|
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
|
// Load sessions for the selectedCandidate
|
||||||
const loadSessions = async () => {
|
const loadSessions = async () => {
|
||||||
@ -113,7 +186,7 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
|||||||
const newSession = await apiClient.createCandidateChatSession(
|
const newSession = await apiClient.createCandidateChatSession(
|
||||||
selectedCandidate.username,
|
selectedCandidate.username,
|
||||||
'candidate_chat',
|
'candidate_chat',
|
||||||
`Interview Discussion - ${selectedCandidate.username}`
|
`Backstory chat about ${selectedCandidate.fullName}`
|
||||||
);
|
);
|
||||||
setChatSession(newSession);
|
setChatSession(newSession);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
@ -127,12 +200,65 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send message
|
// Edit session
|
||||||
const sendMessage = async () => {
|
const handleEditSession = (session: ChatSession, event: React.MouseEvent) => {
|
||||||
if (!newMessage.trim() || !chatSession?.id || streaming) return;
|
event.stopPropagation(); // Prevent session selection
|
||||||
|
setEditingSession(session);
|
||||||
|
setEditSessionTitle(session.title || '');
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const messageContent = newMessage;
|
const handleSaveEdit = async () => {
|
||||||
setNewMessage('');
|
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
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
} 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) return;
|
||||||
|
|
||||||
|
const messageContent = message;
|
||||||
setStreaming(true);
|
setStreaming(true);
|
||||||
|
|
||||||
const chatMessage: ChatMessageUser = {
|
const chatMessage: ChatMessageUser = {
|
||||||
@ -215,8 +341,48 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
|||||||
navigate('/find-a-candidate');
|
navigate('/find-a-candidate');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 drawerContent = (
|
const drawerContent = (
|
||||||
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', p: 2 }}>
|
<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>
|
<Typography variant="h6" gutterBottom>
|
||||||
Chat Sessions
|
Chat Sessions
|
||||||
{sessions && (
|
{sessions && (
|
||||||
@ -232,20 +398,42 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={createNewSession}
|
onClick={createNewSession}
|
||||||
disabled={loading || !selectedCandidate}
|
disabled={loading || !selectedCandidate}
|
||||||
sx={{ mb: 2 }}
|
sx={{ mb: 2, width: '100%' }}
|
||||||
>
|
>
|
||||||
New Session
|
New Session
|
||||||
</Button>
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ flexGrow: 1, overflow: 'auto' }}>
|
{/* 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 ? (
|
{sessions ? (
|
||||||
<List>
|
<List sx={{ padding: 0 }}>
|
||||||
{sessions.sessions.data.map((session: any) => (
|
{sessions.sessions.data.map((session: any) => (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={session.id}
|
key={session.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setChatSession(session);
|
setChatSession(session);
|
||||||
// Auto-close drawer on smaller screens when session is selected
|
// Auto-close drawer on mobile only when session is selected
|
||||||
if (!isMdUp) {
|
if (!isMdUp) {
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
}
|
}
|
||||||
@ -254,22 +442,54 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
|||||||
mb: 1,
|
mb: 1,
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: chatSession?.id === session.id ? 'primary.main' : 'divider',
|
borderColor: chatSession?.id === session.id ? 'orange' : 'divider',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
backgroundColor: 'action.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
|
<ListItemText
|
||||||
primary={session.title}
|
primary={session.title}
|
||||||
secondary={`${new Date(session.lastActivity).toLocaleDateString()} • ${session.context.type}`}
|
secondary={`${new Date(session.lastActivity).toLocaleDateString()} • ${session.context.type}`}
|
||||||
|
sx={{ pr: 2 }}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
) : (
|
) : (
|
||||||
<Typography color="text.secondary" align="center">
|
<Typography color="text.secondary" align="center" sx={{ mt: 4 }}>
|
||||||
Enter a username and click "Load Sessions"
|
Enter a username and click "Load Sessions"
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
@ -277,30 +497,96 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
const drawerHandle = (
|
return (
|
||||||
|
<Box ref={ref} sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100vh", // Fixed viewport height
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: 'hidden' // Prevent page-level scrolling
|
||||||
|
}}>
|
||||||
|
{selectedCandidate && (
|
||||||
|
<CandidateInfo
|
||||||
|
action={`Chat with Backstory about ${selectedCandidate.firstName}`}
|
||||||
|
elevation={4}
|
||||||
|
candidate={selectedCandidate}
|
||||||
|
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
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'fixed',
|
position: isMdUp ? 'relative' : 'absolute',
|
||||||
left: drawerOpen ? DRAWER_WIDTH : 0,
|
left: 0,
|
||||||
top: '50%',
|
top: 0,
|
||||||
transform: 'translateY(-50%)',
|
width: isMdUp ? DRAWER_WIDTH : DRAWER_WIDTH + FAB_WIDTH,
|
||||||
zIndex: theme.zIndex.drawer + 1,
|
height: '100%',
|
||||||
transition: theme.transitions.create('left', {
|
transform: isMdUp ? 'none' : (drawerOpen ? 'translateX(0)' : `translateX(-${DRAWER_WIDTH}px)`),
|
||||||
|
transition: isMdUp ? 'none' : theme.transitions.create('transform', {
|
||||||
easing: theme.transitions.easing.sharp,
|
easing: theme.transitions.easing.sharp,
|
||||||
duration: theme.transitions.duration.standard,
|
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
|
<Fab
|
||||||
size="small"
|
size="small"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => setDrawerOpen(!drawerOpen)}
|
onClick={() => setDrawerOpen(!drawerOpen)}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: '0 50% 50% 0',
|
borderRadius: '0 50% 50% 0',
|
||||||
width: 32,
|
width: FAB_WIDTH,
|
||||||
height: 64,
|
height: FAB_HEIGHT,
|
||||||
minHeight: 64,
|
minHeight: FAB_HEIGHT,
|
||||||
boxShadow: 2,
|
boxShadow: 2,
|
||||||
|
transition: theme.transitions.create(['box-shadow', 'background-color'], {
|
||||||
|
duration: theme.transitions.duration.short,
|
||||||
|
}),
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
boxShadow: 4,
|
boxShadow: 4,
|
||||||
}
|
}
|
||||||
@ -309,37 +595,8 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
|||||||
{drawerOpen ? <ChevronLeft /> : <ChatIcon />}
|
{drawerOpen ? <ChevronLeft /> : <ChatIcon />}
|
||||||
</Fab>
|
</Fab>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
)}
|
||||||
|
</Box>
|
||||||
return (
|
|
||||||
<Box ref={ref} sx={{ width: "100%", height: "100%", display: "flex", flexGrow: 1, flexDirection: "column" }}>
|
|
||||||
{selectedCandidate && <CandidateInfo action={`Chat with Backstory about ${selectedCandidate.firstName}`} elevation={4} candidate={selectedCandidate} sx={{ minHeight: "max-content" }} />}
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", mt: 1, gap: 1, height: "100%", position: 'relative' }}>
|
|
||||||
{/* Drawer */}
|
|
||||||
<Drawer
|
|
||||||
variant="persistent"
|
|
||||||
anchor="left"
|
|
||||||
open={drawerOpen}
|
|
||||||
sx={{
|
|
||||||
width: drawerOpen ? DRAWER_WIDTH : 0,
|
|
||||||
flexShrink: 0,
|
|
||||||
'& .MuiDrawer-paper': {
|
|
||||||
width: DRAWER_WIDTH,
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
position: 'relative',
|
|
||||||
height: '100%',
|
|
||||||
border: 'none',
|
|
||||||
borderRight: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{drawerContent}
|
|
||||||
</Drawer>
|
|
||||||
|
|
||||||
{/* Drawer Handle */}
|
|
||||||
{drawerHandle}
|
|
||||||
|
|
||||||
{/* Chat Interface */}
|
{/* Chat Interface */}
|
||||||
<Paper
|
<Paper
|
||||||
@ -347,29 +604,49 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
transition: theme.transitions.create('margin', {
|
marginLeft: isMdUp ? 0 : 0,
|
||||||
easing: theme.transitions.easing.sharp,
|
width: isMdUp ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%',
|
||||||
duration: theme.transitions.duration.standard,
|
overflow: 'hidden',
|
||||||
}),
|
minHeight: 0, // Important for flex child
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{chatSession?.id ? (
|
{chatSession?.id ? (
|
||||||
<>
|
<>
|
||||||
<Box sx={{ flexGrow: 1, overflow: 'auto', p: 2 }}>
|
{/* Scrollable Messages Area */}
|
||||||
{
|
<Box sx={{
|
||||||
messages.map((message: ChatMessageBase) => (
|
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],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}>
|
||||||
|
{messages.map((message: ChatMessageBase) => (
|
||||||
<Message key={message.id} {...{ chatSession, message, setSnack, submitQuery }} />
|
<Message key={message.id} {...{ chatSession, message, setSnack, submitQuery }} />
|
||||||
))
|
))}
|
||||||
}
|
{processingMessage !== null && (
|
||||||
{
|
|
||||||
processingMessage !== null &&
|
|
||||||
<Message {...{ chatSession, message: processingMessage, setSnack, submitQuery }} />
|
<Message {...{ chatSession, message: processingMessage, setSnack, submitQuery }} />
|
||||||
}
|
)}
|
||||||
{
|
{streamingMessage !== null && (
|
||||||
streamingMessage !== null &&
|
|
||||||
<Message {...{ chatSession, message: streamingMessage, setSnack, submitQuery }} />
|
<Message {...{ chatSession, message: streamingMessage, setSnack, submitQuery }} />
|
||||||
}
|
)}
|
||||||
{streaming && <Box sx={{
|
{streaming && (
|
||||||
|
<Box sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@ -383,35 +660,24 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
|||||||
data-testid="loader"
|
data-testid="loader"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
)}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* Message Input */}
|
{/* Fixed Message Input */}
|
||||||
<Box sx={{ p: 2, display: 'flex', gap: 1 }}>
|
<Box sx={{ p: 2, display: 'flex', gap: 1, flexShrink: 0 }}>
|
||||||
<TextField
|
<BackstoryTextField
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
placeholder="Type your message about the candidate..."
|
placeholder="Type your message about the candidate..."
|
||||||
value={newMessage}
|
ref={backstoryTextRef}
|
||||||
onChange={(e) => setNewMessage(e.target.value)}
|
onEnter={sendMessage}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
sendMessage();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={streaming}
|
disabled={streaming}
|
||||||
multiline
|
|
||||||
maxRows={4}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={sendMessage}
|
onClick={() => { sendMessage((backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || ""); }}
|
||||||
disabled={!newMessage.trim() || streaming}
|
disabled={streaming}
|
||||||
sx={{ minWidth: 'auto', px: 2 }}
|
sx={{ minWidth: 'auto', px: 2 }}
|
||||||
>
|
>
|
||||||
▶
|
▶
|
||||||
@ -439,6 +705,46 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
|||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -109,6 +109,13 @@ export interface CreateChatSessionRequest {
|
|||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateChatSessionRequest {
|
||||||
|
title?: string;
|
||||||
|
context?: Partial<Types.ChatContext>;
|
||||||
|
isArchived?: boolean;
|
||||||
|
systemPrompt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CandidateSessionsResponse {
|
export interface CandidateSessionsResponse {
|
||||||
candidate: {
|
candidate: {
|
||||||
id: string;
|
id: string;
|
||||||
@ -505,6 +512,34 @@ class ApiClient {
|
|||||||
return this.handleApiResponseWithConversion<Types.ChatSession>(response, 'ChatSession');
|
return this.handleApiResponseWithConversion<Types.ChatSession>(response, 'ChatSession');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a chat session's properties
|
||||||
|
*/
|
||||||
|
async updateChatSession(
|
||||||
|
id: string,
|
||||||
|
updates: UpdateChatSessionRequest
|
||||||
|
): Promise<Types.ChatSession> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/chat/sessions/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: this.defaultHeaders,
|
||||||
|
body: JSON.stringify(formatApiRequest(updates))
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.handleApiResponseWithConversion<Types.ChatSession>(response, 'ChatSession');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a chat session
|
||||||
|
*/
|
||||||
|
async deleteChatSession(id: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/chat/sessions/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: this.defaultHeaders
|
||||||
|
});
|
||||||
|
|
||||||
|
return handleApiResponse<{ success: boolean; message: string }>(response);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send message with streaming response support and date conversion
|
* Send message with streaming response support and date conversion
|
||||||
*/
|
*/
|
||||||
@ -971,6 +1006,24 @@ try {
|
|||||||
console.error('Failed to fetch jobs:', error);
|
console.error('Failed to fetch jobs:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update and delete chat sessions with proper date handling
|
||||||
|
try {
|
||||||
|
// Update a session title
|
||||||
|
const updatedSession = await apiClient.updateChatSession('session-id', {
|
||||||
|
title: 'New Session Title',
|
||||||
|
isArchived: false
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Updated session:', updatedSession.title);
|
||||||
|
console.log('Last activity:', updatedSession.lastActivity.toLocaleString());
|
||||||
|
|
||||||
|
// Delete a session
|
||||||
|
const deleteResult = await apiClient.deleteChatSession('session-id');
|
||||||
|
console.log('Delete result:', deleteResult.message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to manage session:', error);
|
||||||
|
}
|
||||||
|
|
||||||
// Streaming with proper date conversion
|
// Streaming with proper date conversion
|
||||||
const streamResponse = apiClient.sendMessageStream(sessionId, 'Tell me about job opportunities', {
|
const streamResponse = apiClient.sendMessageStream(sessionId, 'Tell me about job opportunities', {
|
||||||
onStreaming: (chunk) => {
|
onStreaming: (chunk) => {
|
||||||
|
@ -418,10 +418,27 @@ class RedisDatabase:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def delete_chat_session(self, session_id: str):
|
async def delete_chat_session(self, session_id: str) -> bool:
|
||||||
"""Delete chat session"""
|
'''Delete a chat session from Redis'''
|
||||||
key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}"
|
try:
|
||||||
await self.redis.delete(key)
|
result = await self.redis.delete(f"chat_session:{session_id}")
|
||||||
|
return result > 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting chat session {session_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def delete_chat_message(self, session_id: str, message_id: str) -> bool:
|
||||||
|
'''Delete a specific chat message from Redis'''
|
||||||
|
try:
|
||||||
|
# Remove from the session's message list
|
||||||
|
await self.redis.lrem(f"chat_messages:{session_id}", 0, message_id)
|
||||||
|
|
||||||
|
# Delete the message data itself
|
||||||
|
result = await self.redis.delete(f"chat_message:{message_id}")
|
||||||
|
return result > 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting chat message {message_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
# Chat Messages operations (stored as lists)
|
# Chat Messages operations (stored as lists)
|
||||||
async def get_chat_messages(self, session_id: str) -> List[Dict]:
|
async def get_chat_messages(self, session_id: str) -> List[Dict]:
|
||||||
|
@ -1576,6 +1576,164 @@ async def get_chat_session_messages(
|
|||||||
content=create_error_response("FETCH_ERROR", str(e))
|
content=create_error_response("FETCH_ERROR", str(e))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@api_router.patch("/chat/sessions/{session_id}")
|
||||||
|
async def update_chat_session(
|
||||||
|
session_id: str = Path(...),
|
||||||
|
updates: Dict[str, Any] = Body(...),
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Update a chat session's properties"""
|
||||||
|
try:
|
||||||
|
# Get the existing session
|
||||||
|
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 update their own sessions
|
||||||
|
if session.user_id != current_user.id:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content=create_error_response("FORBIDDEN", "Cannot update another user's chat session")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate and apply updates
|
||||||
|
allowed_fields = {"title", "context", "isArchived", "systemPrompt"}
|
||||||
|
filtered_updates = {k: v for k, v in updates.items() if k in allowed_fields}
|
||||||
|
|
||||||
|
if not filtered_updates:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content=create_error_response("INVALID_UPDATES", "No valid fields provided for update")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply updates to session data
|
||||||
|
session_dict = session.model_dump()
|
||||||
|
|
||||||
|
# Handle special field mappings (camelCase to snake_case)
|
||||||
|
if "isArchived" in filtered_updates:
|
||||||
|
session_dict["is_archived"] = filtered_updates["isArchived"]
|
||||||
|
if "systemPrompt" in filtered_updates:
|
||||||
|
session_dict["system_prompt"] = filtered_updates["systemPrompt"]
|
||||||
|
if "title" in filtered_updates:
|
||||||
|
session_dict["title"] = filtered_updates["title"]
|
||||||
|
if "context" in filtered_updates:
|
||||||
|
# Merge context updates with existing context
|
||||||
|
existing_context = session_dict.get("context", {})
|
||||||
|
context_updates = filtered_updates["context"]
|
||||||
|
|
||||||
|
# Update specific context fields while preserving others
|
||||||
|
for context_key, context_value in context_updates.items():
|
||||||
|
if context_key == "additionalContext":
|
||||||
|
# Merge additional context
|
||||||
|
existing_additional = existing_context.get("additional_context", {})
|
||||||
|
existing_additional.update(context_value)
|
||||||
|
existing_context["additional_context"] = existing_additional
|
||||||
|
else:
|
||||||
|
# Convert camelCase to snake_case for context fields
|
||||||
|
snake_key = context_key
|
||||||
|
if context_key == "relatedEntityId":
|
||||||
|
snake_key = "related_entity_id"
|
||||||
|
elif context_key == "relatedEntityType":
|
||||||
|
snake_key = "related_entity_type"
|
||||||
|
elif context_key == "aiParameters":
|
||||||
|
snake_key = "ai_parameters"
|
||||||
|
|
||||||
|
existing_context[snake_key] = context_value
|
||||||
|
|
||||||
|
session_dict["context"] = existing_context
|
||||||
|
|
||||||
|
# Update last activity timestamp
|
||||||
|
session_dict["last_activity"] = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
# Validate the updated session
|
||||||
|
updated_session = ChatSession.model_validate(session_dict)
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
await database.set_chat_session(session_id, updated_session.model_dump())
|
||||||
|
|
||||||
|
logger.info(f"✅ Chat session {session_id} updated by user {current_user.id}")
|
||||||
|
|
||||||
|
return create_success_response(updated_session.model_dump(by_alias=True, exclude_unset=True))
|
||||||
|
|
||||||
|
except ValueError as ve:
|
||||||
|
logger.warning(f"⚠️ Validation error updating chat session: {ve}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content=create_error_response("VALIDATION_ERROR", str(ve))
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Update chat session error: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=create_error_response("UPDATE_ERROR", str(e))
|
||||||
|
)
|
||||||
|
|
||||||
|
@api_router.delete("/chat/sessions/{session_id}")
|
||||||
|
async def delete_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 delete 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
|
||||||
|
|
||||||
|
# Delete the session itself
|
||||||
|
await database.delete_chat_session(session_id)
|
||||||
|
|
||||||
|
logger.info(f"🗑️ Chat session {session_id} deleted by user {current_user.id}")
|
||||||
|
|
||||||
|
return create_success_response({
|
||||||
|
"success": True,
|
||||||
|
"message": "Chat session deleted successfully",
|
||||||
|
"sessionId": session_id
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Delete chat session error: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=create_error_response("DELETE_ERROR", str(e))
|
||||||
|
)
|
||||||
|
|
||||||
@api_router.get("/candidates/{username}/chat-sessions")
|
@api_router.get("/candidates/{username}/chat-sessions")
|
||||||
async def get_candidate_chat_sessions(
|
async def get_candidate_chat_sessions(
|
||||||
username: str = Path(...),
|
username: str = Path(...),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user