Added mobile view of resume chat

This commit is contained in:
James Ketr 2025-07-16 18:17:59 -07:00
parent c2564f5966
commit 794efe0f95
5 changed files with 255 additions and 186 deletions

View File

@ -1,6 +1,7 @@
import React, { forwardRef, useState, useEffect, useRef, JSX } from 'react';
import { Box, Button, Tooltip, SxProps } from '@mui/material';
import { Send as SendIcon } from '@mui/icons-material';
import { Box, Button, Tooltip, SxProps, useMediaQuery, useTheme, Tabs, Tab } from '@mui/material';
import { Send as SendIcon, Tune as TuneIcon } from '@mui/icons-material';
import DescriptionIcon from '@mui/icons-material/Description';
import { useAuth } from 'hooks/AuthContext';
import {
ChatMessage,
@ -70,15 +71,23 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
>(null);
const [streamingMessage, setStreamingMessage] = useState<ChatMessage | null>(null);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const { setSnack } = useAppState();
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [streaming, setStreaming] = useState<boolean>(false);
const [tabValue, setTabValue] = useState<string>('chat');
const messagesEndRef = useRef<HTMLDivElement>(null);
const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => {
if (newValue !== tabValue) {
setTabValue(newValue);
}
};
useEffect(() => {
if (!resumeId || resume) return;
apiClient
@ -264,8 +273,13 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
timestamp: new Date(),
content:
`Welcome to the Backstory Chat about ${selectedCandidate.fullName}` +
(resume && ` and the ${resume.job?.title} position at ${resume.job?.company}`) +
`. Enter any questions you have about ${candidatePossessive} resume or skills, or select from the available questions.`,
(resume ? ` and the ${resume.job?.title} position at ${resume.job?.company}` : '') +
`. Enter any questions you have about ${candidatePossessive} ${
resume ? 'resume' : 'work history'
} or skills, or select from the available questions.` +
(isMobile
? `\n\nYou can also click on the "Resume" tab to ${candidatePossessive} resume for this position.`
: ''),
metadata: emptyMetadata,
};
@ -275,183 +289,204 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
return (
<Box
ref={ref}
sx={{
display: 'flex',
flexDirection: 'row',
height: '100%' /* Restrict to main-container's height */,
width: '100%',
minHeight: 0 /* Prevent flex overflow */,
maxHeight: '100%',
'& > *:not(.Scrollable)': {
flexShrink: 0 /* Prevent shrinking */,
},
...sx,
flexDirection: 'column',
flexGrow: 1,
p: 0,
m: 0,
backgroundColor: '#D3CDBF' /* Warm Gray */,
height: '100%',
position: 'relative',
}}
>
{resume && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
width: '50%',
maxHeight: '100%',
p: 0,
m: 0,
position: 'relative',
backgroundColor: '#f5f5f5',
}}
>
<Box
sx={{
position: 'relative',
display: 'flex',
justifyContent: 'center',
p: 1,
}}
>
<strong>{resume.job?.title}</strong>&nbsp;at&nbsp;
<strong>{resume.job?.company}</strong>. Last updated{' '}
{formatDate(resume.updatedAt, false, true)}.
</Box>
<Scrollable sx={{ m: 0 }}>
<ResumePreview shadeMargins={false} resume={resume} />
</Scrollable>
</Box>
{isMobile && (
<Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab value="resume" icon={<DescriptionIcon />} label="Resume" />
<Tab value="chat" icon={<TuneIcon />} label="Chat" />
</Tabs>
)}
<Box
ref={ref}
sx={{
display: 'flex',
flexDirection: 'column',
width: '50%',
p: 1,
m: resume ? 0 : '0 auto',
position: 'relative',
backgroundColor: 'background.paper',
flexDirection: 'row',
height: '100%' /* Restrict to main-container's height */,
width: '100%',
minHeight: 0 /* Prevent flex overflow */,
maxHeight: '100%',
'& > *:not(.Scrollable)': {
flexShrink: 0 /* Prevent shrinking */,
},
...sx,
p: 0,
m: 0,
backgroundColor: '#D3CDBF' /* Warm Gray */,
}}
>
{!resume && (
<CandidateInfo
key={selectedCandidate.username}
action={`Chat with Backstory about ${selectedCandidate.firstName}`}
elevation={4}
candidate={selectedCandidate}
variant="normal"
{(!isMobile || tabValue === 'resume') && resume && (
<Box
sx={{
width: '100%',
maxHeight: 0,
minHeight: 'min-content',
}} // Prevent header from shrinking
/>
)}
{/* Chat Interface */}
{/* Scrollable Messages Area */}
{chatSession && (
<Scrollable
sx={{
width: '100%',
display: 'flex',
flexDirection: 'column',
width: isMobile ? '100%' : '50%',
maxHeight: '100%',
p: 0,
m: 0,
position: 'relative',
backgroundColor: '#f5f5f5',
}}
>
<Box sx={{ display: 'flex', flexGrow: 1 }}></Box>
{messages.length === 0 && <Message {...{ chatSession, message: welcomeMessage }} />}
{messages.map((message: ChatMessage) => (
<Message key={message.id} {...{ chatSession, message }} />
))}
{processingMessage !== null && (
<Message {...{ chatSession, message: processingMessage }} />
)}
{streamingMessage !== null && (
<Message {...{ chatSession, message: streamingMessage }} />
)}
{streaming && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
m: 1,
}}
>
<PropagateLoader
size="10px"
loading={streaming}
aria-label="Loading Spinner"
data-testid="loader"
/>
</Box>
)}
<div ref={messagesEndRef} />
</Scrollable>
)}
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 0.5,
p: 0.5,
flex: 0,
flexWrap: 'wrap',
}}
>
{selectedCandidate.questions?.map((q, i) => (
<BackstoryQuery key={i} question={q} submitQuery={handleSubmitQuestion} />
))}
{resume &&
defaultQuestions.map((question: string, i) => (
<BackstoryQuery
key={i}
question={{ question }}
submitQuery={handleSubmitQuestion}
/>
))}
</Box>
{/* Fixed Message Input */}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<DeleteConfirmation
onDelete={(): void => {
chatSession && onDelete(chatSession);
}}
disabled={!chatSession}
sx={{ minWidth: 'auto', px: 2, maxHeight: 'min-content' }}
action="reset"
label="chat session"
title="Reset Chat Session"
message={`Are you sure you want to reset the session? This action cannot be undone.`}
/>
<BackstoryTextField
placeholder="Type your message about the candidate..."
ref={backstoryTextRef}
onEnter={sendMessage}
disabled={streaming || loading}
/>
<Tooltip title="Send">
<span
style={{
minWidth: 'auto',
maxHeight: 'min-content',
alignSelf: 'center',
<Box
sx={{
position: 'relative',
display: 'flex',
justifyContent: 'center',
p: 1,
}}
>
<Button
variant="contained"
onClick={(): void => {
sendMessage(
(backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) ||
''
);
<strong>{resume.job?.title}</strong>&nbsp;at&nbsp;
<strong>{resume.job?.company}</strong>. Last updated{' '}
{formatDate(resume.updatedAt, false, true)}.
</Box>
<Scrollable sx={{ m: 0 }}>
<ResumePreview shadeMargins={false} resume={resume} />
</Scrollable>
</Box>
)}
{(!isMobile || tabValue === 'chat') && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
width: isMobile ? '100%' : '50%',
p: 1,
m: resume ? 0 : '0 auto',
position: 'relative',
backgroundColor: 'background.paper',
}}
>
{!resume && (
<CandidateInfo
key={selectedCandidate.username}
action={`Chat with Backstory about ${selectedCandidate.firstName}`}
elevation={4}
candidate={selectedCandidate}
variant="normal"
sx={{
width: '100%',
maxHeight: 0,
minHeight: 'min-content',
}} // Prevent header from shrinking
/>
)}
{/* Chat Interface */}
{/* Scrollable Messages Area */}
{chatSession && (
<Scrollable
sx={{
width: '100%',
}}
disabled={streaming || loading}
>
<SendIcon />
</Button>
</span>
</Tooltip>
</Box>
{messages.length === 0 && (
<Message {...{ chatSession, message: welcomeMessage }} />
)}
{messages.map((message: ChatMessage) => (
<Message key={message.id} {...{ chatSession, message }} />
))}
{processingMessage !== null && (
<Message {...{ chatSession, message: processingMessage }} />
)}
{streamingMessage !== null && (
<Message {...{ chatSession, message: streamingMessage }} />
)}
{streaming && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
m: 1,
}}
>
<PropagateLoader
size="10px"
loading={streaming}
aria-label="Loading Spinner"
data-testid="loader"
/>
</Box>
)}
<div ref={messagesEndRef} />
</Scrollable>
)}
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 0.5,
p: 0.5,
flex: 0,
flexWrap: 'wrap',
}}
>
{selectedCandidate.questions?.map((q, i) => (
<BackstoryQuery key={i} question={q} submitQuery={handleSubmitQuestion} />
))}
{resume &&
defaultQuestions.map((question: string, i) => (
<BackstoryQuery
key={i}
question={{ question }}
submitQuery={handleSubmitQuestion}
/>
))}
</Box>
{/* Fixed Message Input */}
<Box sx={{ display: 'flex', gap: 1 }}>
<DeleteConfirmation
onDelete={(): void => {
chatSession && onDelete(chatSession);
}}
disabled={!chatSession}
sx={{ minWidth: 'auto', px: 2, maxHeight: 'min-content' }}
action="reset"
label="chat session"
title="Reset Chat Session"
message={`Are you sure you want to reset the session? This action cannot be undone.`}
/>
<BackstoryTextField
placeholder="Type your message about the candidate..."
ref={backstoryTextRef}
onEnter={sendMessage}
disabled={streaming || loading}
/>
<Tooltip title="Send">
<span
style={{
minWidth: 'auto',
maxHeight: 'min-content',
alignSelf: 'center',
}}
>
<Button
variant="contained"
onClick={(): void => {
sendMessage(
(backstoryTextRef.current &&
backstoryTextRef.current.getAndResetValue()) ||
''
);
}}
disabled={streaming || loading}
>
<SendIcon />
</Button>
</span>
</Tooltip>
</Box>
</Box>
)}
</Box>
</Box>
);

