backstory/frontend/src/DocumentViewer.tsx

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
};