backstory/frontend/src/ResumeBuilder.tsx

519 lines
15 KiB
TypeScript

import React, { useState, useCallback, useRef } from 'react';
import {
Tabs,
Tab,
Paper,
IconButton,
Box,
useMediaQuery,
Divider,
Slider,
Stack,
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import {
ChevronLeft,
ChevronRight,
SwapHoriz,
} from '@mui/icons-material';
import { SxProps, Theme } from '@mui/material';
import { ChatQuery } from './Message';
import { MessageList, MessageData } from './Message';
import { SetSnackType } from './Snack';
import { Conversation } from './Conversation';
interface ResumeBuilderProps {
connectionBase: string,
sessionId: string | undefined,
setSnack: SetSnackType,
sx?: SxProps<Theme>;
};
/**
* ResumeBuilder component
*
* A responsive component that displays job descriptions, generated resumes and fact checks
* with different layouts for mobile and desktop views.
*/
const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
sx,
connectionBase,
sessionId,
setSnack
}) => {
// State for editing job description
const [hasJobDescription, setHasJobDescription] = useState<boolean>(false);
const [hasResume, setHasResume] = useState<boolean>(false);
const [hasFacts, setHasFacts] = useState<boolean>(false);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const jobConversationRef = useRef<any>(null);
const resumeConversationRef = useRef<any>(null);
const factsConversationRef = useRef<any>(null);
const [activeTab, setActiveTab] = useState<number>(0);
const [splitRatio, setSplitRatio] = useState<number>(100);
/**
* 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);
};
const handleJobQuery = (query: string) => {
console.log(`handleJobQuery: ${query} -- `, jobConversationRef.current ? ' sending' : 'no handler');
jobConversationRef.current?.submitQuery(query);
};
const handleResumeQuery = (query: string) => {
console.log(`handleResumeQuery: ${query} -- `, resumeConversationRef.current ? ' sending' : 'no handler');
resumeConversationRef.current?.submitQuery(query);
};
const handleFactsQuery = (query: string) => {
console.log(`handleFactsQuery: ${query} -- `, factsConversationRef.current ? ' sending' : 'no handler');
factsConversationRef.current?.submitQuery(query);
};
const filterJobDescriptionMessages = useCallback((messages: MessageList): MessageList => {
if (messages === undefined || messages.length === 0) {
return [];
}
let reduced = messages.filter((m, i) => {
const keep = (m.metadata?.origin || m.origin || "no origin") === 'job_description';
if ((m.metadata?.origin || m.origin || "no origin") === 'resume') {
setHasResume(true);
}
// if (!keep) {
// console.log(`filterJobDescriptionMessages: ${i + 1} filtered:`, m);
// } else {
// console.log(`filterJobDescriptionMessages: ${i + 1}:`, m);
// }
return keep;
});
if (reduced.length > 0) {
// First message is always 'content'
reduced[0].title = 'Job Description';
reduced[0].role = 'content';
setHasJobDescription(true);
}
/* If there is more than one message, it is user: "...JOB_DESCRIPTION...", assistant: "...stored..."
* which means a resume has been generated. */
if (reduced.length > 1) {
setHasResume(true);
}
/* Filter out any messages which the server injected for state management */
reduced = reduced.filter(m => m.display !== "hide");
return reduced;
}, [setHasJobDescription, setHasResume]);
const filterResumeMessages = useCallback((messages: MessageList): MessageList => {
if (messages === undefined || messages.length === 0) {
return [];
}
let reduced = messages.filter((m, i) => {
const keep = (m.metadata?.origin || m.origin || "no origin") === 'resume';
if ((m.metadata?.origin || m.origin || "no origin") === 'fact_check') {
setHasFacts(true);
}
// if (!keep) {
// console.log(`filterResumeMessages: ${i + 1} filtered:`, m);
// } else {
// console.log(`filterResumeMessages: ${i + 1}:`, m);
// }
return keep;
});
/* If there is more than one message, it is user: "...JOB_DESCRIPTION...", assistant: "...RESUME..."
* which means a resume has been generated. */
if (reduced.length > 1) {
/* Remove the assistant message from the UI */
if (reduced[0].role === "user") {
reduced.splice(0, 1);
}
}
/* If Fact Check hasn't occurred yet and there is still more than one message,
* facts have have been generated. */
if (!hasFacts && reduced.length > 1) {
setHasFacts(true);
}
/* Filter out any messages which the server injected for state management */
reduced = reduced.filter(m => m.display !== "hide");
/* If there are any messages, there is a resume */
if (reduced.length > 0) {
// First message is always 'content'
reduced[0].title = 'Resume';
reduced[0].role = 'content';
setHasResume(true);
}
return reduced;
}, [setHasResume, hasFacts, setHasFacts]);
const filterFactsMessages = useCallback((messages: MessageList): MessageList => {
if (messages === undefined || messages.length === 0) {
return [];
}
// messages.forEach((m, i) => console.log(`filterFactsMessages: ${i + 1}:`, m))
const reduced = messages.filter(m => {
return (m.metadata?.origin || m.origin || "no origin") === 'fact_check';
});
/* If there is more than one message, it is user: "Fact check this resume...", assistant: "...FACT CHECK..."
* which means facts have been generated. */
if (reduced.length > 1) {
/* Remove the user message from the UI */
if (reduced[0].role === "user") {
reduced.splice(0, 1);
}
// First message is always 'content'
reduced[0].title = 'Fact Check';
reduced[0].role = 'content';
setHasFacts(true);
}
return reduced;
}, [setHasFacts]);
const jobResponse = useCallback((message: MessageData): MessageData => {
console.log('onJobResponse', message);
setHasResume(true);
return message;
}, []);
const resumeResponse = useCallback((message: MessageData): MessageData => {
console.log('onResumeResponse', message);
setHasFacts(true);
return message;
}, [setHasFacts]);
const factsResponse = useCallback((message: MessageData): MessageData => {
console.log('onFactsResponse', message);
return message;
}, []);
const renderJobDescriptionView = useCallback((small: boolean) => {
const jobDescriptionQuestions = [
<Box sx={{ display: "flex", flexDirection: small ? "column" : "row" }}>
<ChatQuery text="What are the key skills necessary for this position?" submitQuery={handleJobQuery} />
<ChatQuery text="How much should this position pay (accounting for inflation)?" submitQuery={handleJobQuery} />
</Box>,
];
if (!hasJobDescription) {
return <Conversation
ref={jobConversationRef}
{...{
type: "job_description",
actionLabel: "Generate Resume",
prompt: "Paste a job description, then click Generate...",
multiline: true,
messageFilter: filterJobDescriptionMessages,
onResponse: jobResponse,
sessionId,
connectionBase,
setSnack,
}}
/>
} else {
return <Conversation
ref={jobConversationRef}
{...{
type: "job_description",
actionLabel: "Send",
prompt: "Ask a question about this job description...",
messageFilter: filterJobDescriptionMessages,
defaultPrompts: jobDescriptionQuestions,
onResponse: jobResponse,
sessionId,
connectionBase,
setSnack,
}}
/>
}
}, [connectionBase, filterJobDescriptionMessages, hasJobDescription, sessionId, setSnack, jobResponse]);
/**
* Renders the resume view with loading indicator
*/
const renderResumeView = useCallback((small: boolean) => {
const resumeQuestions = [
<Box sx={{ display: "flex", flexDirection: small ? "column" : "row" }}>
<ChatQuery text="Is this resume a good fit for the provided job description?" submitQuery={handleResumeQuery} />
<ChatQuery text="Provide a more concise resume." submitQuery={handleResumeQuery} />
</Box>,
];
if (!hasFacts) {
return <Conversation
ref={resumeConversationRef}
{...{
actionLabel: "Fact Check",
multiline: true,
type: "resume",
messageFilter: filterResumeMessages,
onResponse: resumeResponse,
sessionId,
connectionBase,
setSnack,
}}
/>
} else {
return <Conversation
ref={resumeConversationRef}
{...{
type: "resume",
actionLabel: "Send",
prompt: "Ask a question about this job resume...",
messageFilter: filterResumeMessages,
defaultPrompts: resumeQuestions,
onResponse: resumeResponse,
sessionId,
connectionBase,
setSnack,
}}
/>
}
}, [connectionBase, filterResumeMessages, hasFacts, sessionId, setSnack, resumeResponse]);
/**
* Renders the fact check view
*/
const renderFactCheckView = useCallback((small: boolean) => {
const factsQuestions = [
<Box sx={{ display: "flex", flexDirection: small ? "column" : "row" }}>
<ChatQuery text="Rewrite the resume to address any discrepancies." submitQuery={handleFactsQuery} />
</Box>,
];
return <Conversation
ref={factsConversationRef}
{...{
type: "fact_check",
actionLabel: "Send",
prompt: "Ask a question about any discrepencies...",
messageFilter: filterFactsMessages,
defaultPrompts: factsQuestions,
onResponse: factsResponse,
sessionId,
connectionBase,
setSnack,
}}
/>
}, [connectionBase, sessionId, setSnack, factsResponse, filterFactsMessages]);
/**
* Gets the appropriate content based on active state for Desktop
*/
const getActiveDesktopContent = useCallback(() => {
const hasSlider = hasResume || hasFacts;
const ratio = 75 + 25 * splitRatio / 100;
const otherRatio = hasResume ? ratio / (hasFacts ? 3 : 2) : 100;
const resumeRatio = 100 - otherRatio * (hasFacts ? 2 : 1);
const children = [];
children.push(
<Box key="JobDescription" className="ChatBox" sx={{
display: 'flex',
flexDirection: 'column',
minWidth: `${otherRatio}%`,
width: `${otherRatio}%`,
maxWidth: `${otherRatio}%`,
p: 0,
flexGrow: 1,
}}>
{renderJobDescriptionView(false)}
</Box>);
/* Resume panel - conditionally rendered if resume defined, or processing is in progress */
if (hasResume) {
children.push(
<Box key="ResumeView"
className="ChatBox"
sx={{
display: 'flex',
flexDirection: 'column',
minWidth: `${resumeRatio}%`,
width: `${resumeRatio}%`,
maxWidth: `${resumeRatio}%`,
p: 0,
flexGrow: 1
}}>
<Divider orientation="vertical" flexItem />
{renderResumeView(false)}
</Box>
);
}
/* Fact Check panel - conditionally rendered if facts defined, or processing is in progress */
if (hasFacts) {
children.push(
<Box key="FactCheckView"
className="ChatBox"
sx={{
display: 'flex',
flexDirection: 'column',
minWidth: `${otherRatio}%`,
width: `${otherRatio}%`,
maxWidth: `${otherRatio}%`,
p: 0,
flexGrow: 1,
}}>
<Divider orientation="vertical" flexItem />
{renderFactCheckView(false)}
</Box>
);
}
/* Split control panel - conditionally rendered if either facts or resume is set */
let slider = <Box key="slider"></Box>;
if (hasSlider) {
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(s => Math.max(0, s - 10))}>
<ChevronLeft />
</IconButton>
<Slider
value={splitRatio}
onChange={handleSliderChange}
aria-label="Split ratio"
min={0}
max={100}
/>
<IconButton onClick={() => setSplitRatio(s => Math.min(100, s + 10))}>
<ChevronRight />
</IconButton>
<IconButton onClick={resetSplit}>
<SwapHoriz />
</IconButton>
</Stack>
</Paper>
);
}
return (
<Box sx={{
p: 0,
m: 0,
display: "flex",
flexGrow: 1,
flexDirection: "column",
}}>
<Box sx={{
display: 'flex',
flexGrow: 1,
flexDirection: 'row',
overflow: 'hidden',
p: 0,
m: 0,
margin: "0 auto",
maxWidth: hasSlider ? "100%" : "1024px",
width: hasSlider ? "100%" : "1024px",
height: `calc(100vh - ${hasSlider ? 144 : 72}px)`,
backgroundColor: "#F5F5F5",
}}
>
{children}
</Box>
{slider}
</Box>
)
}, [renderFactCheckView, renderJobDescriptionView, renderResumeView, splitRatio, hasFacts, hasResume]);
// Render mobile view
if (isMobile) {
/**
* Gets the appropriate content based on active tab
*/
const getActiveMobileContent = () => {
switch (activeTab) {
case 0:
return renderJobDescriptionView(true);
case 1:
return renderResumeView(true);
case 2:
return renderFactCheckView(true);
default:
return renderJobDescriptionView(true);
}
};
return (
<Box sx={{
p: 0,
m: 0,
display: "flex",
flexGrow: 1,
margin: "0 auto",
overflow: "hidden",
height: "calc(100vh - 72px)",
backgroundColor: "#F5F5F5",
flexDirection: "column"
}}
>
{/* Tabs */}
<Tabs
value={activeTab}
onChange={handleTabChange}
variant="fullWidth"
sx={{ bgcolor: 'background.paper' }}
>
<Tab value={0} label="Job Description" />
{hasResume && <Tab value={1} label="Resume" />}
{hasFacts && <Tab value={2} label="Fact Check" />}
</Tabs>
{/* Document display area */}
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, p: 0, width: "100%", ...sx }}>
{getActiveMobileContent()}
</Box>
</Box>
);
}
return getActiveDesktopContent();
};
export type {
ResumeBuilderProps
};
export {
ResumeBuilder
};