View File

@ -80,7 +80,7 @@ class ResumeChat(Agent):
)
yield error_message
return
resume_data = await database.get_resume(user_id=candidate.id, resume_id=extra_context.resume_id)
resume_data = await database.get_resume(resume_id=extra_context.resume_id, user_id=candidate.id)
resume = Resume.model_validate(resume_data) if resume_data else None
if not resume:
error_message = ChatMessageError(

View File

@ -320,7 +320,7 @@ class DatabaseProtocol(Protocol):
async def get_resumes_by_job(self, user_id: str, job_id: str) -> List[Resume]:
...
async def get_resume(self, user_id: str, resume_id: str) -> Optional[Resume]:
async def get_resume(self, resume_id: str, user_id: Optional[str] = None) -> Optional[Resume]:
...
async def get_resume_statistics(self, user_id: str) -> Dict[str, Any]:

View File

@ -33,21 +33,52 @@ class ResumeMixin(DatabaseProtocol):
logger.error(f"❌ Error saving resume for user {user_id}: {e}")
return False
async def get_resume(self, user_id: str, resume_id: str) -> Optional[Resume]:
"""Get a specific resume for a user"""
async def get_resume(self, resume_id: str, user_id: Optional[str] = None) -> Optional[Resume]:
"""Get a specific resume by resume_id, optionally filtered by user_id"""
try:
key = f"{KEY_PREFIXES['resumes']}{user_id}:{resume_id}"
data = await self.redis.get(key)
if data:
resume_data = self._deserialize(data)
logger.info(f"📄 Retrieved resume {resume_id} for user {user_id}")
return Resume.model_validate(resume_data)
logger.info(f"📄 Resume {resume_id} not found for user {user_id}")
return None
except Exception as e:
logger.error(f"❌ Error retrieving resume {resume_id} for user {user_id}: {e}")
return None
if user_id:
# If user_id is provided, use the original key structure
key = f"{KEY_PREFIXES['resumes']}{user_id}:{resume_id}"
data = await self.redis.get(key)
if data:
resume_data = self._deserialize(data)
logger.info(f"📄 Retrieved resume {resume_id} for user {user_id}")
return Resume.model_validate(resume_data)
logger.info(f"📄 Resume {resume_id} not found for user {user_id}")
return None
else:
# If no user_id provided, search for the resume across all users
pattern = f"{KEY_PREFIXES['resumes']}*:{resume_id}"
# Use SCAN to find matching keys
matching_keys = []
async for key in self.redis.scan_iter(match=pattern):
matching_keys.append(key)
if not matching_keys:
logger.info(f"📄 Resume {resume_id} not found")
return None
if len(matching_keys) > 1:
logger.warning(f"📄 Multiple resumes found with ID {resume_id}, returning first match")
# Get data from the first matching key
target_key = matching_keys[0]
data = await self.redis.get(target_key)
if data:
resume_data = self._deserialize(data)
# Extract user_id from key for logging
key_user_id = target_key.replace(KEY_PREFIXES["resumes"], "").split(":")[0]
logger.info(f"📄 Retrieved resume {resume_id} for user {key_user_id}")
return Resume.model_validate(resume_data)
logger.info(f"📄 Resume {resume_id} data not found")
return None
except Exception as e:
logger.error(f"❌ Error retrieving resume {resume_id}: {e}")
return None
async def get_all_resumes_for_user(self, user_id: str) -> List[Dict]:
"""Get all resumes for a specific user"""
try:
@ -283,7 +314,7 @@ class ResumeMixin(DatabaseProtocol):
"""Update specific fields of a resume and save the previous version to history"""
try:
# Get current resume data
current_resume = await self.get_resume(user_id, resume_id)
current_resume = await self.get_resume(resume_id, user_id)
if not current_resume:
logger.warning(f"📄 Resume {resume_id} not found for user {user_id}")
return None
@ -412,7 +443,7 @@ class ResumeMixin(DatabaseProtocol):
return None
# Save current version to history before restoring
current_resume = await self.get_resume(user_id, resume_id)
current_resume = await self.get_resume(resume_id, user_id)
if current_resume:
await self._save_resume_revision(user_id, resume_id, current_resume.model_dump())

View File

@ -1,6 +1,7 @@
"""
Resume Routes
"""
import json
from typing import List
import uuid
@ -12,12 +13,13 @@ import backstory_traceback as backstory_traceback
from database.manager import RedisDatabase
from logger import logger
from models import MOCK_UUID, ChatMessageError, Job, Candidate, Resume, ResumeMessage
from utils.dependencies import get_database, get_current_user
from utils.dependencies import get_database, get_current_user, get_current_user_or_guest
from utils.responses import create_success_response
# Create router for authentication endpoints
router = APIRouter(prefix="/resumes", tags=["resumes"])
@router.post("")
async def create_candidate_resume(
resume: Resume = Body(...),
@ -142,12 +144,12 @@ async def get_user_resumes(current_user=Depends(get_current_user), database: Red
@router.get("/{resume_id}")
async def get_resume(
resume_id: str = Path(..., description="ID of the resume"),
current_user=Depends(get_current_user),
current_user=Depends(get_current_user_or_guest),
database: RedisDatabase = Depends(get_database),
):
"""Get a specific resume by ID"""
try:
resume_data = await database.get_resume(current_user.id, resume_id)
resume_data = await database.get_resume(resume_id)
if not resume_data:
raise HTTPException(status_code=404, detail="Resume not found")
resume = Resume.model_validate(resume_data)
@ -242,6 +244,7 @@ async def get_resume_statistics(
logger.error(f"❌ Error retrieving resume statistics for user {current_user.id}: {e}")
raise HTTPException(status_code=500, detail="Failed to retrieve resume statistics")
@router.patch("")
async def update_resume(
resume: Resume = Body(...),
@ -260,7 +263,7 @@ async def update_resume(
logger.warning(f"⚠️ Resume {resume.id} could not be updated for user {current_user.id}")
raise HTTPException(status_code=400, detail="Failed to update resume")
return create_success_response(updated_resume.model_dump(by_alias=True))
except HTTPException:
raise
except Exception as e:
@ -420,4 +423,4 @@ async def compare_resume_revisions(
raise
except Exception as e:
logger.error(f"❌ Error comparing revisions {revision_id1} and {revision_id2} for resume {resume_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to compare resume revisions")
raise HTTPException(status_code=500, detail="Failed to compare resume revisions")