457 lines
14 KiB
TypeScript
457 lines
14 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react';
|
|
import {
|
|
Typography,
|
|
Card,
|
|
Button,
|
|
Tabs,
|
|
Tab,
|
|
Paper,
|
|
IconButton,
|
|
Box,
|
|
useMediaQuery,
|
|
Divider,
|
|
Slider,
|
|
Stack,
|
|
TextField,
|
|
Tooltip
|
|
} from '@mui/material';
|
|
import { useTheme } from '@mui/material/styles';
|
|
import SendIcon from '@mui/icons-material/Send';
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
SwapHoriz,
|
|
RestartAlt as ResetIcon,
|
|
} from '@mui/icons-material';
|
|
import PropagateLoader from "react-spinners/PropagateLoader";
|
|
|
|
import { Message } from './Message';
|
|
import { Document } from './Document';
|
|
import { DocumentViewerProps } from './DocumentTypes';
|
|
import MuiMarkdown from 'mui-markdown';
|
|
|
|
/**
|
|
* DocumentViewer component
|
|
*
|
|
* A responsive component that displays job descriptions, generated resumes and fact checks
|
|
* with different layouts for mobile and desktop views.
|
|
*/
|
|
const DocumentViewer: React.FC<DocumentViewerProps> = ({
|
|
generateResume,
|
|
jobDescription,
|
|
factCheck,
|
|
resume,
|
|
setResume,
|
|
facts,
|
|
setFacts,
|
|
sx
|
|
}) => {
|
|
// State for editing job description
|
|
const [editJobDescription, setEditJobDescription] = useState<string | undefined>(jobDescription);
|
|
// Processing state to show loading indicators
|
|
const [processing, setProcessing] = useState<string | undefined>(undefined);
|
|
// Theme and responsive design setup
|
|
const theme = useTheme();
|
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
|
|
|
// State for controlling which document is active on mobile
|
|
const [activeTab, setActiveTab] = useState<number>(0);
|
|
// State for controlling split ratio on desktop
|
|
const [splitRatio, setSplitRatio] = useState<number>(0);
|
|
|
|
/**
|
|
* Reset processing state when resume is generated
|
|
*/
|
|
useEffect(() => {
|
|
if (resume !== undefined && processing === "resume") {
|
|
setProcessing(undefined);
|
|
}
|
|
}, [processing, resume]);
|
|
|
|
/**
|
|
* Reset processing state when facts is generated
|
|
*/
|
|
useEffect(() => {
|
|
if (facts !== undefined && processing === "facts") {
|
|
setProcessing(undefined);
|
|
}
|
|
}, [processing, facts]);
|
|
|
|
/**
|
|
* Trigger resume generation and update UI state
|
|
*/
|
|
const triggerGeneration = useCallback((jobDescription: string | undefined) => {
|
|
if (jobDescription === undefined) {
|
|
setProcessing(undefined);
|
|
setResume(undefined);
|
|
setActiveTab(0);
|
|
return;
|
|
}
|
|
setProcessing("resume");
|
|
setTimeout(() => { setActiveTab(1); }, 250); // Switch to resume view on mobile
|
|
generateResume(jobDescription);
|
|
}, [generateResume, setProcessing, setActiveTab, setResume]);
|
|
|
|
/**
|
|
* Trigger fact check and update UI state
|
|
*/
|
|
const triggerFactCheck = useCallback((resume: string | undefined) => {
|
|
if (resume === undefined) {
|
|
setProcessing(undefined);
|
|
setResume(undefined);
|
|
setFacts(undefined);
|
|
setActiveTab(1);
|
|
return;
|
|
}
|
|
setProcessing("facts");
|
|
factCheck(resume);
|
|
setTimeout(() => { setActiveTab(2); }, 250); // Switch to resume view on mobile
|
|
}, [factCheck, setResume, setProcessing, setActiveTab, setFacts]);
|
|
|
|
/**
|
|
* Switch to resume tab when resume become available
|
|
*/
|
|
useEffect(() => {
|
|
if (resume !== undefined) {
|
|
setTimeout(() => { setActiveTab(1); }, 250); // Switch to resume view on mobile
|
|
}
|
|
}, [resume]);
|
|
|
|
/**
|
|
* Switch to fact check tab when facts become available
|
|
*/
|
|
useEffect(() => {
|
|
if (facts !== undefined) {
|
|
setTimeout(() => { setActiveTab(2); }, 250); // Switch to resume view on mobile
|
|
}
|
|
}, [facts]);
|
|
|
|
/**
|
|
* Handle tab change for mobile view
|
|
*/
|
|
const handleTabChange = (_event: React.SyntheticEvent, newValue: number): void => {
|
|
setActiveTab(newValue);
|
|
};
|
|
|
|
/**
|
|
* Adjust split ratio for desktop view
|
|
*/
|
|
const handleSliderChange = (_event: Event, newValue: number | number[]): void => {
|
|
setSplitRatio(newValue as number);
|
|
};
|
|
|
|
/**
|
|
* Reset split ratio to default
|
|
*/
|
|
const resetSplit = (): void => {
|
|
setSplitRatio(50);
|
|
};
|
|
|
|
/**
|
|
* Handle keyboard shortcuts
|
|
*/
|
|
const handleKeyPress = (event: React.KeyboardEvent): void => {
|
|
if (event.key === 'Enter' && event.ctrlKey) {
|
|
triggerGeneration(editJobDescription || "");
|
|
}
|
|
};
|
|
|
|
const renderJobDescriptionView = () => {
|
|
const jobDescription = [];
|
|
|
|
if (resume === undefined && processing === undefined) {
|
|
jobDescription.push(
|
|
<Document key="jobDescription" sx={{ display: "flex", flexGrow: 1 }} title="">
|
|
<TextField
|
|
variant="outlined"
|
|
fullWidth
|
|
multiline
|
|
type="text"
|
|
sx={{
|
|
flex: 1,
|
|
flexGrow: 1,
|
|
maxHeight: '100%',
|
|
overflow: 'auto',
|
|
}}
|
|
value={editJobDescription}
|
|
onChange={(e) => setEditJobDescription(e.target.value)}
|
|
onKeyDown={handleKeyPress}
|
|
placeholder="Paste a job description, then click Generate..."
|
|
/>
|
|
</Document>
|
|
);
|
|
} else {
|
|
jobDescription.push(<MuiMarkdown key="jobDescription" >{editJobDescription}</MuiMarkdown>)
|
|
}
|
|
|
|
jobDescription.push(
|
|
<Box key="jobActions" sx={{ display: "flex", justifyContent: "center", flexDirection: "row" }}>
|
|
<IconButton
|
|
sx={{ display: "flex", margin: 'auto 0px' }}
|
|
size="large"
|
|
edge="start"
|
|
color="inherit"
|
|
disabled={processing !== undefined}
|
|
onClick={() => { setEditJobDescription(""); triggerGeneration(undefined); }}
|
|
>
|
|
<Tooltip title="Reset Job Description">
|
|
<ResetIcon />
|
|
</Tooltip>
|
|
</IconButton>
|
|
<Tooltip title="Generate">
|
|
<Button
|
|
sx={{ m: 1, gap: 1, flexGrow: 1 }}
|
|
variant="contained"
|
|
onClick={() => { triggerGeneration(editJobDescription); }}
|
|
>
|
|
Generate<SendIcon />
|
|
</Button>
|
|
</Tooltip>
|
|
</Box>
|
|
);
|
|
|
|
return jobDescription;
|
|
}
|
|
|
|
/**
|
|
* Renders the resume view with loading indicator
|
|
*/
|
|
const renderResumeView = () => (
|
|
<Box key="ResumeView" sx={{ display: "flex", flexDirection: "column", overflow: "auto", flexGrow: 1, flexBasis: 0 }}>
|
|
<Document sx={{ display: "flex", flexGrow: 1 }} title="">
|
|
{resume !== undefined && <Message message={resume} />}
|
|
</Document>
|
|
{processing === "resume" && (
|
|
<Box sx={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
mb: 1,
|
|
height: "10px"
|
|
}}>
|
|
<PropagateLoader
|
|
size="10px"
|
|
loading={true}
|
|
aria-label="Loading Spinner"
|
|
data-testid="loader"
|
|
/>
|
|
<Typography>Generating resume...</Typography>
|
|
</Box>
|
|
)}
|
|
<ResumeActionCard
|
|
resume={resume}
|
|
processing={processing}
|
|
triggerFactCheck={triggerFactCheck}
|
|
/>
|
|
</Box>
|
|
);
|
|
|
|
/**
|
|
* Renders the fact check view
|
|
*/
|
|
const renderFactCheckView = () => (
|
|
<Box key="FactView" sx={{ display: "flex", flexDirection: "column", overflow: "auto", flexGrow: 1, flexBasis: 0, p: 0 }}>
|
|
<Document sx={{ display: "flex", flexGrow: 1 }} title="">
|
|
{facts !== undefined && <Message message={facts} />}
|
|
</Document>
|
|
{processing === "facts" && (
|
|
<Box sx={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
mb: 1,
|
|
height: "10px"
|
|
}}>
|
|
<PropagateLoader
|
|
size="10px"
|
|
loading={true}
|
|
aria-label="Loading Spinner"
|
|
data-testid="loader"
|
|
/>
|
|
<Typography>Fact Checking resume...</Typography>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
|
|
// Render mobile view
|
|
if (isMobile) {
|
|
/**
|
|
* Gets the appropriate content based on active tab
|
|
*/
|
|
const getActiveMobileContent = () => {
|
|
switch (activeTab) {
|
|
case 0:
|
|
return renderJobDescriptionView();
|
|
case 1:
|
|
return renderResumeView();
|
|
case 2:
|
|
return renderFactCheckView();
|
|
default:
|
|
return renderJobDescriptionView();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, ...sx }}>
|
|
{/* Tabs */}
|
|
<Tabs
|
|
value={activeTab}
|
|
onChange={handleTabChange}
|
|
variant="fullWidth"
|
|
sx={{ bgcolor: 'background.paper' }}
|
|
>
|
|
<Tab value={0} label="Job Description" />
|
|
{(resume !== undefined || processing === "resume") && <Tab value={1} label="Resume" />}
|
|
{(facts !== undefined || processing === "facts") && <Tab value={2} label="Fact Check" />}
|
|
</Tabs>
|
|
|
|
{/* Document display area */}
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, overflow: 'hidden', p: 0 }}>
|
|
{getActiveMobileContent()}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Gets the appropriate content based on active state for Desktop
|
|
*/
|
|
const getActiveDesktopContent = () => {
|
|
/* Left panel - Job Description */
|
|
const showResume = resume !== undefined || processing === "resume"
|
|
const showFactCheck = facts !== undefined || processing === "facts"
|
|
const otherRatio = showResume ? (100 - splitRatio / 2) : 100;
|
|
const children = [];
|
|
children.push(
|
|
<Box key="JobDescription" sx={{ display: 'flex', flexDirection: 'column', width: `${otherRatio}%`, p: 0, flexGrow: 1, overflow: 'hidden' }}>
|
|
{renderJobDescriptionView()}
|
|
</Box>);
|
|
|
|
/* Resume panel - conditionally rendered if resume defined, or processing is in progress */
|
|
if (showResume) {
|
|
children.push(
|
|
<Box key="ResumeView" sx={{ display: 'flex', width: '100%', p: 0, flexGrow: 1, flexDirection: 'row' }}>
|
|
<Divider orientation="vertical" flexItem />
|
|
{renderResumeView()}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
/* Fact Check panel - conditionally rendered if facts defined, or processing is in progress */
|
|
if (showFactCheck) {
|
|
children.push(
|
|
<Box key="FactCheckView" sx={{ display: 'flex', width: `${otherRatio}%`, p: 0, flexGrow: 1, flexDirection: 'row' }}>
|
|
<Divider orientation="vertical" flexItem />
|
|
{renderFactCheckView()}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
/* Split control panel - conditionally rendered if either facts or resume is set */
|
|
let slider = <Box key="slider"></Box>;
|
|
if (showResume || showFactCheck) {
|
|
slider = (
|
|
<Paper key="slider" sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<Stack direction="row" spacing={2} alignItems="center" sx={{ width: '60%' }}>
|
|
<IconButton onClick={() => setSplitRatio(Math.max(0, splitRatio - 10))}>
|
|
<ChevronLeft />
|
|
</IconButton>
|
|
|
|
<Slider
|
|
value={splitRatio}
|
|
onChange={handleSliderChange}
|
|
aria-label="Split ratio"
|
|
min={0}
|
|
max={100}
|
|
/>
|
|
|
|
<IconButton onClick={() => setSplitRatio(Math.min(100, splitRatio + 10))}>
|
|
<ChevronRight />
|
|
</IconButton>
|
|
|
|
<IconButton onClick={resetSplit}>
|
|
<SwapHoriz />
|
|
</IconButton>
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexGrow: 1, flexDirection: 'column', overflow: 'hidden', p: 0 }}>
|
|
<Box sx={{ display: 'flex', flexGrow: 1, flexDirection: 'row', overflow: 'hidden', p: 0 }}>
|
|
{children}
|
|
</Box>
|
|
{slider}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, ...sx }}>
|
|
{getActiveDesktopContent()}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
|
|
/**
|
|
* Props for the ResumeActionCard component
|
|
*/
|
|
interface ResumeActionCardProps {
|
|
resume: any;
|
|
processing: string | undefined;
|
|
triggerFactCheck: (resume: string | undefined) => void;
|
|
}
|
|
|
|
/**
|
|
* Action card displayed underneath the resume with notes and fact check button
|
|
*/
|
|
const ResumeActionCard: React.FC<ResumeActionCardProps> = ({ resume, processing, triggerFactCheck }) => (
|
|
<Box sx={{ display: "flex", justifyContent: "center", flexDirection: "column" }}>
|
|
<Card sx={{ display: "flex", overflow: "auto", minHeight: "fit-content", p: 1, flexDirection: "column" }}>
|
|
{resume !== undefined || processing === "resume" ? (
|
|
<Typography>
|
|
<b>NOTE:</b> As with all LLMs, hallucination is always a possibility. If the generated resume seems too good to be true, <b>Fact Check</b> or, expand the <b>LLM information for this query</b> section (at the end of the resume) and click the links in the <b>Top RAG</b> matches to view the relavent RAG source document to read the details. Or go back to 'Backstory' and ask a question.
|
|
</Typography>
|
|
) : (
|
|
<Typography>
|
|
Once you click <b>Generate</b> under the <b>Job Description</b>, a resume will be generated based on the user's RAG content and the job description.
|
|
</Typography>
|
|
)}
|
|
</Card>
|
|
<Box sx={{ display: "flex", justifyContent: "center", flexDirection: "row", flexGrow: 1 }}>
|
|
<IconButton
|
|
sx={{ display: "flex", margin: 'auto 0px' }}
|
|
size="large"
|
|
edge="start"
|
|
color="inherit"
|
|
disabled={processing === "resume"}
|
|
onClick={() => { triggerFactCheck(undefined); }}
|
|
>
|
|
<Tooltip title="Reset Resume">
|
|
<ResetIcon />
|
|
</Tooltip>
|
|
</IconButton>
|
|
<Tooltip title="Fact Check">
|
|
<span style={{ display: "flex", flexGrow: 1 }}>
|
|
<Button
|
|
sx={{ m: 1, gap: 1, flexGrow: 1 }}
|
|
variant="contained"
|
|
disabled={processing === "facts"}
|
|
onClick={() => { resume && triggerFactCheck(resume.content); }}
|
|
>
|
|
Fact Check<SendIcon />
|
|
</Button>
|
|
</span>
|
|
</Tooltip>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
|
|
export {
|
|
DocumentViewer
|
|
}; |