Refactored elements
This commit is contained in:
parent
f67cdc24f8
commit
b6cde81cfb
@ -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 { \
|
||||
|
@ -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 >
|
||||
);
|
||||
};
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
}}>
|
||||
{
|
||||
|
@ -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
|
||||
};
|
@ -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
|
||||
};
|
@ -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}
|
||||
|
@ -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 (<></>);
|
||||
}
|
||||
|
@ -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
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user