199 lines
6.2 KiB
TypeScript
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 };
|