Improved resume editing

This commit is contained in:
James Ketr 2025-07-17 16:44:19 -07:00
parent 65471b9fe0
commit 35a296889b
19 changed files with 1260 additions and 1051 deletions

View File

@ -86,7 +86,6 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
const [loading, setLoading] = useState<boolean>(false);
const [streaming, setStreaming] = useState<boolean>(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [lastPrompt, setLastPrompt] = useState<string>('');
const onDelete = async (session: ChatSession): Promise<void> => {
if (!session.id) {
@ -109,7 +108,6 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
const messageContent = message;
setStreaming(true);
setLastPrompt(message);
const chatMessage: ChatMessageUser = {
sessionId: chatSession.id,
@ -141,7 +139,7 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
isAnswer: true,
};
} else {
onResumeChange(lastPrompt, msg.content);
onResumeChange(message, msg.content);
}
setMessages(prev => {
@ -167,11 +165,21 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
},
onStreaming: (chunk: ChatMessageStreaming): void => {
// console.log("onStreaming:", chunk);
if (chunk.content[0] === 'A') {
chunk.content = chunk.content.replace(/A[^:]*:?/, '');
const prefix = 'ANSWER:';
let content = chunk.content || '';
for (let i = prefix.length; i >= 1; i--) {
if (chunk.content.startsWith(prefix.slice(0, i))) {
content = chunk.content.slice(i);
break;
}
}
content = content.trim();
if (streamingMessage?.content === content) {
return; // Avoid duplicate updates
}
setStreamingMessage({
...chunk,
content,
role: 'assistant',
metadata: emptyMetadata,
});
@ -226,9 +234,10 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
try {
const result = await apiClient.getChatMessages(chatSession.id);
const chatMessages: ChatMessage[] = result.data;
console.log(`getChatMessages returned ${chatMessages.length} messages.`, chatMessages);
chatMessages.forEach(msg => {
if (msg.role === 'assistant' && msg.content.match(/^ANSWER:\s*/)) {
msg.content = msg.content.replace(/^ANSWER:\s*/, '');
msg.content = msg.content.replace(/^ANSWER: */, '');
msg.extraContext = {
isAnswer: true,
};
@ -238,7 +247,6 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
setMessages(chatMessages);
setProcessingMessage(null);
setStreamingMessage(null);
console.log(`getChatMessages returned ${chatMessages.length} messages.`, chatMessages);
} catch (error) {
console.error('Failed to load messages:', error);
}

View File

@ -27,7 +27,7 @@ interface BackstoryPageContainerProps {
const BackstoryPageContainer = (props: BackstoryPageContainerProps): JSX.Element => {
const { children, variant = 'normal' } = props;
console.log({ variant });
return (
<Container
className="BackstoryPageContainer"

View File

@ -0,0 +1,792 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Typography,
Button,
LinearProgress,
IconButton,
Tooltip,
Tabs,
Tab,
Paper,
FormControl,
Select,
MenuItem,
InputLabel,
Chip,
Alert,
Stack,
SelectChangeEvent,
} from '@mui/material';
import PrintIcon from '@mui/icons-material/Print';
import DifferenceIcon from '@mui/icons-material/Difference';
import {
Save as SaveIcon,
ModelTraining,
History as HistoryIcon,
RestoreFromTrash as RestoreFromTrashIcon,
Refresh as RefreshIcon,
Work as WorkIcon,
Undo as UndoIcon,
} from '@mui/icons-material';
import PreviewIcon from '@mui/icons-material/Preview';
import EditDocumentIcon from '@mui/icons-material/EditDocument';
import { useReactToPrint } from 'react-to-print';
import { useAuth } from 'hooks/AuthContext';
import { Resume } from 'types/types';
import { BackstoryTextField } from 'components/BackstoryTextField';
import { JobInfo } from './JobInfo';
import { Scrollable } from 'components/Scrollable';
import * as Types from 'types/types';
import { StreamingOptions } from 'services/api-client';
import { StatusBox, StatusIcon } from './StatusIcon';
import { ResumeChat } from 'components/ResumeChat';
import { DiffViewer } from 'components/DiffViewer';
import { ResumePreview, resumeStyles } from './ResumePreview';
import { useAppState } from 'hooks/GlobalContext';
interface ResumeRevision {
revisionId: string;
revisionTimestamp: string;
updatedAt: string;
createdAt: string;
candidateId: string;
jobId: string;
}
interface ResumeEditProps {
onClose?: () => void;
resumeId?: string;
onSave?: (updatedResume: Resume) => void;
}
const ResumeEdit: React.FC<ResumeEditProps> = (props: ResumeEditProps) => {
const { setSnack } = useAppState();
const { onClose, resumeId, onSave } = props;
const { selectedCandidate, selectedJob } = useAppState();
const { apiClient } = useAuth();
const [editContent, setEditContent] = useState<string>('');
const [saving, setSaving] = useState<boolean>(false);
const [tabValue, setTabValue] = useState('markdown');
const [jobTabValue, setJobTabValue] = useState('chat');
const [status, setStatus] = useState<string>('');
const [statusType, setStatusType] = useState<Types.ApiActivityType | null>(null);
const [error, setError] = useState<Types.ChatMessageError | null>(null);
const [selectedStyle, setSelectedStyle] = useState<string>('corporate');
const [resume, setResume] = useState<Resume | null>(null);
const [isAIGenerated, setIsAIGenerated] = useState<boolean>(false);
const [editPrompt, setEditPrompt] = useState<string>('');
// Revision-related state
const [revisions, setRevisions] = useState<ResumeRevision[]>([]);
const [selectedRevision, setSelectedRevision] = useState<string>('current');
const [loadingRevisions, setLoadingRevisions] = useState<boolean>(false);
const [loadingRevision, setLoadingRevision] = useState<boolean>(false);
const [saveDisabled, setSaveDisabled] = useState<boolean>(true);
const [current, setCurrent] = useState<Types.Resume | null>(null);
const [backupContent, setBackupContent] = useState<string>('');
const printContentRef = useRef<HTMLDivElement>(null);
const reactToPrintFn = useReactToPrint({
contentRef: printContentRef,
pageStyle: '@page { margin: 8mm !important; }',
});
useEffect(() => {
setSaveDisabled(
!resume ||
statusType !== null ||
saving ||
editContent.trim() === '' ||
selectedRevision !== 'current' ||
(editContent === resume?.resume && isAIGenerated === false)
);
}, [resume, statusType, saving, editContent, isAIGenerated]);
// Initialize edit content when dialog opens
useEffect(() => {
if (!selectedCandidate || !selectedJob) {
setSnack('No candidate or job selected for resume generation.', 'error');
return;
}
if (!resumeId && !resume) {
apiClient
.getResumesByCandidate(selectedCandidate.id || '', selectedJob.id || '')
.then(resumes => {
if (resumes.length > 0) {
console.log(
`Found ${resumes.length} resumes for candidate ${selectedCandidate.id} and job ${selectedJob.id}`
);
setResume(resumes[0]);
setCurrent(resumes[0]);
setBackupContent(resumes[0].resume);
} else {
console.log(
`No resumes found for candidate ${selectedCandidate.id} and job ${selectedJob.id}. Generating new resume...`
);
generateResume();
}
})
.catch(error => {
console.log(error);
});
return;
}
if (!resume) {
apiClient.getResume(resumeId || '').then(loadedResume => {
console.log(`Loading resume with ID: ${resumeId}`);
setResume(loadedResume);
setCurrent(loadedResume);
setBackupContent(loadedResume.resume);
});
return;
}
console.log(`Resume was ${resume.aiGenerated ? 'AI-generated' : 'user-generated'}`, resume);
setEditPrompt(resume.prompt || '');
setIsAIGenerated(resume.aiGenerated || false);
setEditContent(resume.resume);
loadResumeRevisions();
}, [resume]);
const currentStyle = useMemo(() => {
return resumeStyles[selectedStyle];
}, [selectedStyle]);
// Load resume revisions
const loadResumeRevisions = async (): Promise<void> => {
if (!resume) return;
setLoadingRevisions(true);
try {
const response = await apiClient.getResumeRevisions(resume.id || '');
console.log('Loaded revisions:', response.revisions);
setRevisions(response.revisions || []);
} catch (error) {
if (error instanceof Error) {
console.error('Failed to load revisions:', error);
setSnack(`Failed to load resume revisions: ${error.message || 'Unknown error'}`, 'error');
}
} finally {
setLoadingRevisions(false);
}
};
// Load specific revision content
const loadRevision = async (revisionId: string): Promise<void> => {
if (!resume || revisionId === 'current') {
return;
}
setLoadingRevision(true);
try {
const response = await apiClient.getResumeRevision(resume.id || '', revisionId);
setResume(response.revision);
setEditPrompt(response.revision.prompt || '');
setIsAIGenerated(response.revision.aiGenerated || false);
} catch (error) {
console.error('Failed to load revision:', error);
setSnack('Failed to load revision content.', 'error');
} finally {
setLoadingRevision(false);
}
};
// Handle revision selection
const handleRevisionChange = async (event: SelectChangeEvent<string>): Promise<void> => {
const newRevisionId = event.target.value;
if (selectedRevision === 'current') {
setEditPrompt(current?.prompt || '');
setIsAIGenerated(current?.aiGenerated || false);
setSelectedRevision(newRevisionId);
setBackupContent(editContent);
} else {
setSelectedRevision(newRevisionId);
if (newRevisionId === 'current') {
setEditContent(backupContent);
}
await loadRevision(newRevisionId);
}
};
// Restore revision to editor
const restoreRevision = (): void => {
if (!resume) {
setSnack('No revision selected to restore.', 'error');
return;
}
setResume(resume);
setEditContent(resume.resume);
setSelectedRevision('current');
setSnack('Revision restored to editor.', 'warning');
};
// Format timestamp for display
const formatRevisionTimestamp = (timestamp: string): string => {
try {
const date = new Date(timestamp);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return timestamp;
}
};
const handleSave = async (): Promise<void> => {
if (!resume) {
setSnack('No resume to save.', 'error');
return;
}
setSaving(true);
try {
const resumeUpdate = {
...resume,
resume: editContent,
prompt: isAIGenerated ? editPrompt : 'Manual edits',
};
const result = await apiClient.updateResume(resumeUpdate);
console.log('Resume updated:', result);
const updatedResume = {
...resume,
...result,
};
setResume(updatedResume);
setCurrent(updatedResume);
setBackupContent(updatedResume.resume);
onSave && onSave(updatedResume);
setSnack('Resume updated successfully.');
// Reload revisions to include the new version
await loadResumeRevisions();
} catch (error) {
setSnack('Failed to update resume.', 'error');
} finally {
setSaving(false);
}
};
const generateResumeHandlers: StreamingOptions<Types.ChatMessageResume> = {
onMessage: (_message: Types.ChatMessageResume) => {
setStatus('');
setStatusType(null);
setSnack('Resume generation completed successfully.');
},
onStreaming: (chunk: Types.ChatMessageStreaming) => {
if (status === '') {
setStatus('Generating resume...');
setStatusType('generating');
}
setEditContent(chunk.content);
},
onStatus: (status: Types.ChatMessageStatus) => {
console.log('status:', status.content);
setStatusType(status.activity);
setStatus(status.content);
},
onError: (error: Types.ChatMessageError) => {
console.log('error:', error);
setStatusType(null);
setStatus(error.content);
setError(error);
},
};
const generateResume = async (): Promise<Resume> => {
if (!selectedCandidate || !selectedJob) {
setSnack('No candidate or job selected for resume generation.', 'error');
return Promise.reject('No candidate or job selected');
}
setEditContent('');
setStatusType('thinking');
setStatus('Starting resume generation...');
setSelectedRevision('current');
const request = apiClient.generateResume(
selectedCandidate.id || '',
selectedJob.id || '',
generateResumeHandlers
);
const generatedResume = (await request.promise).resume;
setResume(generatedResume);
setCurrent(generatedResume);
setBackupContent(generatedResume.resume);
setSelectedRevision('current');
setEditPrompt(generatedResume.prompt || '');
setIsAIGenerated(true);
await loadResumeRevisions();
return generatedResume;
};
const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => {
if (newValue === 'print') {
console.log('Printing resume...');
reactToPrintFn();
return;
}
if (newValue === 'regenerate') {
setSnack('Regenerating resume...');
setTabValue('markdown');
generateResume();
return;
}
if (newValue === 'undo') {
setEditContent(current?.resume || '');
setEditPrompt(current?.prompt || '');
setIsAIGenerated(current?.aiGenerated || false);
setTabValue('markdown');
return;
}
setTabValue(newValue);
};
const handleJobTabChange = (event: React.SyntheticEvent, newValue: string): void => {
setJobTabValue(newValue);
};
const handleClose = (): void => {
onClose && onClose();
};
const changeLog =
(selectedRevision !== 'current' && resume?.aiGenerated ? resume?.prompt : '') ||
(isAIGenerated ? editPrompt : '') ||
'Manual edits';
console.log(
`Change log: ${changeLog}, isAIGenerated: ${isAIGenerated}, editPrompt: ${editPrompt}, selectedRevision: ${selectedRevision}, resume?.aiGenerated: ${resume?.aiGenerated}`
);
return (
<Box
sx={{
display: 'flex',
flexGrow: 1,
width: '100%',
height: '100%',
flexDirection: 'column',
position: 'relative',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
p: 2,
position: 'relative',
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.5,
m: 0,
p: 0,
minWidth: '25rem',
}}
>
{resume && (
<>
<Typography variant="caption" display="block" color="text.secondary">
Resume for {resume.candidate?.fullName || resume.candidateId},{' '}
{resume.job?.title || 'No Job Title Assigned'},{' '}
{resume.job?.company || 'No Company Assigned'}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
Resume ID: {resume.id}
</Typography>
</>
)}
{!resume && (
<Typography variant="caption" display="block" color="text.secondary">
No resume loaded. Please wait for generation to complete.
</Typography>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
{/* Style Selector */}
<FormControl size="small" sx={{ minWidth: 'min-content' }}>
<InputLabel id="resume-style-label">Resume Style</InputLabel>
<Select
labelId="resume-style-label"
value={selectedStyle}
onChange={e => setSelectedStyle(e.target.value)}
label="Resume Style"
>
{Object.entries(resumeStyles).map(([key, style]) => (
<MenuItem key={key} value={key}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Typography variant="body2" fontWeight="bold">
{style.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{style.description}
</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
<Box sx={{ display: 'flex', flexDirection: 'row' }}>
<Stack direction="row" spacing={2} alignItems="center">
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel id="revision-select-label">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<HistoryIcon fontSize="small" />
Version History
</Box>
</InputLabel>
<Select
labelId="revision-select-label"
value={selectedRevision}
onChange={handleRevisionChange}
label="Version History"
disabled={loadingRevisions}
>
<MenuItem value="current">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label="CURRENT" size="small" color="primary" variant="outlined" />
<Typography variant="body2">Current Version</Typography>
</Box>
</MenuItem>
{revisions.map(revision => (
<MenuItem key={revision.revisionId} value={revision.revisionId}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Typography variant="body2">
{formatRevisionTimestamp(revision.revisionTimestamp)}
</Typography>
<Typography variant="caption" color="text.secondary">
Updated: {formatRevisionTimestamp(revision.updatedAt)}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: '0.65rem' }}
>
ID: {revision.revisionId.substring(0, 8)}...
</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
<Tooltip title="Refresh Revisions">
<IconButton
size="small"
onClick={loadResumeRevisions}
disabled={loadingRevisions}
>
<RefreshIcon />
</IconButton>
</Tooltip>
{selectedRevision !== 'current' && (
<>
<Tooltip title="Restore this revision to editor">
<Button
size="small"
startIcon={<RestoreFromTrashIcon />}
onClick={restoreRevision}
disabled={loadingRevision}
>
Restore
</Button>
</Tooltip>
</>
)}
</Stack>
</Box>
</Box>
</Box>
</Box>
</Box>
<Box
sx={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
height: '100%',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
height: '100%',
gap: 1,
pt: 1,
width: '100%',
position: 'relative',
overflow: 'hidden',
}}
>
<Paper
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
position: 'relative',
maxWidth: '100%',
height: '100%',
overflow: 'hidden',
alignItems: 'center',
}}
>
<Box sx={{ display: 'flex', m: 0, p: 0 }}>
<Tabs value={tabValue} onChange={handleTabChange}>
<Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" />
<Tab
value="diff"
disabled={editContent === current?.resume}
icon={<DifferenceIcon />}
label="Changes"
/>
<Tab value="preview" icon={<PreviewIcon />} label="Preview" />
<Tab
disabled={tabValue !== 'preview'}
value="print"
icon={<PrintIcon />}
label="Print"
/>
<Tab value="regenerate" icon={<ModelTraining />} label="Regenerate" />
<Tab
value="undo"
disabled={editContent === resume?.resume}
icon={<UndoIcon />}
label="Revert"
/>
</Tabs>
</Box>
{status && (
<Box sx={{ mt: 1, mb: 1, width: '100%' }}>
<StatusBox>
{statusType && <StatusIcon type={statusType} />}
<Typography variant="body2" sx={{ ml: 1 }}>
{status || 'Processing...'}
</Typography>
</StatusBox>
{status && !error && <LinearProgress sx={{ mt: 1 }} />}
</Box>
)}
<Box
sx={{
display: 'flex',
flexGrow: 1,
width: '100%',
position: 'relative',
flexDirection: 'column',
m: 0,
p: 0,
gap: 0,
}}
>
{selectedRevision !== 'current' && (
<Alert severity="info" sx={{ width: '100%' }}>
You are viewing a previous version. Click &quot;Restore&quot; to load this content
into the editor.
</Alert>
)}
{isAIGenerated && (
<Alert severity="warning" sx={{ width: '100%' }}>
This resume was generated by AI and has not been manually edited. Review and then
selecte &apos;Save&apos;.
</Alert>
)}
</Box>
<Scrollable
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
minHeight: 0,
'& > *:not(.Scrollable)': {
flexShrink: 0,
},
position: 'relative',
}}
>
{tabValue === 'markdown' && (
<>
{selectedRevision === 'current' ? (
<BackstoryTextField
value={editContent}
onChange={(value): void => setEditContent(value)}
style={{
position: 'relative',
maxHeight: '100%',
height: '100%',
width: '100%',
display: 'flex',
minHeight: '100%',
flexGrow: 1,
flex: 1,
overflowY: 'auto',
fontFamily: 'monospace',
backgroundColor: '#fafafa',
fontSize: '12px',
}}
placeholder="Enter resume content..."
/>
) : (
<Box
sx={{
p: 2,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
backgroundColor: 'grey.50',
height: '100%',
overflow: 'auto',
}}
>
{loadingRevision ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<LinearProgress sx={{ flexGrow: 1 }} />
<Typography variant="body2">Loading revision...</Typography>
</Box>
) : (
<pre style={{ border: 0 }}>{resume?.resume}</pre>
)}
</Box>
)}
</>
)}
{tabValue === 'diff' && current && (
<DiffViewer
changeLog={changeLog}
original={{ content: current.resume || '', name: 'original' }}
modified={{
content: selectedRevision !== 'current' && resume ? resume.resume : editContent,
name: 'modified',
}}
/>
)}
{tabValue === 'preview' && resume && resume.candidate && (
<Box
className="document-container"
ref={printContentRef}
sx={currentStyle.contentStyle}
>
<ResumePreview resume={resume} selectedStyle={selectedStyle} />
</Box>
)}
</Scrollable>
</Paper>
<Scrollable
sx={{
flex: 1,
display: 'flex',
height: '100%',
overflowY: 'auto',
position: 'relative',
}}
>
<Paper
sx={{
p: 1,
flex: 1,
display: 'flex',
flexDirection: 'column',
position: 'relative',
}}
>
<Tabs value={jobTabValue} onChange={handleJobTabChange}>
{resume && resume.job !== undefined && (
<Tab value="job" icon={<WorkIcon />} label="Job" />
)}
<Tab value="chat" icon={<ModelTraining />} label="AI Edit" />
</Tabs>
{resume && resume.job !== undefined && jobTabValue === 'job' && (
<JobInfo
variant={'all'}
job={resume.job}
sx={{
m: 0,
p: 1,
backgroundColor: '#f8f0e0',
}}
/>
)}
{jobTabValue === 'chat' && resume && (
<ResumeChat
session={resume.id || ''}
resume={editContent}
onResumeChange={(prompt: string, newResume: string): void => {
console.log('onResumeChange:', prompt);
if (newResume !== editContent) {
setBackupContent(editContent);
setEditPrompt(prompt);
setEditContent(newResume);
setIsAIGenerated(true);
}
}}
sx={{
m: 1,
p: 1,
flexGrow: 1,
position: 'relative',
maxWidth: 'fit-content',
minWidth: '100%',
}}
/>
)}
</Paper>
</Scrollable>
</Box>
</Box>
{resume && (
<Box
sx={{ display: 'flex', flexDirection: 'row', gap: 1, alignItems: 'center', pl: 1, pt: 1 }}
>
{onClose && <Button onClick={handleClose}>Cancel</Button>}
<Button
onClick={() => {
handleSave();
}}
variant="contained"
disabled={saveDisabled}
startIcon={<SaveIcon />}
>
{saving ? 'Saving...' : 'Save'}
</Button>
<Typography variant="caption" display="block" color="text.secondary">
Last saved: {resume.updatedAt ? new Date(resume.updatedAt).toLocaleString() : 'N/A'}
</Typography>
</Box>
)}
</Box>
);
};
export { ResumeEdit };

View File

@ -1,11 +1,9 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
Box,
Typography,
SxProps,
CardHeader,
Button,
LinearProgress,
IconButton,
Tooltip,
Card,
@ -13,63 +11,21 @@ import {
Divider,
useTheme,
useMediaQuery,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Tabs,
Tab,
Paper,
FormControl,
Select,
MenuItem,
InputLabel,
Chip,
Alert,
Stack,
SelectChangeEvent,
} from '@mui/material';
import PrintIcon from '@mui/icons-material/Print';
// import SaveIcon from '@mui/icons-material/Save';
// import UndoIcon from '@mui/icons-material/Undo';
// import PreviewIcon from '@mui/icons-material/Preview';
import DifferenceIcon from '@mui/icons-material/Difference';
// import EditDocumentIcon from '@mui/icons-material/EditDocument';
import {
Delete as DeleteIcon,
Restore as RestoreIcon,
Save as SaveIcon,
Edit as EditIcon,
Description as DescriptionIcon,
Work as WorkIcon,
Person as PersonIcon,
Schedule as ScheduleIcon,
ModelTraining,
History as HistoryIcon,
RestoreFromTrash as RestoreFromTrashIcon,
Refresh as RefreshIcon,
PrecisionManufacturing,
// Language as WebsiteIcon,
Work as WorkIcon,
} from '@mui/icons-material';
import TuneIcon from '@mui/icons-material/Tune';
import PreviewIcon from '@mui/icons-material/Preview';
import EditDocumentIcon from '@mui/icons-material/EditDocument';
import { useReactToPrint } from 'react-to-print';
import { useAuth } from 'hooks/AuthContext';
import { useAppState } from 'hooks/GlobalContext';
import { StyledMarkdown } from 'components/StyledMarkdown';
import { Resume } from 'types/types';
import { BackstoryTextField } from 'components/BackstoryTextField';
import { JobInfo } from './JobInfo';
import { Scrollable } from 'components/Scrollable';
import * as Types from 'types/types';
import { StreamingOptions } from 'services/api-client';
import { StatusBox, StatusIcon } from './StatusIcon';
import { ResumeChat } from 'components/ResumeChat';
import { DiffViewer } from 'components/DiffViewer';
import { ResumePreview, resumeStyles } from './ResumePreview';
import { useNavigate } from 'react-router-dom';
interface ResumeInfoProps {
@ -80,16 +36,6 @@ interface ResumeInfoProps {
variant?: 'minimal' | 'small' | 'normal' | 'all' | null;
}
// Resume revision interface
interface ResumeRevision {
revisionId: string;
revisionTimestamp: string;
updatedAt: string;
createdAt: string;
candidateId: string;
jobId: string;
}
const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const { setSnack } = useAppState();
const navigate = useNavigate();
@ -101,31 +47,6 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const isAdmin = user?.isAdmin;
const [activeResume, setActiveResume] = useState<Resume>({ ...resume });
const [deleted, setDeleted] = useState<boolean>(false);
const [editDialogOpen, setEditDialogOpen] = useState<boolean>(false);
const [editContent, setEditContent] = useState<string>('');
const [editSystemPrompt, setEditSystemPrompt] = useState<string>('');
const [editPrompt, setEditPrompt] = useState<string>('');
const [lastRevision, setLastRevision] = useState<Resume | null>(null);
const [saving, setSaving] = useState<boolean>(false);
const [tabValue, setTabValue] = useState('markdown');
const [jobTabValue, setJobTabValue] = useState('chat');
const [status, setStatus] = useState<string>('');
const [statusType, setStatusType] = useState<Types.ApiActivityType | null>(null);
const [error, setError] = useState<Types.ChatMessageError | null>(null);
const [selectedStyle, setSelectedStyle] = useState<string>('corporate');
// New revision-related state
const [revisions, setRevisions] = useState<ResumeRevision[]>([]);
const [selectedRevision, setSelectedRevision] = useState<string>('current');
const [loadingRevisions, setLoadingRevisions] = useState<boolean>(false);
const [loadingRevision, setLoadingRevision] = useState<boolean>(false);
const [revisionContent, setRevisionContent] = useState<string>('');
const printContentRef = useRef<HTMLDivElement>(null);
const reactToPrintFn = useReactToPrint({
contentRef: printContentRef,
pageStyle: '@page { margin: 8mm !important; }',
});
useEffect(() => {
if (resume && resume.id !== activeResume?.id) {
@ -133,90 +54,6 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
}
}, [resume, activeResume]);
// Load revisions when dialog opens
useEffect(() => {
if (editDialogOpen && activeResume.id) {
loadResumeRevisions();
}
}, [editDialogOpen, activeResume.id]);
const currentStyle = useMemo(() => {
return resumeStyles[selectedStyle];
}, [selectedStyle]);
// Load resume revisions
const loadResumeRevisions = async (): Promise<void> => {
if (!activeResume.id) return;
setLoadingRevisions(true);
try {
const response = await apiClient.getResumeRevisions(activeResume.id);
console.log('Loaded revisions:', response.revisions);
setRevisions(response.revisions || []);
} catch (error) {
if (error instanceof Error) {
console.error('Failed to load revisions:', error);
setSnack(`Failed to load resume revisions: ${error.message || 'Unknown error'}`);
}
} finally {
setLoadingRevisions(false);
}
};
// Load specific revision content
const loadRevisionContent = async (revisionId: string): Promise<void> => {
if (!activeResume.id || revisionId === 'current') {
setRevisionContent(editContent);
return;
}
setLoadingRevision(true);
try {
const response = await apiClient.getResumeRevision(activeResume.id, revisionId);
setRevisionContent(response.revision.resume || '');
} catch (error) {
console.error('Failed to load revision:', error);
setSnack('Failed to load revision content.');
} finally {
setLoadingRevision(false);
}
};
// Handle revision selection
const handleRevisionChange = async (event: SelectChangeEvent<string>): Promise<void> => {
const newRevisionId = event.target.value;
if (selectedRevision === 'current') {
setLastRevision(activeResume);
}
setSelectedRevision(newRevisionId);
await loadRevisionContent(newRevisionId);
};
// Restore revision to editor
const restoreRevision = (): void => {
setEditContent(revisionContent);
setSelectedRevision('current');
setLastRevision(null);
setSnack('Revision restored to editor.');
};
// Format timestamp for display
const formatRevisionTimestamp = (timestamp: string): string => {
try {
const date = new Date(timestamp);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return timestamp;
}
};
// Rest of the existing methods...
const deleteResume = async (id: string | undefined): Promise<void> => {
if (id) {
try {
@ -233,40 +70,8 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
setActiveResume({ ...resume });
};
const handleSave = async (prompt = ''): Promise<void> => {
setSaving(true);
try {
const resumeUpdate = {
...activeResume,
resume: editContent,
systemPrompt: editSystemPrompt,
prompt: prompt || editPrompt,
};
const result = await apiClient.updateResume(resumeUpdate);
console.log('Resume updated:', result);
const updatedResume = {
...activeResume,
...result,
};
setActiveResume(updatedResume);
setSnack('Resume updated successfully.');
// Reload revisions to include the new version
await loadResumeRevisions();
} catch (error) {
setSnack('Failed to update resume.');
} finally {
setSaving(false);
}
};
const handleEditOpen = (): void => {
setEditContent(activeResume.resume);
setEditSystemPrompt(activeResume.systemPrompt || '');
setEditPrompt(activeResume.prompt || '');
setSelectedRevision('current');
setLastRevision(null);
setRevisionContent(activeResume.resume);
setEditDialogOpen(true);
navigate(`/candidate/resumes/${activeResume.id}/edit`);
};
if (!resume) {
@ -289,66 +94,6 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
}
};
const generateResumeHandlers: StreamingOptions<Types.ChatMessageResume> = {
onMessage: (message: Types.ChatMessageResume) => {
const resume: Resume = message.resume;
setEditSystemPrompt(resume.systemPrompt || '');
setEditPrompt(resume.prompt || '');
setEditContent(resume.resume);
setActiveResume({ ...resume });
setStatus('');
setSnack('Resume generation completed successfully.');
},
onStreaming: (chunk: Types.ChatMessageStreaming) => {
if (status === '') {
setStatus('Generating resume...');
setStatusType('generating');
}
setEditContent(chunk.content);
},
onStatus: (status: Types.ChatMessageStatus) => {
console.log('status:', status.content);
setStatusType(status.activity);
setStatus(status.content);
},
onError: (error: Types.ChatMessageError) => {
console.log('error:', error);
setStatusType(null);
setStatus(error.content);
setError(error);
},
};
const generateResume = async (): Promise<void> => {
setStatusType('thinking');
setStatus('Starting resume generation...');
setActiveResume({ ...activeResume, resume: '' });
const request = await apiClient.generateResume(
activeResume.candidateId || '',
activeResume.jobId || '',
generateResumeHandlers
);
await request.promise;
};
const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => {
if (newValue === 'print') {
console.log('Printing resume...');
reactToPrintFn();
return;
}
if (newValue === 'regenerate') {
setSnack('Regenerating resume...');
generateResume();
return;
}
setTabValue(newValue);
};
const handleJobTabChange = (event: React.SyntheticEvent, newValue: string): void => {
setJobTabValue(newValue);
};
return (
<Box
sx={{
@ -467,7 +212,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
avatar={<DescriptionIcon color="success" />}
sx={{ p: 0, pb: 1 }}
action={
<Tooltip title="Edit Resume Content">
<Tooltip title="Edit Resume">
<IconButton size="small" onClick={handleEditOpen}>
<EditIcon />
</IconButton>
@ -533,416 +278,8 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
</IconButton>
</Tooltip>
</Box>
{saving && (
<Box sx={{ mt: 1 }}>
<LinearProgress />
<Typography variant="caption" sx={{ mt: 0.5 }}>
Saving resume...
</Typography>
</Box>
)}
</Box>
)}
{/* Edit Dialog */}
<Dialog
open={editDialogOpen}
onClose={(): void => {
setEditDialogOpen(false);
}}
maxWidth="lg"
fullWidth
disableEscapeKeyDown={true}
fullScreen={true}
>
<DialogTitle>
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, m: 0, p: 0 }}>
Edit Resume Content
<Typography variant="caption" display="block" color="text.secondary">
Resume for {activeResume.candidate?.fullName || activeResume.candidateId},{' '}
{activeResume.job?.title || 'No Job Title Assigned'},{' '}
{activeResume.job?.company || 'No Company Assigned'}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
Resume ID: # {activeResume.id}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
Last saved:{' '}
{activeResume.updatedAt ? new Date(activeResume.updatedAt).toLocaleString() : 'N/A'}
</Typography>
</Box>
<Box
sx={{
display: 'flex',
flexGrow: 1,
flexDirection: 'row',
gap: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{/* Style Selector */}
<FormControl size="small" sx={{ minWidth: 'min-content' }}>
<InputLabel id="resume-style-label">Resume Style</InputLabel>
<Select
labelId="resume-style-label"
value={selectedStyle}
onChange={e => setSelectedStyle(e.target.value)}
label="Resume Style"
>
{Object.entries(resumeStyles).map(([key, style]) => (
<MenuItem key={key} value={key}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Typography variant="body2" fontWeight="bold">
{style.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{style.description}
</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
</Box>
<Tabs value={tabValue} onChange={handleTabChange}>
<Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" />
{lastRevision && <Tab value="diff" icon={<DifferenceIcon />} label="Changes" />}
<Tab value="preview" icon={<PreviewIcon />} label="Preview" />
<Tab value="print" icon={<PrintIcon />} label="Print" />
{activeResume.systemPrompt && (
<Tab value="context" icon={<TuneIcon />} label="Context" />
)}
<Tab value="regenerate" icon={<ModelTraining />} label="Regenerate" />
</Tabs>
</Box>
</Box>
</DialogTitle>
<DialogContent
sx={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
height: '100%',
gap: 1,
pt: 1,
width: '100%',
position: 'relative',
overflow: 'hidden',
}}
>
<Paper
sx={{
p: 1,
flex: 1,
display: 'flex',
flexDirection: 'column',
position: 'relative',
maxWidth: '100%',
height: '100%',
overflow: 'hidden',
}}
>
{status && (
<Box sx={{ mt: 0, mb: 1 }}>
<StatusBox>
{statusType && <StatusIcon type={statusType} />}
<Typography variant="body2" sx={{ ml: 1 }}>
{status || 'Processing...'}
</Typography>
</StatusBox>
{status && !error && <LinearProgress sx={{ mt: 1 }} />}
</Box>
)}
{/* Revision History Dropdown for Markdown Tab */}
{tabValue === 'markdown' && (
<Box sx={{ mb: 2 }}>
<Stack direction="row" spacing={2} alignItems="center">
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel id="revision-select-label">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<HistoryIcon fontSize="small" />
Version History
</Box>
</InputLabel>
<Select
labelId="revision-select-label"
value={selectedRevision}
onChange={handleRevisionChange}
label="Version History"
disabled={loadingRevisions}
>
<MenuItem value="current">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label="CURRENT" size="small" color="primary" variant="outlined" />
<Typography variant="body2">Current Version</Typography>
</Box>
</MenuItem>
{revisions.map(revision => (
<MenuItem key={revision.revisionId} value={revision.revisionId}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Typography variant="body2">
{formatRevisionTimestamp(revision.revisionTimestamp)}
</Typography>
<Typography variant="caption" color="text.secondary">
Updated: {formatRevisionTimestamp(revision.updatedAt)}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: '0.65rem' }}
>
ID: {revision.revisionId.substring(0, 8)}...
</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
<Tooltip title="Refresh Revisions">
<IconButton
size="small"
onClick={loadResumeRevisions}
disabled={loadingRevisions}
>
<RefreshIcon />
</IconButton>
</Tooltip>
{selectedRevision !== 'current' && (
<>
<Tooltip title="See changes against current version">
<Button
size="small"
startIcon={<PrecisionManufacturing />}
onClick={() => setTabValue('diff')}
disabled={loadingRevision}
>
Changes
</Button>
</Tooltip>
<Tooltip title="Restore this revision to editor">
<Button
size="small"
startIcon={<RestoreFromTrashIcon />}
onClick={restoreRevision}
disabled={loadingRevision}
>
Restore
</Button>
</Tooltip>
</>
)}
</Stack>
{selectedRevision !== 'current' && (
<Alert severity="info" sx={{ mt: 1 }}>
You are viewing a previous version. Click &quot;Restore&quot; to load this
content into the editor.
</Alert>
)}
</Box>
)}
<Scrollable
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
minHeight: 0,
'& > *:not(.Scrollable)': {
flexShrink: 0,
},
position: 'relative',
}}
>
{tabValue === 'markdown' && (
<>
{selectedRevision === 'current' ? (
<BackstoryTextField
value={editContent}
onChange={(value): void => setEditContent(value)}
style={{
position: 'relative',
maxHeight: '100%',
height: '100%',
width: '100%',
display: 'flex',
minHeight: '100%',
flexGrow: 1,
flex: 1,
overflowY: 'auto',
fontFamily: 'monospace',
backgroundColor: '#fafafa',
fontSize: '12px',
}}
placeholder="Enter resume content..."
/>
) : (
<Box
sx={{
p: 2,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
backgroundColor: 'grey.50',
height: '100%',
overflow: 'auto',
}}
>
{loadingRevision ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<LinearProgress sx={{ flexGrow: 1 }} />
<Typography variant="body2">Loading revision...</Typography>
</Box>
) : (
<pre style={{ border: 0 }}>{revisionContent}</pre>
)}
</Box>
)}
</>
)}
{tabValue === 'context' && (
<StyledMarkdown content={activeResume.systemPrompt || ''} />
)}
{tabValue === 'prompt' && (
<BackstoryTextField
value={editPrompt}
onChange={(value): void => setEditPrompt(value)}
style={{
position: 'relative',
maxHeight: '100%',
height: '100%',
width: '100%',
display: 'flex',
minHeight: '100%',
flexGrow: 1,
flex: 1,
overflowY: 'auto',
}}
placeholder="Edit prompt..."
/>
)}
{tabValue === 'diff' && lastRevision && (
<DiffViewer
changeLog={activeResume.prompt || ''}
original={{ content: revisionContent, name: 'original' }}
modified={{ content: lastRevision.resume, name: 'modified' }}
/>
)}
{tabValue === 'preview' && activeResume.candidate && (
<Box
className="document-container"
ref={printContentRef}
sx={currentStyle.contentStyle}
>
<ResumePreview resume={activeResume} selectedStyle={selectedStyle} />
</Box>
)}
</Scrollable>
</Paper>
<Scrollable
sx={{
flex: 1,
display: 'flex',
height: '100%',
overflowY: 'auto',
position: 'relative',
}}
>
<Paper
sx={{
p: 1,
flex: 1,
display: 'flex',
flexDirection: 'column',
position: 'relative',
}}
>
<Tabs value={jobTabValue} onChange={handleJobTabChange}>
{activeResume.job !== undefined && (
<Tab value="job" icon={<WorkIcon />} label="Job" />
)}
<Tab value="chat" icon={<ModelTraining />} label="AI Edit" />
</Tabs>
{activeResume.job !== undefined && jobTabValue === 'job' && (
<JobInfo
variant={'all'}
job={activeResume.job}
sx={{
m: 0,
p: 1,
backgroundColor: '#f8f0e0',
}}
/>
)}
{jobTabValue === 'chat' && (
<ResumeChat
session={activeResume.id || ''}
resume={editContent}
onResumeChange={(prompt: string, newResume: string): void => {
if (newResume !== editContent) {
handleSave(prompt).then(() => {
setEditContent(newResume);
setLastRevision(activeResume);
setActiveResume({ ...activeResume, prompt, resume: newResume });
});
}
}}
sx={{
m: 1,
p: 1,
flexGrow: 1,
position: 'relative',
maxWidth: 'fit-content',
minWidth: '100%',
}}
/>
)}
</Paper>
</Scrollable>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={(): void => setEditDialogOpen(false)}>Cancel</Button>
<Button
onClick={() => {
handleSave();
}}
variant="contained"
disabled={saving}
startIcon={<SaveIcon />}
>
{saving ? 'Saving...' : 'Save'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};

View File

@ -153,78 +153,6 @@ const generateResumeStyles = (): Record<string, ResumeStyle> => {
background: '#ffffff',
},
},
creative: {
name: 'Creative',
description: 'Colorful, unique design with personality',
headerStyle: {
display: 'flex',
flexDirection: 'row',
fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: '#ffffff',
padding: 2.5,
borderRadius: 1.5,
marginBottom: 3,
} as SxProps<Theme>,
footerStyle: {
fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: '#ffffff',
paddingTop: 2,
borderRadius: 1.5,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase',
alignContent: 'center',
fontSize: '0.8rem',
pb: 2,
mb: 2,
} as SxProps<Theme>,
contentStyle: {
fontFamily: '"Open Sans", Arial, sans-serif',
lineHeight: 1.6,
color: '#444444',
} as SxProps<Theme>,
markdownStyle: {
fontFamily: '"Open Sans", Arial, sans-serif',
'& h1, & h2, & h3': {
fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif',
color: '#667eea',
fontWeight: 600,
marginBottom: 2,
},
'& h1': {
fontSize: '1.5rem',
},
'& h2': {
fontSize: '1.25rem',
},
'& h3': {
fontSize: '1.1rem',
},
'& p, & li': {
lineHeight: 1.6,
marginBottom: 1,
color: '#444444',
},
'& strong': {
color: '#764ba2',
fontWeight: 600,
},
'& ul': {
paddingLeft: 3,
},
} as SxProps<Theme>,
color: {
primary: '#667eea',
secondary: '#764ba2',
accent: '#f093fb',
text: '#444444',
background: '#ffffff',
},
},
corporate: {
name: 'Corporate',
description: 'Formal, structured business format',
@ -444,7 +372,7 @@ const StyledFooter: React.FC<StyledFooterProps> = ({ resume, style, isMobile })
sx={{
...style.footerStyle,
color: style.color.secondary,
fontSize: isMobile ? '0.6rem' : '1rem',
fontSize: isMobile ? '0.6rem' : '0.8rem',
}}
>
Dive deeper into my qualifications at Backstory...
@ -455,7 +383,9 @@ const StyledFooter: React.FC<StyledFooterProps> = ({ resume, style, isMobile })
className="qr-code"
sx={{ display: 'flex', mt: 1, mb: 1 }}
/>
{window.location.protocol}://{window.location.host}/chat/{resume.id}
{window.location.protocol}
{'//'}
{window.location.host}/chat/{resume.id}
</Box>
<Box sx={{ pb: 2 }}>&nbsp;</Box>
</>

View File

@ -40,6 +40,7 @@ import { useAppState, useSelectedResume } from 'hooks/GlobalContext'; // Assumin
import { useNavigate, useParams } from 'react-router-dom';
import { Resume } from 'types/types';
import { formatDate } from 'utils/formatDate';
import { ResumeEdit } from './ResumeEdit';
type SortField = 'updatedAt' | 'createdAt' | 'candidateId' | 'jobId';
type SortOrder = 'asc' | 'desc';
@ -64,6 +65,7 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
const { resumeId, mode = 'view' } = useParams<{ resumeId?: string; mode?: string }>();
const { apiClient } = useAuth();
const { selectedResume, setSelectedResume } = useSelectedResume(); // Assuming similar context
@ -74,7 +76,6 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
const [mobileDialogOpen, setMobileDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [filteredResumes, setFilteredResumes] = useState<Resume[]>([]);
const { resumeId } = useParams<{ resumeId?: string }>();
useEffect(() => {
if (resumeId) {
@ -103,19 +104,18 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
const getResumes = async (): Promise<void> => {
try {
let results;
let result: Resume[];
if (candidateId) {
results = await apiClient.getResumesByCandidate(candidateId);
result = await apiClient.getResumesByCandidate(candidateId);
} else if (jobId) {
results = await apiClient.getResumesByJob(jobId);
result = await apiClient.getResumesByJob(jobId);
} else {
results = await apiClient.getResumes();
result = await apiClient.getResumes();
}
const resumesData: Resume[] = results.resumes || [];
setResumes(resumesData);
setFilteredResumes(resumesData);
setResumes(result);
setFilteredResumes(result);
} catch (err) {
console.error('Failed to load resumes:', err);
setSnack('Failed to load resumes: ' + err, 'error');
@ -521,6 +521,22 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
</Box>
);
const handleSave = (updatedResume: Resume): void => {
setSelectedResume(updatedResume);
};
if (mode === 'edit' && selectedResume) {
return (
<ResumeEdit
onClose={() => {
navigate(window.location.pathname.replace('/edit', ''));
}}
resumeId={selectedResume.id}
onSave={handleSave}
/>
);
}
if (isMobile) {
return (
<Box

View File

@ -136,7 +136,7 @@ export const navigationConfig: NavigationConfig = {
{
id: 'explore-resumes',
label: 'Resumes',
path: '/candidate/resumes/:resumeId?',
path: '/candidate/resumes/:resumeId?/:mode?',
icon: <EditDocumentIcon />,
component: <ResumeViewer />,
variant: 'fullWidth',

View File

@ -12,6 +12,7 @@ import * as Types from 'types/types';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from 'hooks/AuthContext';
import { SetSnackType, SeverityType, Snack } from 'components/Snack';
import { ApiErrorDetails } from 'services/api-client';
// ============================
// Storage Keys
@ -411,8 +412,8 @@ const AppStateContext = createContext<AppStateContextType | null>(null);
export function AppStateProvider({ children }: { children: React.ReactNode }): JSX.Element {
const appState = useAppStateLogic();
const { apiClient } = useAuth();
const snackRef = useRef<{ setSnack: SetSnackType }>(null);
// Global UI components
appState.setSnack = useCallback(
(message: string, severity?: SeverityType) => {
@ -421,6 +422,27 @@ export function AppStateProvider({ children }: { children: React.ReactNode }): J
[snackRef]
);
apiClient.setErrorCallback((error: ApiErrorDetails) => {
console.log('API Error:', error);
// Handle authorization failures
if (error.status === 401) {
// Token expired or invalid - redirect to login
appState.setSnack('Authentication token expired or invalid. Please login again.', 'error');
} else if (error.status === 403) {
// Forbidden - show access denied message
appState.setSnack('Access denied', 'error');
} else if (error.status >= 500) {
// Server error - show generic error message
appState.setSnack('Server error. Please try again later.', 'error');
} else {
appState.setSnack(
`Error: ${('message' in error && error.message) || 'An unexpected error occurred.'}`,
'error'
);
}
});
return (
<AppStateContext.Provider value={appState}>
{children}

View File

@ -27,9 +27,9 @@ import { useSelectedCandidate, useSelectedJob } from 'hooks/GlobalContext';
import { CandidatePicker } from 'components/ui/CandidatePicker';
import { JobCreator } from 'components/JobCreator';
import { LoginRestricted } from 'components/ui/LoginRestricted';
import { ResumeGenerator } from 'components/ResumeGenerator';
import { JobsView } from 'components/ui/JobsView';
import { useNavigate, useParams } from 'react-router-dom';
import { ResumeEdit } from 'components/ui/ResumeEdit';
function WorkAddIcon(): JSX.Element {
return (
@ -403,13 +403,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
return <></>;
}
return (
<ResumeGenerator
job={analysisState.job}
candidate={analysisState.candidate}
skills={analysisState.analysis.skills}
/>
);
return <ResumeEdit />;
};
// Don't render until initialized to avoid flash of incorrect state
@ -652,7 +646,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
},
}}
>
<Box sx={{ mb: 1, justifyContent: 'center' }}>{!isMobile && 'Generate '}Resume</Box>
<Box sx={{ mb: 1, justifyContent: 'center' }}>{!isMobile && 'Edit '}Resume</Box>
</Box>
</StepLabel>
</Step>

View File

@ -24,7 +24,7 @@ import {
InputAdornment,
} from '@mui/material';
import { Visibility, VisibilityOff } from '@mui/icons-material';
import { ApiClient, RegistrationResponse } from 'services/api-client';
import { RegistrationResponse } from 'services/api-client';
import { RegistrationSuccessDialog } from 'components/EmailVerificationComponents';
import { useAuth } from 'hooks/AuthContext';
import { useNavigate } from 'react-router-dom';
@ -390,6 +390,7 @@ const CandidateRegistrationForm = (): JSX.Element => {
// Employer Registration Form
const EmployerRegistrationForm = (): JSX.Element => {
const { apiClient } = useAuth();
const [formData, setFormData] = useState({
email: '',
username: '',
@ -412,8 +413,6 @@ const EmployerRegistrationForm = (): JSX.Element => {
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const apiClient = new ApiClient();
const industryOptions = [
'Technology',
'Healthcare',

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
// Generated TypeScript types from Pydantic models
// Source: src/backend/models.py
// Generated on: 2025-07-16T23:28:16.752031
// Generated on: 2025-07-17T19:28:19.417396
// DO NOT EDIT MANUALLY - This file is auto-generated
// ============================
@ -1006,6 +1006,7 @@ export interface Resume {
updatedAt?: Date;
job?: Job;
candidate?: Candidate;
aiGenerated?: boolean;
}
export interface ResumeMessage {

View File

@ -536,7 +536,7 @@ Content: {content}
umap_embedding_2d=umap_2d,
umap_embedding_3d=umap_3d,
)
if rag_metadata.query_embedding:
if rag_metadata.query_embedding and len(rag_metadata.query_embedding) > 0:
logger.info(f"Len of embedding: {len(rag_metadata.query_embedding)}")
else:
logger.warning(f"No query embedding found in RAG results; {query_embedding}")

View File

@ -126,6 +126,7 @@ class EditResume(Agent):
content = ""
start_time = time.perf_counter()
response = None
async for response in llm.chat_stream(
model=model,
messages=messages,
@ -209,10 +210,10 @@ You are a professional copy editor. Your task is to edit and enhance the provide
- DO NOT estimate, approximate, or infer numerical data
**RESPONSE FORMAT RULES:**
- IF the user asks a question (contains question words like "what", "how", "why", "when", "where", "which", "who" OR ends with "?"), then:
- IF the user prompt contains question words like "what", "how", "why", "when", "where", "which", "who" OR ends with "?", then it is a QUESTION:
- Start response with exactly "ANSWER: " (including the space)
- Provide only the answer, not the full resume
- IF the user requests edits/changes to the resume, then:
- IF the user prompt IS NOT A QUESTION:
- Return the complete edited resume without any prefix
- Do not include "ANSWER:" anywhere in the response
@ -231,9 +232,9 @@ You are a professional copy editor. Your task is to edit and enhance the provide
- Example of UNACCEPTABLE impact: "Streamlined resume analysis processes with a 40% reduction in manual review time" (unless that 40% is stated in the original content)
If the user asks a question about the resume, state "ANSWER:" and then provide a brief answer based only on the content of the resume or the context provided.
Start the response with "ANSWER:" if an answer is being rovided and not the full resume.
Start the response with "ANSWER:" if an answer is being provided and not the full resume.
If the user did not ask a question, return the entire resume with the requested edits applied while maintaining the current formatting.
If the user did not ask a question, always return the entire resume with the requested edits applied while maintaining the current formatting.
"""
logger.info(f"Generating edit_resume response to: {prompt}\ncontext: {extra_context}")

View File

@ -83,38 +83,40 @@ direct duplication from the assessment.
Do not provide header information like name, email, or phone number in the resume, as that information will be added later.
## CANDIDATE INFORMATION:
Name: {self.user.full_name}
Email: {self.user.email or "N/A"}
Phone: {self.user.phone or "N/A"}
{f"Location: {json.dumps(self.user.location.model_dump())}" if self.user.location else ""}
* Name: {self.user.full_name}
* Email: {self.user.email or "N/A"}
* Phone: {self.user.phone or "N/A"}
{f"* Location: {json.dumps(self.user.location.model_dump(exclude_none=True, exclude_unset=True))}" if self.user.location else ""}
## SKILL ASSESSMENT RESULTS:
"""
if len(skills_by_strength[SkillStrength.STRONG]):
system_prompt += f"""\
### Strong Skills (prominent in resume):
* {".\n* ".join(skills_by_strength[SkillStrength.STRONG])}
"""
if len(skills_by_strength[SkillStrength.MODERATE]):
system_prompt += f"""\
### Moderate Skills (demonstrated in resume):
* {".\n* ".join(skills_by_strength[SkillStrength.MODERATE])}
"""
if len(skills_by_strength[SkillStrength.WEAK]):
system_prompt += f"""\
### Weaker Skills (mentioned or implied):
* {".\n* ".join(skills_by_strength[SkillStrength.WEAK])}
"""
system_prompt += """\
system_prompt += """
## EXPERIENCE EVIDENCE:
"""
# Add experience evidence by source/position

View File

@ -4,6 +4,25 @@ import logging
import defines
class RelativePathFormatter(logging.Formatter):
def __init__(self, fmt=None, datefmt=None, remove_prefix=None):
super().__init__(fmt, datefmt)
self.remove_prefix = remove_prefix or os.getcwd()
# Ensure the prefix ends with a separator
if not self.remove_prefix.endswith(os.sep):
self.remove_prefix += os.sep
def format(self, record):
# Make a copy of the record to avoid modifying the original
record = logging.makeLogRecord(record.__dict__)
# Remove the prefix from pathname
if record.pathname.startswith(self.remove_prefix):
record.pathname = record.pathname[len(self.remove_prefix) :]
return super().format(record)
def _setup_logging(level=defines.logging_level) -> logging.Logger:
os.environ["TORCH_CPP_LOG_LEVEL"] = "ERROR"
warnings.filterwarnings("ignore", message="Overriding a previously registered kernel")
@ -18,8 +37,8 @@ def _setup_logging(level=defines.logging_level) -> logging.Logger:
raise ValueError(f"Invalid log level: {level}")
# Create a custom formatter
formatter = logging.Formatter(
fmt="%(levelname)s - %(filename)s:%(lineno)d - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
formatter = RelativePathFormatter(
fmt="%(levelname)s - %(pathname)s:%(lineno)d - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
)
# Create a handler (e.g., StreamHandler for console output)

View File

@ -1209,6 +1209,7 @@ class Resume(BaseModel):
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("updatedAt"))
job: Optional[Job] = None
candidate: Optional[Candidate] = None
ai_generated: Optional[bool] = Field(default=True, alias=str("aiGenerated"))
model_config = ConfigDict(populate_by_name=True)
class ChatMessageResume(ChatMessageUser):

View File

@ -1985,9 +1985,40 @@ async def generate_resume(
yield error_message
return
resume: ChatMessageResume = final_message
resume.resume.job_id = job.id
yield resume
resume_message: ChatMessageResume = final_message
resume_message.resume.ai_generated = True
resume_message.resume.job_id = job.id
resume_message.resume.candidate_id = candidate.id
current_resumes = await database.get_resumes_by_job(current_user.id, job.id)
current_resume = current_resumes[0] if len(current_resumes) != 0 else None
if current_resume:
logger.info(f"💾 Updating resume from candidate {candidate.id} and job {job.id}")
resume_message.resume.id = current_resume.id
current_resume.resume = resume_message.resume.resume
updates = current_resume.model_dump()
if current_resume.resume == resume_message.resume.resume:
logger.info(f"✅ No changes detected in resume for candidate {candidate.id} and job {job.id}")
resume_message.resume.job = job
resume_message.resume.candidate = candidate
yield resume_message
return
updated_resume_data = await database.update_resume(current_user.id, current_resume.id, updates)
if updated_resume_data:
updated_resume = Resume.model_validate(updated_resume_data) if updated_resume_data else None
if updated_resume:
logger.info(f"✅ Resume {updated_resume.id} updated successfully for user {current_user.id}")
resume_message.resume = updated_resume
else:
success = await database.set_resume(current_user.id, resume_message.resume.model_dump())
if not success:
error_message = ChatMessageError(session_id=MOCK_UUID, content="Failed to save resume to database")
yield error_message
return
resume_message.resume.job = job
resume_message.resume.candidate = candidate
yield resume_message
return
try:

View File

@ -3,7 +3,7 @@ Resume Routes
"""
import json
from typing import List
from typing import List, Optional
import uuid
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query
@ -13,7 +13,7 @@ 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, get_current_user_or_guest
from utils.dependencies import get_database, get_current_user_or_guest
from utils.responses import create_success_response
# Create router for authentication endpoints
@ -23,7 +23,7 @@ router = APIRouter(prefix="/resumes", tags=["resumes"])
@router.post("")
async def create_candidate_resume(
resume: Resume = Body(...),
current_user=Depends(get_current_user),
current_user=Depends(get_current_user_or_guest),
database: RedisDatabase = Depends(get_database),
):
"""Create a new resume for a candidate/job combination"""
@ -122,7 +122,9 @@ async def create_candidate_resume(
@router.get("")
async def get_user_resumes(current_user=Depends(get_current_user), database: RedisDatabase = Depends(get_database)):
async def get_user_resumes(
current_user=Depends(get_current_user_or_guest), database: RedisDatabase = Depends(get_database)
):
"""Get all resumes for the current user"""
try:
resumes_data = await database.get_all_resumes_for_user(current_user.id)
@ -170,7 +172,7 @@ async def get_resume(
@router.delete("/{resume_id}")
async def delete_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),
):
"""Delete a specific resume"""
@ -179,7 +181,7 @@ async def delete_resume(
if not success:
raise HTTPException(status_code=404, detail="Resume not found")
return {"success": True, "message": f"Resume {resume_id} deleted successfully"}
return create_success_response({"message": f"Resume {resume_id} deleted successfully"})
except HTTPException:
raise
except Exception as e:
@ -188,15 +190,28 @@ async def delete_resume(
@router.get("/candidate/{candidate_id}")
@router.get("/candidate/{candidate_id}/{job_id}")
async def get_resumes_by_candidate(
candidate_id: str = Path(..., description="ID of the candidate"),
current_user=Depends(get_current_user),
job_id: Optional[str] = Path(..., description="ID of the job"),
current_user=Depends(get_current_user_or_guest),
database: RedisDatabase = Depends(get_database),
):
"""Get all resumes for a specific candidate"""
try:
resumes = await database.get_resumes_by_candidate(current_user.id, candidate_id)
return {"success": True, "candidate_id": candidate_id, "resumes": resumes, "count": len(resumes)}
if job_id:
for resume in resumes:
logger.info(f"Checking resume {resume.id} for job {job_id}: {resume.job_id}")
resumes = [resume for resume in resumes if resume.job_id == job_id]
for resume in resumes:
job_data = await database.get_job(resume.job_id)
if job_data:
resume.job = Job.model_validate(job_data)
candidate_data = await database.get_candidate(resume.candidate_id)
if candidate_data:
resume.candidate = Candidate.model_validate(candidate_data)
return create_success_response({"resumes": [resume.model_dump(by_alias=True) for resume in resumes]})
except Exception as e:
logger.error(f"❌ Error retrieving resumes for candidate {candidate_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to retrieve candidate resumes")
@ -205,13 +220,13 @@ async def get_resumes_by_candidate(
@router.get("/job/{job_id}")
async def get_resumes_by_job(
job_id: str = Path(..., description="ID of the job"),
current_user=Depends(get_current_user),
current_user=Depends(get_current_user_or_guest),
database: RedisDatabase = Depends(get_database),
):
"""Get all resumes for a specific job"""
try:
resumes = await database.get_resumes_by_job(current_user.id, job_id)
return {"success": True, "job_id": job_id, "resumes": resumes, "count": len(resumes)}
return create_success_response({"job_id": job_id, "resumes": resumes, "count": len(resumes)})
except Exception as e:
logger.error(f"❌ Error retrieving resumes for job {job_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to retrieve job resumes")
@ -220,13 +235,13 @@ async def get_resumes_by_job(
@router.get("/search")
async def search_resumes(
q: str = Query(..., description="Search query"),
current_user=Depends(get_current_user),
current_user=Depends(get_current_user_or_guest),
database: RedisDatabase = Depends(get_database),
):
"""Search resumes by content"""
try:
resumes = await database.search_resumes_for_user(current_user.id, q)
return {"success": True, "query": q, "resumes": resumes, "count": len(resumes)}
return create_success_response({"query": q, "resumes": resumes, "count": len(resumes)})
except Exception as e:
logger.error(f"❌ Error searching resumes for user {current_user.id}: {e}")
raise HTTPException(status_code=500, detail="Failed to search resumes")
@ -234,12 +249,12 @@ async def search_resumes(
@router.get("/stats")
async def get_resume_statistics(
current_user=Depends(get_current_user), database: RedisDatabase = Depends(get_database)
current_user=Depends(get_current_user_or_guest), database: RedisDatabase = Depends(get_database)
):
"""Get resume statistics for the current user"""
try:
stats = await database.get_resume_statistics(current_user.id)
return {"success": True, "statistics": stats}
return create_success_response({"statistics": stats})
except Exception as e:
logger.error(f"❌ Error retrieving resume statistics for user {current_user.id}: {e}")
raise HTTPException(status_code=500, detail="Failed to retrieve resume statistics")
@ -248,11 +263,19 @@ async def get_resume_statistics(
@router.patch("")
async def update_resume(
resume: Resume = Body(...),
current_user=Depends(get_current_user),
current_user=Depends(get_current_user_or_guest),
database: RedisDatabase = Depends(get_database),
):
"""Update the content of a specific resume"""
try:
current_resume = await database.get_resume(resume.id)
if not current_resume:
logger.warning(f"⚠️ Resume {resume.id} not found for user {current_user.id}")
raise HTTPException(status_code=404, detail="Resume not found")
if current_resume.resume == resume.resume and resume.ai_generated is False:
logger.info(f"✅ No changes detected in resume {resume.id} for user {current_user.id}")
return create_success_response(resume.model_dump(by_alias=True))
resume.ai_generated = False # Ensure we mark it as user-generated
updates = resume.model_dump()
updated_resume_data = await database.update_resume(current_user.id, resume.id, updates)
if not updated_resume_data:
@ -279,7 +302,7 @@ async def update_resume(
@router.get("/{resume_id}/revisions")
async def get_resume_revisions(
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 list of all revisions for a resume with metadata"""
@ -295,7 +318,7 @@ async def get_resume_revisions(
async def get_resume_revision(
resume_id: str = Path(..., description="ID of the resume"),
revision_id: str = Path(..., description="Revision ID of the revision"),
current_user=Depends(get_current_user),
current_user=Depends(get_current_user_or_guest),
database: RedisDatabase = Depends(get_database),
):
"""Get a specific revision of a resume by revision ID"""
@ -303,7 +326,13 @@ async def get_resume_revision(
revision = await database.get_resume_revision(current_user.id, resume_id, revision_id)
if not revision:
raise HTTPException(status_code=404, detail="Revision not found")
job_data = await database.get_job(revision.job_id)
if job_data:
revision.job = Job.model_validate(job_data)
candidate_data = await database.get_candidate(revision.candidate_id)
if candidate_data:
revision.candidate = Candidate.model_validate(candidate_data)
logger.info(f"📄 Retrieved revision {revision_id} for resume {resume_id}")
return create_success_response(
{"resume_id": resume_id, "revision_id": revision_id, "revision": revision.model_dump(by_alias=True)}
)
@ -317,7 +346,7 @@ async def get_resume_revision(
@router.get("/{resume_id}/history")
async def get_resume_history(
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 all historical versions of a resume with full data"""
@ -339,7 +368,7 @@ async def get_resume_history(
async def restore_resume_revision(
resume_id: str = Path(..., description="ID of the resume"),
revision_id: str = Path(..., description="Revision ID of the revision to restore"),
current_user=Depends(get_current_user),
current_user=Depends(get_current_user_or_guest),
database: RedisDatabase = Depends(get_database),
):
"""Restore a resume to a specific revision"""
@ -368,7 +397,7 @@ async def restore_resume_revision(
async def delete_resume_revision(
resume_id: str = Path(..., description="ID of the resume"),
revision_id: str = Path(..., description="Revision ID of the revision to delete"),
current_user=Depends(get_current_user),
current_user=Depends(get_current_user_or_guest),
database: RedisDatabase = Depends(get_database),
):
"""Delete a specific revision from history"""
@ -397,7 +426,7 @@ async def compare_resume_revisions(
resume_id: str = Path(..., description="ID of the resume"),
revision_id1: str = Path(..., description="First revision ID"),
revision_id2: str = Path(..., description="Second revision ID"),
current_user=Depends(get_current_user),
current_user=Depends(get_current_user_or_guest),
database: RedisDatabase = Depends(get_database),
):
"""Compare two revisions of a resume"""