519 lines
15 KiB
TypeScript
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
|
|
};
|
|
|