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 -r /opt/backstory/src/requirements.txt
|
||||||
|
|
||||||
|
RUN pip install timm xformers
|
||||||
|
|
||||||
SHELL [ "/bin/bash", "-c" ]
|
SHELL [ "/bin/bash", "-c" ]
|
||||||
|
|
||||||
RUN { \
|
RUN { \
|
||||||
|
@ -6,8 +6,6 @@ import Avatar from '@mui/material/Avatar';
|
|||||||
import Tabs from '@mui/material/Tabs';
|
import Tabs from '@mui/material/Tabs';
|
||||||
import Tab from '@mui/material/Tab';
|
import Tab from '@mui/material/Tab';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
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 AppBar from '@mui/material/AppBar';
|
||||||
import Drawer from '@mui/material/Drawer';
|
import Drawer from '@mui/material/Drawer';
|
||||||
import Toolbar from '@mui/material/Toolbar';
|
import Toolbar from '@mui/material/Toolbar';
|
||||||
@ -21,7 +19,7 @@ import { useTheme } from '@mui/material/styles';
|
|||||||
|
|
||||||
import { ResumeBuilder } from './ResumeBuilder';
|
import { ResumeBuilder } from './ResumeBuilder';
|
||||||
import { Message, ChatQuery, MessageList, MessageData } from './Message';
|
import { Message, ChatQuery, MessageList, MessageData } from './Message';
|
||||||
import { SetSnackType, SeverityType } from './Snack';
|
import { Snack, SeverityType } from './Snack';
|
||||||
import { VectorVisualizer } from './VectorVisualizer';
|
import { VectorVisualizer } from './VectorVisualizer';
|
||||||
import { Controls } from './Controls';
|
import { Controls } from './Controls';
|
||||||
import { Conversation, ConversationHandle } from './Conversation';
|
import { Conversation, ConversationHandle } from './Conversation';
|
||||||
@ -74,9 +72,6 @@ const App = () => {
|
|||||||
const [connectionBase,] = useState<string>(getConnectionBase(window.location))
|
const [connectionBase,] = useState<string>(getConnectionBase(window.location))
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [isMenuClosing, setIsMenuClosing] = 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 [tab, setTab] = useState<number>(0);
|
||||||
const [about, setAbout] = useState<string>("");
|
const [about, setAbout] = useState<string>("");
|
||||||
const [resume, setResume] = useState<MessageData | undefined>(undefined);
|
const [resume, setResume] = useState<MessageData | undefined>(undefined);
|
||||||
@ -86,15 +81,7 @@ const App = () => {
|
|||||||
const chatRef = useRef<ConversationHandle>(null);
|
const chatRef = useRef<ConversationHandle>(null);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
const snackRef = useRef<any>(null);
|
||||||
// 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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevIsDesktopRef.current === isDesktop)
|
if (prevIsDesktopRef.current === isDesktop)
|
||||||
@ -107,6 +94,10 @@ const App = () => {
|
|||||||
prevIsDesktopRef.current = isDesktop;
|
prevIsDesktopRef.current = isDesktop;
|
||||||
}, [isDesktop, setMenuOpen, menuOpen])
|
}, [isDesktop, setMenuOpen, menuOpen])
|
||||||
|
|
||||||
|
const setSnack = useCallback((message: string, severity?: SeverityType) => {
|
||||||
|
snackRef.current?.setSnack(message, severity);
|
||||||
|
}, [snackRef]);
|
||||||
|
|
||||||
// Get the About markdown
|
// Get the About markdown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (about !== "") {
|
if (about !== "") {
|
||||||
@ -143,9 +134,8 @@ const App = () => {
|
|||||||
const chatPreamble: MessageList = [
|
const chatPreamble: MessageList = [
|
||||||
{
|
{
|
||||||
role: 'content',
|
role: 'content',
|
||||||
|
title: 'Welcome to Backstory',
|
||||||
content: `
|
content: `
|
||||||
# Welcome to Backstory
|
|
||||||
|
|
||||||
Backstory is a RAG enabled expert system with access to real-time data running self-hosted
|
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).
|
(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
|
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>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSnackClose = (
|
|
||||||
event: React.SyntheticEvent | Event,
|
|
||||||
reason?: SnackbarCloseReason,
|
|
||||||
) => {
|
|
||||||
if (reason === 'clickaway') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSnackOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* toolbar height is 56px + 8px margin-top */
|
/* toolbar height is 56px + 8px margin-top */
|
||||||
const Offset = styled('div')(({ theme }) => ({ ...theme.mixins.toolbar, minHeight: '64px', height: '64px' }));
|
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>
|
</Box>
|
||||||
|
|
||||||
<Snackbar open={snackOpen} autoHideDuration={(snackSeverity === "success" || snackSeverity === "info") ? 1500 : 6000} onClose={handleSnackClose}>
|
<Snack
|
||||||
<Alert
|
ref={snackRef}
|
||||||
onClose={handleSnackClose}
|
/>
|
||||||
severity={snackSeverity}
|
|
||||||
variant="filled"
|
|
||||||
sx={{ width: '100%' }}
|
|
||||||
>
|
|
||||||
{snackMessage}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
</Box >
|
</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 { useTheme } from '@mui/material/styles';
|
||||||
import { SxProps, Theme } from '@mui/material';
|
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';
|
import { MessageRoles } from './Message';
|
||||||
|
|
||||||
interface ChatBubbleProps {
|
interface ChatBubbleProps {
|
||||||
@ -11,9 +16,10 @@ interface ChatBubbleProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
sx?: SxProps<Theme>;
|
sx?: SxProps<Theme>;
|
||||||
className?: string;
|
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 theme = useTheme();
|
||||||
|
|
||||||
const defaultRadius = '16px';
|
const defaultRadius = '16px';
|
||||||
@ -96,7 +102,7 @@ function ChatBubble({ role, isFullWidth, children, sx, className }: ChatBubblePr
|
|||||||
alignSelf: 'center', // Centered in the chat
|
alignSelf: 'center', // Centered in the chat
|
||||||
color: theme.palette.text.primary, // Charcoal Black for maximum readability
|
color: theme.palette.text.primary, // Charcoal Black for maximum readability
|
||||||
padding: '8px 8px', // More generous padding for better text framing
|
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
|
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.05)', // Subtle elevation
|
||||||
fontSize: '0.9rem', // Slightly smaller than default
|
fontSize: '0.9rem', // Slightly smaller than default
|
||||||
lineHeight: '1.3', // More compact line height
|
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 (
|
return (
|
||||||
<Box className={className} sx={{ ...styles[role], ...sx }}>
|
<Box className={className} sx={{ ...styles[role], ...sx }}>
|
||||||
{children}
|
{children}
|
||||||
|
@ -500,7 +500,7 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={className || "Conversation"} sx={{
|
<Box className={className || "Conversation"} sx={{
|
||||||
display: "flex", flexDirection: "column", flexGrow: 1, p: 1,
|
display: "flex", flexDirection: "column", flexGrow: 1, p: 1, mt: 1,
|
||||||
...sx
|
...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,
|
role: MessageRoles,
|
||||||
content: string,
|
content: string,
|
||||||
user?: string,
|
user?: string,
|
||||||
|
title?: string,
|
||||||
origin?: string,
|
origin?: string,
|
||||||
display?: string, /* Messages generated on the server for filler should not be shown */
|
display?: string, /* Messages generated on the server for filler should not be shown */
|
||||||
id?: string,
|
id?: string,
|
||||||
@ -250,8 +251,21 @@ const Message = ({ message, submitQuery, isFullWidth, sessionId, setSnack, conne
|
|||||||
const formattedContent = message.content.trim();
|
const formattedContent = message.content.trim();
|
||||||
|
|
||||||
return (
|
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" }}>
|
<ChatBubble
|
||||||
<CardContent ref={textFieldRef} sx={{ position: "relative", display: "flex", flexDirection: "column", overflowX: "auto" }}>
|
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>
|
<Tooltip title="Copy to clipboard" placement="top" arrow>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
@ -261,8 +275,9 @@ const Message = ({ message, submitQuery, isFullWidth, sessionId, setSnack, conne
|
|||||||
right: 0,
|
right: 0,
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
|
opacity: 0.75,
|
||||||
bgcolor: 'background.paper',
|
bgcolor: 'background.paper',
|
||||||
'&:hover': { bgcolor: 'action.hover' },
|
'&:hover': { bgcolor: 'action.hover', opacity: 1 },
|
||||||
}}
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
color={copied ? "success" : "default"}
|
color={copied ? "success" : "default"}
|
||||||
@ -288,7 +303,7 @@ const Message = ({ message, submitQuery, isFullWidth, sessionId, setSnack, conne
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
{message.metadata && <>
|
{message.metadata && <>
|
||||||
<CardActions disableSpacing sx={{ justifySelf: "flex-end" }}>
|
<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
|
<ExpandMore
|
||||||
expand={expanded}
|
expand={expanded}
|
||||||
onClick={handleExpandClick}
|
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 { 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 {
|
interface ResumeBuilderProps {
|
||||||
setProcessing: (processing: boolean) => void,
|
|
||||||
processing: boolean,
|
|
||||||
connectionBase: string,
|
connectionBase: string,
|
||||||
sessionId: string | undefined,
|
sessionId: string | undefined,
|
||||||
setSnack: (message: string, severity?: SeverityType) => void,
|
setSnack: (message: string, severity?: SeverityType) => void,
|
||||||
resume: MessageData | undefined,
|
|
||||||
setResume: (resume: MessageData | undefined) => void,
|
|
||||||
facts: MessageData | undefined,
|
|
||||||
setFacts: (facts: MessageData | undefined) => void,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// type Resume = {
|
const ResumeBuilder = ({ connectionBase, sessionId, setSnack }: ResumeBuilderProps) => {
|
||||||
// resume: MessageData | undefined,
|
|
||||||
// fact_check: MessageData | undefined,
|
|
||||||
// job_description: string,
|
|
||||||
// metadata: MessageMetaProps
|
|
||||||
// };
|
|
||||||
|
|
||||||
const ResumeBuilder = ({ facts, setFacts, resume, setResume, setProcessing, processing, connectionBase, sessionId, setSnack }: ResumeBuilderProps) => {
|
|
||||||
if (sessionId === undefined) {
|
if (sessionId === undefined) {
|
||||||
return (<></>);
|
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 SeverityType = 'error' | 'info' | 'success' | 'warning' | undefined;
|
||||||
type SetSnackType = (message: string, severity?: SeverityType) => void;
|
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 {
|
export type {
|
||||||
SeverityType,
|
SeverityType,
|
||||||
SetSnackType
|
SetSnackType
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
Snack
|
||||||
|
};
|
Loading…
x
Reference in New Issue
Block a user