backstory/frontend/src/components/ResumeGenerator.tsx

199 lines
6.2 KiB
TypeScript

import React, { useState, useCallback, useEffect } from 'react';
import { Tabs, Tab, Box, Button, Paper, Typography, LinearProgress } from '@mui/material';
import { Job, Candidate, SkillAssessment } from 'types/types';
import { Scrollable } from './Scrollable';
import { useAuth } from 'hooks/AuthContext';
import * as Types from 'types/types';
import { StyledMarkdown } from './StyledMarkdown';
import InputIcon from '@mui/icons-material/Input';
import TuneIcon from '@mui/icons-material/Tune';
import ArticleIcon from '@mui/icons-material/Article';
import { StatusBox, StatusIcon } from './ui/StatusIcon';
import { CopyBubble } from './CopyBubble';
import { useAppState } from 'hooks/GlobalContext';
import { StreamingOptions } from 'services/api-client';
import { useNavigate } from 'react-router-dom';
interface ResumeGeneratorProps {
job: Job;
candidate: Candidate;
skills: SkillAssessment[];
onComplete?: (resume: string) => void;
}
const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorProps) => {
const { job, candidate, skills, onComplete } = props;
const { setSnack } = useAppState();
const navigate = useNavigate();
const { apiClient, user } = useAuth();
const [resume, setResume] = useState<string>('');
const [prompt, setPrompt] = useState<string>('');
const [systemPrompt, setSystemPrompt] = useState<string>('');
const [generated, setGenerated] = useState<boolean>(false);
const [tabValue, setTabValue] = useState<string>('resume');
const [status, setStatus] = useState<string>('');
const [statusType, setStatusType] = useState<Types.ApiActivityType | null>(null);
const [error, setError] = useState<Types.ChatMessageError | null>(null);
const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => {
setTabValue(newValue);
};
useEffect(() => {
if (!job || !candidate || !skills || generated) {
return;
}
setGenerated(true);
setStatusType('thinking');
setStatus('Starting resume generation...');
const generateResumeHandlers: StreamingOptions<Types.ChatMessageResume> = {
onMessage: (message: Types.ChatMessageResume) => {
const resume: Types.Resume = message.resume;
setSystemPrompt(resume.systemPrompt || '');
setPrompt(resume.prompt || '');
setResume(resume.resume || '');
setStatus('');
},
onStreaming: (chunk: Types.ChatMessageStreaming) => {
if (status === '') {
setStatus('Generating resume...');
setStatusType('generating');
}
setResume(chunk.content);
},
onStatus: (status: Types.ChatMessageStatus) => {
console.log('status:', status.content);
setStatusType(status.activity);
setStatus(status.content);
},
onComplete: () => {
onComplete && onComplete(resume);
},
onError: (error: Types.ChatMessageError) => {
console.log('error:', error);
setStatusType(null);
setStatus(error.content);
setError(error);
},
};
const generateResume = async (): Promise<void> => {
const request = await apiClient.generateResume(
candidate.id || '',
job.id || '',
generateResumeHandlers
);
await request.promise;
};
generateResume();
}, [
job,
candidate,
apiClient,
resume,
skills,
generated,
status,
setSystemPrompt,
setPrompt,
setResume,
onComplete,
setStatus,
setStatusType,
setError,
]);
const handleSave = useCallback(async () => {
if (!resume) {
setSnack('No resume to save!');
return;
}
try {
if (!candidate.id || !job.id) {
setSnack('Candidate or job ID is missing.');
return;
}
const submission: Types.Resume = {
jobId: job.id,
candidateId: candidate.id,
resume,
systemPrompt,
prompt,
};
const controller = apiClient.saveResume(submission);
const result = await controller.promise;
if (result.resume.id) {
setSnack('Resume saved successfully!');
navigate(`/candidate/resumes/${result.resume.id}`);
}
} catch (error) {
console.error('Error saving resume:', error);
setSnack('Error saving resume.');
}
}, [apiClient, candidate.id, job.id, resume, setSnack, navigate, prompt, systemPrompt]);
return (
<Box
className="ResumeGenerator"
sx={{
display: 'flex',
flexDirection: 'column',
}}
>
{user?.isAdmin && (
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 1 }}>
<Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab disabled={systemPrompt === ''} value="system" icon={<TuneIcon />} label="System" />
<Tab disabled={prompt === ''} value="prompt" icon={<InputIcon />} label="Prompt" />
<Tab disabled={resume === ''} value="resume" icon={<ArticleIcon />} label="Resume" />
</Tabs>
</Box>
)}
{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>
)}
<Paper elevation={3} sx={{ p: 3, m: 1, mt: 0 }}>
<Scrollable autoscroll sx={{ display: 'flex', flexGrow: 1, position: 'relative' }}>
{tabValue === 'system' && <pre>{systemPrompt}</pre>}
{tabValue === 'prompt' && <pre>{prompt}</pre>}
{tabValue === 'resume' && (
<>
<CopyBubble
onClick={(): void => {
setSnack('Resume copied to clipboard!');
}}
sx={{ position: 'absolute', top: 0, right: 0 }}
content={resume}
/>
<StyledMarkdown content={resume} />
</>
)}
</Scrollable>
</Paper>
{resume && !status && !error && (
<Button onClick={handleSave} variant="contained" color="primary" sx={{ mt: 2 }}>
Save Resume and Edit
</Button>
)}
</Box>
);
};
export { ResumeGenerator };