Refactored elements

This commit is contained in:
James Ketr 2025-04-25 21:37:32 -07:00
parent f67cdc24f8
commit b6cde81cfb
9 changed files with 600 additions and 571 deletions

View File

@ -448,6 +448,8 @@ COPY /src/requirements.txt /opt/backstory/src/requirements.txt
RUN pip install -r /opt/backstory/src/requirements.txt
RUN pip install timm xformers
SHELL [ "/bin/bash", "-c" ]
RUN { \

View File

@ -6,8 +6,6 @@ import Avatar from '@mui/material/Avatar';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Tooltip from '@mui/material/Tooltip';
import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import AppBar from '@mui/material/AppBar';
import Drawer from '@mui/material/Drawer';
import Toolbar from '@mui/material/Toolbar';
@ -21,7 +19,7 @@ import { useTheme } from '@mui/material/styles';
import { ResumeBuilder } from './ResumeBuilder';
import { Message, ChatQuery, MessageList, MessageData } from './Message';
import { SetSnackType, SeverityType } from './Snack';
import { Snack, SeverityType } from './Snack';
import { VectorVisualizer } from './VectorVisualizer';
import { Controls } from './Controls';
import { Conversation, ConversationHandle } from './Conversation';
@ -74,9 +72,6 @@ const App = () => {
const [connectionBase,] = useState<string>(getConnectionBase(window.location))
const [menuOpen, setMenuOpen] = useState(false);
const [isMenuClosing, setIsMenuClosing] = useState(false);
const [snackOpen, setSnackOpen] = useState(false);
const [snackMessage, setSnackMessage] = useState("");
const [snackSeverity, setSnackSeverity] = useState<SeverityType>("success");
const [tab, setTab] = useState<number>(0);
const [about, setAbout] = useState<string>("");
const [resume, setResume] = useState<MessageData | undefined>(undefined);
@ -86,15 +81,7 @@ const App = () => {
const chatRef = useRef<ConversationHandle>(null);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
// Set the snack pop-up and open it
const setSnack: SetSnackType = useCallback<SetSnackType>((message: string, severity: SeverityType = "success") => {
setTimeout(() => {
setSnackMessage(message);
setSnackSeverity(severity);
setSnackOpen(true);
});
}, [setSnackMessage, setSnackSeverity, setSnackOpen]);
const snackRef = useRef<any>(null);
useEffect(() => {
if (prevIsDesktopRef.current === isDesktop)
@ -107,6 +94,10 @@ const App = () => {
prevIsDesktopRef.current = isDesktop;
}, [isDesktop, setMenuOpen, menuOpen])
const setSnack = useCallback((message: string, severity?: SeverityType) => {
snackRef.current?.setSnack(message, severity);
}, [snackRef]);
// Get the About markdown
useEffect(() => {
if (about !== "") {
@ -143,9 +134,8 @@ const App = () => {
const chatPreamble: MessageList = [
{
role: 'content',
title: 'Welcome to Backstory',
content: `
# Welcome to Backstory
Backstory is a RAG enabled expert system with access to real-time data running self-hosted
(no cloud) versions of industry leading Large and Small Language Models (LLM/SLMs).
It was written by James Ketrenos in order to provide answers to
@ -278,17 +268,6 @@ What would you like to know about James?
</Card>
);
const handleSnackClose = (
event: React.SyntheticEvent | Event,
reason?: SnackbarCloseReason,
) => {
if (reason === 'clickaway') {
return;
}
setSnackOpen(false);
};
/* toolbar height is 56px + 8px margin-top */
const Offset = styled('div')(({ theme }) => ({ ...theme.mixins.toolbar, minHeight: '64px', height: '64px' }));
@ -454,16 +433,9 @@ What would you like to know about James?
</Box>
<Snackbar open={snackOpen} autoHideDuration={(snackSeverity === "success" || snackSeverity === "info") ? 1500 : 6000} onClose={handleSnackClose}>
<Alert
onClose={handleSnackClose}
severity={snackSeverity}
variant="filled"
sx={{ width: '100%' }}
>
{snackMessage}
</Alert>
</Snackbar>
<Snack
ref={snackRef}
/>
</Box >
);
};

View File

@ -1,7 +1,12 @@
import { Box } from '@mui/material';
import React from 'react';
import { Box, Typography } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import { SxProps, Theme } from '@mui/material';
import React from 'react';
import Accordion from '@mui/material/Accordion';
import AccordionSummary from '@mui/material/AccordionSummary';
import AccordionDetails from '@mui/material/AccordionDetails';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { MessageRoles } from './Message';
interface ChatBubbleProps {
@ -11,9 +16,10 @@ interface ChatBubbleProps {
children: React.ReactNode;
sx?: SxProps<Theme>;
className?: string;
title?: string;
}
function ChatBubble({ role, isFullWidth, children, sx, className }: ChatBubbleProps) {
function ChatBubble({ role, isFullWidth, children, sx, className, title }: ChatBubbleProps) {
const theme = useTheme();
const defaultRadius = '16px';
@ -96,7 +102,7 @@ function ChatBubble({ role, isFullWidth, children, sx, className }: ChatBubblePr
alignSelf: 'center', // Centered in the chat
color: theme.palette.text.primary, // Charcoal Black for maximum readability
padding: '8px 8px', // More generous padding for better text framing
marginBottom: '20px', // Space between content and conversation
marginBottom: '0px', // Space between content and conversation
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.05)', // Subtle elevation
fontSize: '0.9rem', // Slightly smaller than default
lineHeight: '1.3', // More compact line height
@ -104,6 +110,28 @@ function ChatBubble({ role, isFullWidth, children, sx, className }: ChatBubblePr
},
};
if (role === 'content' && title) {
return (
<Accordion
defaultExpanded
className={className}
sx={{ ...styles[role] }}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
slotProps={{ content: { sx: { fontWeight: 'bold', fontSize: '1.1rem', m: 0, p: 0, display: 'flex', justifyItems: 'center' } } }}
>
{title}
</AccordionSummary>
<AccordionDetails sx={{ mt: 0, mb: 0, p: 0, pl: 2, pr: 2 }}>
{children}
</AccordionDetails>
</Accordion>
);
}
return (
<Box className={className} sx={{ ...styles[role], ...sx }}>
{children}

View File

@ -500,7 +500,7 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
return (
<Box className={className || "Conversation"} sx={{
display: "flex", flexDirection: "column", flexGrow: 1, p: 1,
display: "flex", flexDirection: "column", flexGrow: 1, p: 1, mt: 1,
...sx
}}>
{

View File

@ -1,45 +0,0 @@
import React from 'react';
import { Box, Typography } from '@mui/material';
import { SxProps, Theme } from '@mui/material';
/**
* Props for the Document component
* @interface DocumentComponentProps
* @property {string} title - The title of the document
* @property {React.ReactNode} [children] - The content of the document
*/
interface DocumentComponentProps {
title: string;
children?: React.ReactNode;
sx?: SxProps<Theme>;
}
/**
* Document component renders a container with optional title and scrollable content
*
* This component provides a consistent document viewing experience across the application
* with a title header and scrollable content area
*/
const Document: React.FC<DocumentComponentProps> = ({ title, children, sx }) => (
<Box
sx={{
...sx,
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
overflow: 'hidden',
}}
>
{
title !== "" &&
<Typography sx={{ pl: 1, pr: 1, display: 'flex', mt: -1, fontWeight: 'bold' }}>{title}</Typography>
}
<Box sx={{ display: 'flex', p: 1, flexGrow: 1, overflow: 'auto' }}>
{children}
</Box>
</Box>
);
export {
Document
};

View File

@ -1,462 +0,0 @@
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';
/**
* Props for the DocumentViewer component
* @interface DocumentViewerProps
* @property {SxProps<Theme>} [sx] - Optional styling properties
* @property {string} [connectionBase] - Base URL for fetch calls
* @property {string} [sessionId] - Session ID
* @property {SetSnackType} - setSnack UI callback
*/
export interface DocumentViewerProps {
sx?: SxProps<Theme>;
connectionBase: string;
sessionId: string;
setSnack: SetSnackType;
}
/**
* 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> = ({
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 'user'
reduced[0].role = 'assistant';
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].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].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(() => {
/* Left panel - Job Description */
const showResume = hasResume
const showFactCheck = hasFacts
const ratio = 75 + 25 * splitRatio / 100;
const otherRatio = showResume ? 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, overflowY: 'auto' }}>
{renderJobDescriptionView(false)}
</Box>);
/* Resume panel - conditionally rendered if resume defined, or processing is in progress */
if (showResume) {
children.push(
<Box key="ResumeView" className="ChatBox" sx={{ display: 'flex', flexDirection: 'column', minWidth: `${resumeRatio}%`, width: `${resumeRatio}%`, maxWidth: `${resumeRatio}%`, p: 0, flexGrow: 1, overflowY: 'auto' }}>
<Divider orientation="vertical" flexItem />
{renderResumeView(false)}
</Box>
);
}
/* Fact Check panel - conditionally rendered if facts defined, or processing is in progress */
if (showFactCheck) {
children.push(
<Box key="FactCheckView" className="ChatBox" sx={{ display: 'flex', flexDirection: 'column', minWidth: `${otherRatio}%`, width: `${otherRatio}%`, maxWidth: `${otherRatio}%`, p: 0, flexGrow: 1, overflowY: 'auto' }}>
<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 (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(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={{ ...sx, display: 'flex', flexGrow: 1, flexDirection: 'column', p: 0 }}>
<Box sx={{ display: 'flex', flexGrow: 1, flexDirection: 'row', overflow: 'hidden', p: 0 }}>
{children}
</Box>
{slider}
</Box>
)
}, [renderFactCheckView, renderJobDescriptionView, renderResumeView, splitRatio, sx, 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={{ 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" />
{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 (
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, width: "100%", ...sx }}>
{getActiveDesktopContent()}
</Box>
);
};
export {
DocumentViewer
};

View File

@ -35,6 +35,7 @@ type MessageData = {
role: MessageRoles,
content: string,
user?: string,
title?: string,
origin?: string,
display?: string, /* Messages generated on the server for filler should not be shown */
id?: string,
@ -250,8 +251,21 @@ const Message = ({ message, submitQuery, isFullWidth, sessionId, setSnack, conne
const formattedContent = message.content.trim();
return (
<ChatBubble className="Message" isFullWidth={isFullWidth} role={message.role} sx={{ display: "flex", flexDirection: "column", pb: message.metadata ? 0 : "8px", m: 0, mb: 1, mt: 1, overflowX: "auto" }}>
<CardContent ref={textFieldRef} sx={{ position: "relative", display: "flex", flexDirection: "column", overflowX: "auto" }}>
<ChatBubble
className="Message"
isFullWidth={isFullWidth}
role={message.role}
title={message.title}
sx={{
display: "flex",
flexDirection: "column",
pb: message.metadata ? 0 : "8px",
m: 0,
mb: 1,
mt: 1,
overflowX: "auto"
}}>
<CardContent ref={textFieldRef} sx={{ position: "relative", display: "flex", flexDirection: "column", overflowX: "auto", m: 0, p: 0 }}>
<Tooltip title="Copy to clipboard" placement="top" arrow>
<IconButton
onClick={handleCopy}
@ -261,8 +275,9 @@ const Message = ({ message, submitQuery, isFullWidth, sessionId, setSnack, conne
right: 0,
width: 24,
height: 24,
opacity: 0.75,
bgcolor: 'background.paper',
'&:hover': { bgcolor: 'action.hover' },
'&:hover': { bgcolor: 'action.hover', opacity: 1 },
}}
size="small"
color={copied ? "success" : "default"}
@ -288,7 +303,7 @@ const Message = ({ message, submitQuery, isFullWidth, sessionId, setSnack, conne
</CardContent>
{message.metadata && <>
<CardActions disableSpacing sx={{ justifySelf: "flex-end" }}>
<Button variant="text" onClick={handleExpandClick} sx={{ color: "darkgrey", p: 1, flexGrow: 0 }}>LLM information for this query</Button>
<Button variant="text" onClick={handleExpandClick} sx={{ color: "darkgrey", p: 1, flexGrow: 0, justifySelf: "flex-end" }}>LLM information for this query</Button>
<ExpandMore
expand={expanded}
onClick={handleExpandClick}

View File

@ -1,28 +1,474 @@
import Box from '@mui/material/Box';
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 { SeverityType } from './Snack';
import { MessageData } from './Message';
import { DocumentViewer } from './DocumentViewer';
import { ChatQuery } from './Message';
import { MessageList, MessageData } from './Message';
import { SetSnackType } from './Snack';
import { Conversation } from './Conversation';
/**
* Props for the DocumentViewer component
* @interface DocumentViewerProps
* @property {SxProps<Theme>} [sx] - Optional styling properties
* @property {string} [connectionBase] - Base URL for fetch calls
* @property {string} [sessionId] - Session ID
* @property {SetSnackType} - setSnack UI callback
*/
export interface DocumentViewerProps {
sx?: SxProps<Theme>;
connectionBase: string;
sessionId: string;
setSnack: SetSnackType;
}
/**
* 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> = ({
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(() => {
/* Left panel - Job Description */
const showResume = hasResume
const showFactCheck = hasFacts
const ratio = 75 + 25 * splitRatio / 100;
const otherRatio = showResume ? 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, overflowY: 'auto' }}>
{renderJobDescriptionView(false)}
</Box>);
/* Resume panel - conditionally rendered if resume defined, or processing is in progress */
if (showResume) {
children.push(
<Box key="ResumeView" className="ChatBox" sx={{ display: 'flex', flexDirection: 'column', minWidth: `${resumeRatio}%`, width: `${resumeRatio}%`, maxWidth: `${resumeRatio}%`, p: 0, flexGrow: 1, overflowY: 'auto' }}>
<Divider orientation="vertical" flexItem />
{renderResumeView(false)}
</Box>
);
}
/* Fact Check panel - conditionally rendered if facts defined, or processing is in progress */
if (showFactCheck) {
children.push(
<Box key="FactCheckView" className="ChatBox" sx={{ display: 'flex', flexDirection: 'column', minWidth: `${otherRatio}%`, width: `${otherRatio}%`, maxWidth: `${otherRatio}%`, p: 0, flexGrow: 1, overflowY: 'auto' }}>
<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 (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(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={{ ...sx, display: 'flex', flexGrow: 1, flexDirection: 'column', p: 0 }}>
<Box sx={{ display: 'flex', flexGrow: 1, flexDirection: 'row', overflow: 'hidden', p: 0 }}>
{children}
</Box>
{slider}
</Box>
)
}, [renderFactCheckView, renderJobDescriptionView, renderResumeView, splitRatio, sx, 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={{ 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" />
{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 (
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, width: "100%", ...sx }}>
{getActiveDesktopContent()}
</Box>
);
};
interface ResumeBuilderProps {
setProcessing: (processing: boolean) => void,
processing: boolean,
connectionBase: string,
sessionId: string | undefined,
setSnack: (message: string, severity?: SeverityType) => void,
resume: MessageData | undefined,
setResume: (resume: MessageData | undefined) => void,
facts: MessageData | undefined,
setFacts: (facts: MessageData | undefined) => void,
};
// type Resume = {
// resume: MessageData | undefined,
// fact_check: MessageData | undefined,
// job_description: string,
// metadata: MessageMetaProps
// };
const ResumeBuilder = ({ facts, setFacts, resume, setResume, setProcessing, processing, connectionBase, sessionId, setSnack }: ResumeBuilderProps) => {
const ResumeBuilder = ({ connectionBase, sessionId, setSnack }: ResumeBuilderProps) => {
if (sessionId === undefined) {
return (<></>);
}

View File

@ -1,7 +1,80 @@
import React, { useState, useCallback, useImperativeHandle, forwardRef } from 'react';
import { SxProps, Theme } from '@mui/material';
import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import './Snack.css';
type SeverityType = 'error' | 'info' | 'success' | 'warning' | undefined;
type SetSnackType = (message: string, severity?: SeverityType) => void;
interface SnackHandle {
setSnack: SetSnackType;
};
interface SnackProps {
sx?: SxProps<Theme>;
className?: string;
};
const Snack = forwardRef<SnackHandle, SnackProps>(({
className,
sx
}: SnackProps, ref) => {
const [open, setOpen] = useState(false);
const [message, setMessage] = useState("");
const [severity, setSeverity] = useState<SeverityType>("success");
// Set the snack pop-up and open it
const setSnack: SetSnackType = useCallback<SetSnackType>((message: string, severity: SeverityType = "success") => {
setTimeout(() => {
setMessage(message);
setSeverity(severity);
setOpen(true);
});
}, [setMessage, setSeverity, setOpen]);
useImperativeHandle(ref, () => ({
setSnack: (message: string, severity?: SeverityType) => {
setSnack(message, severity);
}
}));
const handleSnackClose = (
event: React.SyntheticEvent | Event,
reason?: SnackbarCloseReason,
) => {
if (reason === 'clickaway') {
return;
}
setOpen(false);
};
return (
<Snackbar
className={className || "Snack"}
sx={{ ...sx }}
open={open}
autoHideDuration={(severity === "success" || severity === "info") ? 1500 : 6000}
onClose={handleSnackClose}>
<Alert
onClose={handleSnackClose}
severity={severity}
variant="filled"
sx={{ width: '100%' }}
>
{message}
</Alert>
</Snackbar>
)
});
export type {
SeverityType,
SetSnackType
};
export {
Snack
};