Compare commits

...

3 Commits

Author SHA1 Message Date
889a3f9e3b Scrolling working
Plotly does not always size correctly
2025-04-26 13:54:52 -07:00
52ec9fdcf0 Restyling in progress 2025-04-26 13:17:47 -07:00
0173657477 Do not unmount tab content when switching tabs 2025-04-26 10:01:34 -07:00
11 changed files with 466 additions and 276 deletions

View File

@ -1,3 +1,7 @@
.App {
overflow: hidden;
}
div {
box-sizing: border-box;
overflow-wrap: break-word;
@ -114,18 +118,6 @@ button {
background-color: #D3CDBF;
}
.Conversation {
display: flex;
background-color: #F5F5F5;
border: 1px solid #E0E0E0;
flex-grow: 1;
padding: 10px;
flex-direction: column;
font-size: 0.9rem;
width: 100%;
margin: 0 auto;
}
.user-message.MuiCard-root {
background-color: #DCF8C6;
border: 1px solid #B2E0A7;

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import useMediaQuery from '@mui/material/useMediaQuery';
import Card from '@mui/material/Card';
import { styled } from '@mui/material/styles';
@ -23,8 +23,10 @@ import { Snack, SeverityType } from './Snack';
import { VectorVisualizer } from './VectorVisualizer';
import { Controls } from './Controls';
import { Conversation, ConversationHandle } from './Conversation';
import { Scrollable } from './AutoScroll';
import './App.css';
import './Conversation.css';
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
@ -33,7 +35,6 @@ import '@fontsource/roboto/700.css';
import MuiMarkdown from 'mui-markdown';
const getConnectionBase = (loc: any): string => {
if (!loc.host.match(/.*battle-linux.*/)) {
return loc.protocol + "//" + loc.host;
@ -61,7 +62,7 @@ function CustomTabPanel(props: TabPanelProps) {
aria-labelledby={`tab-${index}`}
{...other}
>
{tab === index && children}
{children}
</div>
);
}
@ -69,9 +70,10 @@ function CustomTabPanel(props: TabPanelProps) {
const App = () => {
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [connectionBase,] = useState<string>(getConnectionBase(window.location))
const [selectedPath, setSelectedPath] = useState<string>("");
const [menuOpen, setMenuOpen] = useState(false);
const [isMenuClosing, setIsMenuClosing] = useState(false);
const [tab, setTab] = useState<number>(0);
const [activeTab, setActiveTab] = useState<number>(0);
const [about, setAbout] = useState<string>("");
const isDesktop = useMediaQuery('(min-width:650px)');
const prevIsDesktopRef = useRef<boolean>(isDesktop);
@ -126,46 +128,19 @@ const App = () => {
const handleSubmitChatQuery = (query: string) => {
console.log(`handleSubmitChatQuery: ${query} -- `, chatRef.current ? ' sending' : 'no handler');
chatRef.current?.submitQuery(query);
setActiveTab(0);
};
const chatPreamble: MessageList = [
{
role: 'content',
title: 'Welcome to Backstory',
content: `
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
questions potential employers may have about his work history.
What would you like to know about James?
`
}
];
const chatQuestions = [
<Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row" }}>
<ChatQuery text="What is James Ketrenos' work history?" submitQuery={handleSubmitChatQuery} />
<ChatQuery text="What programming languages has James used?" submitQuery={handleSubmitChatQuery} />
<ChatQuery text="What are James' professional strengths?" submitQuery={handleSubmitChatQuery} />
<ChatQuery text="What are today's headlines on CNBC.com?" submitQuery={handleSubmitChatQuery} />
</Box>,
<Box sx={{ p: 1 }}>
<MuiMarkdown>
As with all LLM interactions, the results may not be 100% accurate. If you have questions about my career,
I'd love to hear from you. You can send me an email at **james_backstory@ketrenos.com**.
</MuiMarkdown>
</Box>
];
// Extract the sessionId from the URL if present, otherwise
// request a sessionId from the server.
const validPaths = useMemo(() => ['chat', 'notes', 'tasks'], []); // allowed paths
useEffect(() => {
const url = new URL(window.location.href);
const pathParts = url.pathname.split('/').filter(Boolean);
const pathParts = url.pathname.split('/').filter(Boolean); // [path, sessionId]
const fetchSession = async () => {
const fetchSession = async (pathOverride?: string) => {
try {
const response = await fetch(connectionBase + `/api/context`, {
method: 'POST',
@ -179,20 +154,31 @@ What would you like to know about James?
}
const data = await response.json();
setSessionId(data.id);
window.history.replaceState({}, '', `/${data.id}`);
const newPath = pathOverride || 'chat'; // default fallback
window.history.replaceState({}, '', `/${newPath}/${data.id}`);
} catch (error: any) {
setSnack("Server is temporarily down", "error");
};
}
};
if (!pathParts.length) {
console.log("No session id -- creating a new session")
if (pathParts.length < 2) {
console.log("No session id or path -- creating new session");
fetchSession();
} else {
console.log(`Session id: ${pathParts[0]} -- existing session`)
setSessionId(pathParts[0]);
const currentPath = pathParts[0];
const session = pathParts[1];
if (!validPaths.includes(currentPath)) {
console.log(`Invalid path "${currentPath}" -- redirecting to default`);
fetchSession(); // or you could window.location.replace if you want
} else {
console.log(`Path: ${currentPath}, Session id: ${session}`);
setSessionId(session);
setSelectedPath(currentPath);
}
}, [setSessionId, connectionBase, setSnack]);
}
}, [setSessionId, setSelectedPath, connectionBase, setSnack, validPaths]);
const handleMenuClose = () => {
setIsMenuClosing(true);
@ -209,24 +195,42 @@ What would you like to know about James?
}
};
const settingsPanel = (
<>
{sessionId !== undefined &&
<Controls {...{ sessionId, setSnack, connectionBase }} />}
</>
);
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTab(newValue);
setActiveTab(newValue);
handleMenuClose();
};
const handleTabSelect = (newPath: string) => {
if (!sessionId) return; // safety
setSelectedPath(newPath);
window.history.pushState({}, '', `/${newPath}/${sessionId}`);
};
useEffect(() => {
const handlePopState = () => {
const url = new URL(window.location.href);
const pathParts = url.pathname.split('/').filter(Boolean);
if (pathParts.length >= 2) {
const path = pathParts[0];
const session = pathParts[1];
if (validPaths.includes(path)) {
setSelectedPath(path);
setSessionId(session);
}
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [setSelectedPath, setSessionId, validPaths]);
const menuDrawer = (
<Card className="MenuCard">
<Tabs sx={{ display: "flex", flexGrow: 1 }}
orientation="vertical"
value={tab}
value={activeTab}
indicatorColor="secondary"
textColor="inherit"
variant="scrollable"
@ -265,11 +269,93 @@ What would you like to know about James?
</Card>
);
/* toolbar height is 56px + 8px margin-top */
const Offset = styled('div')(({ theme }) => ({ ...theme.mixins.toolbar, minHeight: '64px', height: '64px' }));
const tabs = useMemo(() => {
const chatPreamble: MessageList = [
{
role: 'content',
title: 'Welcome to Backstory',
content: `
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
questions potential employers may have about his work history.
What would you like to know about James?
`
}
];
const chatQuestions = [
<Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row" }}>
<ChatQuery text="What is James Ketrenos' work history?" submitQuery={handleSubmitChatQuery} />
<ChatQuery text="What programming languages has James used?" submitQuery={handleSubmitChatQuery} />
<ChatQuery text="What are James' professional strengths?" submitQuery={handleSubmitChatQuery} />
<ChatQuery text="What are today's headlines on CNBC.com?" submitQuery={handleSubmitChatQuery} />
</Box>,
<Box sx={{ p: 1 }}>
<MuiMarkdown>
As with all LLM interactions, the results may not be 100% accurate. If you have questions about my career,
I'd love to hear from you. You can send me an email at **james_backstory@ketrenos.com**.
</MuiMarkdown>
</Box>
];
return [
<Scrollable
sx={{
maxWidth: "1024px",
height: "calc(100vh - 72px)",
}}
>
<Conversation
ref={chatRef}
{...{
type: "chat",
prompt: "What would you like to know about James?",
sessionId,
connectionBase,
setSnack,
preamble: chatPreamble,
defaultPrompts: chatQuestions
}}
/>
</Scrollable>,
<ResumeBuilder sx={{
margin: "0 auto",
height: "calc(100vh - 72px)",
overflow: "auto",
backgroundColor: "#F5F5F5",
display: "flex",
flexGrow: 1
}} {...{ setSnack, connectionBase, sessionId }} />,
<Scrollable
sx={{
maxWidth: "1024px",
height: "calc(100vh - 72px)",
}}
>
<VectorVisualizer {...{ connectionBase, sessionId, setSnack }} />
</Scrollable>,
<Box className="ChatBox">
<Box className="Conversation">
<Message {...{ message: { role: 'content', content: about }, submitQuery: handleSubmitChatQuery, connectionBase, sessionId, setSnack }} />
</Box>
</Box>,
<Box className="ChatBox">
{sessionId !== undefined &&
<Controls {...{ sessionId, setSnack, connectionBase }} />
}
</Box>
];
}, [about, connectionBase, sessionId, setSnack, isMobile]);
/* toolbar height is 64px + 8px margin-top */
const Offset = styled('div')(() => ({ minHeight: '72px', height: '72px' }));
return (
<Box className="App" sx={{ display: 'flex', flexDirection: 'column', height: '100dvh' }}>
<Box className="App"
sx={{ display: 'flex', flexDirection: 'column' }}>
<CssBaseline />
<AppBar
position="fixed"
@ -296,7 +382,7 @@ What would you like to know about James?
<Tooltip title="Backstory">
<Box
sx={{ m: 1, gap: 1, display: "flex", flexDirection: "row", alignItems: "center", fontWeight: "bold", fontSize: "1.0rem", cursor: "pointer" }}
onClick={() => { setTab(0); setMenuOpen(false); }}
onClick={() => { setActiveTab(0); setMenuOpen(false); }}
>
<Avatar sx={{
width: 24,
@ -313,7 +399,7 @@ What would you like to know about James?
{menuOpen === false && isDesktop &&
<Tabs sx={{ display: "flex", flexGrow: 1 }}
value={tab}
value={activeTab}
indicatorColor="secondary"
textColor="inherit"
variant="fullWidth"
@ -357,7 +443,9 @@ What would you like to know about James?
<Offset />
<Box sx={{ display: "flex", flexGrow: 1, flexDirection: "column" }}>
<Box
sx={{ display: "flex", flexGrow: 1, flexDirection: "column" }}
>
<Box
component="nav"
aria-label="mailbox folders"
@ -382,52 +470,11 @@ What would you like to know about James?
{menuDrawer}
</Drawer>
</Box>
<CustomTabPanel tab={tab} index={0}>
<Box component="main" sx={{ flexGrow: 1, overflow: 'auto' }} className="ChatBox">
<Conversation
ref={chatRef}
{...{
type: "chat",
prompt: "What would you like to know about James?",
sessionId,
connectionBase,
setSnack,
preamble: chatPreamble,
defaultPrompts: chatQuestions
}}
/>
</Box>
</CustomTabPanel>
<CustomTabPanel tab={tab} index={1}>
<ResumeBuilder {...{ setSnack, connectionBase, sessionId }} />
</CustomTabPanel>
<CustomTabPanel tab={tab} index={2}>
<Box className="ChatBox">
<Box className="Conversation">
<VectorVisualizer {...{ connectionBase, sessionId, setSnack }} />
</Box>
</Box>
</CustomTabPanel>
<CustomTabPanel tab={tab} index={3}>
<Box className="ChatBox">
<Box className="Conversation">
<Message {...{ message: { role: 'assistant', content: about }, submitQuery: handleSubmitChatQuery, connectionBase, sessionId, setSnack }} />
</Box>
</Box>
</CustomTabPanel>
<CustomTabPanel tab={tab} index={4}>
<Box className="ChatBox">
<Box className="Conversation">
{ settingsPanel }
</Box>
</Box>
</CustomTabPanel>
{
tabs.map((tab: any, i: number) =>
<CustomTabPanel key={i} tab={activeTab} index={i}>{tab}</CustomTabPanel>
)
}
</Box>
<Snack

154
frontend/src/AutoScroll.tsx Normal file
View File

@ -0,0 +1,154 @@
import { useEffect, useRef, useState, RefObject } from 'react';
import Box from '@mui/material/Box';
import { SxProps, Theme } from '@mui/material';
/**
* Hook that automatically scrolls a container to the bottom when content changes
* or when the container is resized, but only if the user is already near the bottom.
*
* @param threshold - Distance from bottom (px) to consider "near bottom" (default: 100)
* @param smooth - Whether to use smooth scrolling (default: true)
* @returns Ref to attach to the scrollable container
*/
const useAutoScrollToBottom = (
threshold: number = 0.33, // Percentage of viewport to trigger threshold
smooth: boolean = true
): RefObject<HTMLDivElement> => {
const containerRef = useRef<HTMLDivElement>(null);
const lastScrollTop = useRef<number>(0);
const scrollTimeout = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) {
// console.log("No ref");
return;
}
const lastScrollHeight = container.scrollHeight;
var isUserScrollingUp = false;
// Function to check if we should scroll to bottom
const checkAndScrollToBottom = (priorScrollHeight?: number | undefined): void => {
if (!container) return;
const scrollHeight = (priorScrollHeight !== undefined) ? priorScrollHeight : container.scrollHeight;
// Only auto-scroll if the user is near the bottom and not actively scrolling up
const isNearBottom: boolean =
scrollHeight - container.scrollTop - container.clientHeight <= container.clientHeight * threshold;
if (isNearBottom && !isUserScrollingUp) {
// console.log('Scrolling', {
// isNearBottom,
// isUserScrollingUp,
// scrollHeightToUser: scrollHeight,
// scrollHeight: container.scrollHeight,
// scrollTop: container.scrollTop,
// clientHeight: container.clientHeight,
// threshold,
// delta: container.scrollHeight - container.scrollTop - container.clientHeight
// });
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto'
});
} else {
// console.log('Not scrolling', {
// isNearBottom,
// isUserScrollingUp,
// scrollHeight: container.scrollHeight,
// scrollTop: container.scrollTop,
// clientHeight: container.clientHeight,
// threshold,
// delta: container.scrollHeight - container.scrollTop - container.clientHeight
// });
}
};
// Set up ResizeObserver to detect content size changes
const resizeObserver = new ResizeObserver(() => {
const container = containerRef.current;
if (!container) {
return;
}
checkAndScrollToBottom(lastScrollHeight);
});
// Observe the container and its children
resizeObserver.observe(container);
Array.from(container.children).forEach((child) => {
resizeObserver.observe(child);
});
// Track user scrolling behavior
const handleScroll = (): void => {
if (!container) {
// console.log("No ref in handleScroll");
return;
}
// Clear existing timeout
if (scrollTimeout.current) {
clearTimeout(scrollTimeout.current);
}
// Determine scroll direction
const currentScrollTop = container.scrollTop;
isUserScrollingUp = currentScrollTop < lastScrollTop.current;
lastScrollTop.current = currentScrollTop;
// Reset the scrolling flag after user stops scrolling
scrollTimeout.current = setTimeout(() => {
//setIsUserScrollingUp(false);
}, 500);
};
// Add scroll event listener
container.addEventListener('scroll', handleScroll);
// Run initial check
checkAndScrollToBottom();
// Cleanup
return () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
if (container) {
container.removeEventListener('scroll', handleScroll);
}
if (scrollTimeout.current) {
clearTimeout(scrollTimeout.current);
}
};
}, [smooth, threshold]); // Re-run when dependencies change or scrolling state changes
return containerRef as RefObject<HTMLDivElement>;
};
interface ScrollableProps {
children?: React.ReactNode;
sx?: SxProps<Theme>,
}
const Scrollable = ({ sx, children }: ScrollableProps) => {
const scrollRef = useAutoScrollToBottom();
return <Box
sx={{
display: "flex",
margin: "0 auto",
flexGrow: 1,
overflow: "auto",
backgroundColor: "#F5F5F5",
...sx
}}
ref={scrollRef}
>{children}</Box>;
};
export {
useAutoScrollToBottom,
Scrollable,
};

View File

@ -79,7 +79,7 @@ function ChatBubble({ role, isFullWidth, children, sx, className, title }: ChatB
...defaultStyle,
backgroundColor: 'rgba(74, 122, 125, 0.15)', // Translucent dusty teal
border: `1px solid ${theme.palette.secondary.light}`, // Lighter dusty teal
borderRadius: defaultRadius,
borderRadius: '4px',
maxWidth: isFullWidth ? '100%' : '75%',
alignSelf: 'center',
color: theme.palette.secondary.dark, // Darker dusty teal for text

View File

@ -0,0 +1,13 @@
.Conversation {
display: flex;
background-color: #F5F5F5;
border: 1px solid #E0E0E0;
flex-grow: 1;
padding: 10px;
flex-direction: column;
font-size: 0.9rem;
width: 100%;
margin: 0 auto;
overflow-y: auto;
}

View File

@ -13,6 +13,9 @@ import PropagateLoader from "react-spinners/PropagateLoader";
import { Message, MessageList, MessageData } from './Message';
import { SetSnackType } from './Snack';
import { ContextStatus } from './ContextStatus';
import { useAutoScrollToBottom } from './AutoScroll';
import './Conversation.css';
const loadingMessage: MessageData = { "role": "status", "content": "Establishing connection with server..." };
@ -79,6 +82,7 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
const [contextWarningShown, setContextWarningShown] = useState<boolean>(false);
const [noInteractions, setNoInteractions] = useState<boolean>(true);
const conversationRef = useRef<MessageList>([]);
const scrollRef = useAutoScrollToBottom();
// Keep the ref updated whenever items changes
useEffect(() => {
@ -182,27 +186,6 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
fetchHistory();
}, [setConversation, setFilteredConversation, updateContextStatus, connectionBase, setSnack, type, sessionId]);
const isScrolledToBottom = useCallback(()=> {
// Current vertical scroll position
const scrollTop = window.scrollY || document.documentElement.scrollTop;
// Total height of the page content
const scrollHeight = document.documentElement.scrollHeight;
// Height of the visible window
const clientHeight = document.documentElement.clientHeight;
// If we're at the bottom (allowing a small buffer of 16px)
return scrollTop + clientHeight >= scrollHeight - 16;
}, []);
const scrollToBottom = useCallback(() => {
console.log("Scroll to bottom");
window.scrollTo({
top: document.body.scrollHeight,
});
}, []);
const startCountdown = (seconds: number) => {
if (timerRef.current) clearInterval(timerRef.current);
setCountdown(seconds);
@ -211,11 +194,6 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
if (prev <= 1) {
clearInterval(timerRef.current);
timerRef.current = null;
if (isScrolledToBottom()) {
setTimeout(() => {
scrollToBottom();
}, 50)
}
return 0;
}
return prev - 1;
@ -317,14 +295,10 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
await new Promise(resolve => setTimeout(resolve, 0));
console.log(conversation);
let scrolledToBottom;
scrollToBottom();
// Clear input
setQuery('');
try {
scrolledToBottom = isScrolledToBottom();
setProcessing(true);
// Create a unique ID for the processing message
const processingId = Date.now().toString();
@ -337,10 +311,6 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
// Add a small delay to ensure React has time to update the UI
await new Promise(resolve => setTimeout(resolve, 0));
if (scrolledToBottom) {
setTimeout(() => { scrollToBottom() }, 50);
}
// Make the fetch request with proper headers
const response = await fetch(connectionBase + `/api/chat/${sessionId}/${type}`, {
method: 'POST',
@ -355,12 +325,8 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
const token_guess = 500;
const estimate = Math.round(token_guess / lastEvalTPS + contextStatus.context_used / lastPromptTPS);
scrolledToBottom = isScrolledToBottom();
setSnack(`Query sent. Response estimated in ${estimate}s.`, "info");
startCountdown(Math.round(estimate));
if (scrolledToBottom) {
setTimeout(() => { scrollToBottom() }, 50);
}
if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
@ -395,17 +361,12 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
// Force an immediate state update based on the message type
if (update.status === 'processing') {
scrolledToBottom = isScrolledToBottom();
// Update processing message with immediate re-render
setProcessingMessage({ role: 'status', content: update.message });
// Add a small delay to ensure React has time to update the UI
await new Promise(resolve => setTimeout(resolve, 0));
if (scrolledToBottom) {
setTimeout(() => { scrollToBottom() }, 50);
}
} else if (update.status === 'done') {
// Replace processing message with final result
scrolledToBottom = isScrolledToBottom();
if (onResponse) {
update.message = onResponse(update.message);
}
@ -425,12 +386,8 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
setLastPromptTPS(promptTPS ? promptTPS : 35);
updateContextStatus();
}
if (scrolledToBottom) {
setTimeout(() => { scrollToBottom() }, 50);
}
} else if (update.status === 'error') {
// Show error
scrolledToBottom = isScrolledToBottom();
setProcessingMessage({ role: 'error', content: update.message });
setTimeout(() => {
setProcessingMessage(undefined);
@ -438,9 +395,6 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
// Add a small delay to ensure React has time to update the UI
await new Promise(resolve => setTimeout(resolve, 0));
if (scrolledToBottom) {
setTimeout(() => { scrollToBottom() }, 50);
}
}
} catch (e) {
setSnack("Error processing query", "error")
@ -455,7 +409,6 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
const update = JSON.parse(buffer);
if (update.status === 'done') {
scrolledToBottom = isScrolledToBottom();
if (onResponse) {
update.message = onResponse(update.message);
}
@ -464,25 +417,17 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
...conversationRef.current,
update.message
]);
if (scrolledToBottom) {
setTimeout(() => { scrollToBottom() }, 500);
}
}
} catch (e) {
setSnack("Error processing query", "error")
}
}
scrolledToBottom = isScrolledToBottom();
stopCountdown();
setProcessing(false);
if (scrolledToBottom) {
setTimeout(() => { scrollToBottom() }, 50);
}
} catch (error) {
console.error('Fetch error:', error);
setSnack("Unable to process query", "error");
scrolledToBottom = isScrolledToBottom();
setProcessingMessage({ role: 'error', content: "Unable to process query" });
setTimeout(() => {
setProcessingMessage(undefined);
@ -490,17 +435,16 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
setProcessing(false);
stopCountdown();
if (scrolledToBottom) {
setTimeout(() => { scrollToBottom() }, 50);
}
// Add a small delay to ensure React has time to update the UI
await new Promise(resolve => setTimeout(resolve, 0));
}
};
return (
<Box className={className || "Conversation"} sx={{
display: "flex", flexDirection: "column", flexGrow: 1, p: 1, mt: 1,
<Box className={className || "Conversation"}
ref={scrollRef}
sx={{
display: "flex", flexDirection: "column", flexGrow: 1, p: 1, mt: 0,
...sx
}}>
{

View File

@ -248,7 +248,7 @@ const Message = ({ message, submitQuery, isFullWidth, sessionId, setSnack, conne
m: 0,
mb: 1,
mt: 1,
overflowX: "auto"
// overflowX: "auto"
}}>
<CardContent ref={textFieldRef} sx={{ position: "relative", display: "flex", flexDirection: "column", overflowX: "auto", m: 0, p: 0 }}>
<CopyBubble content={message?.content} />

View File

@ -18,38 +18,31 @@ import {
} from '@mui/icons-material';
import { SxProps, Theme } from '@mui/material';
import { SeverityType } from './Snack';
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 {
interface ResumeBuilderProps {
connectionBase: string,
sessionId: string | undefined,
setSnack: SetSnackType,
sx?: SxProps<Theme>;
connectionBase: string;
sessionId: string;
setSnack: SetSnackType;
}
};
/**
* DocumentViewer component
* ResumeBuilder 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> = ({
const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
sx,
connectionBase,
sessionId,
setSnack
}) => {
// State for editing job description
const [hasJobDescription, setHasJobDescription] = useState<boolean>(false);
@ -229,6 +222,7 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
}, []);
const renderJobDescriptionView = useCallback((small: boolean) => {
console.log('renderJobDescriptionView');
const jobDescriptionQuestions = [
<Box sx={{ display: "flex", flexDirection: small ? "column" : "row" }}>
<ChatQuery text="What are the key skills necessary for this position?" submitQuery={handleJobQuery} />
@ -339,26 +333,43 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
/>
}, [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 hasSlider = hasResume || hasFacts;
const ratio = 75 + 25 * splitRatio / 100;
const otherRatio = showResume ? ratio / (hasFacts ? 3 : 2) : 100;
const otherRatio = hasResume ? ratio / (hasFacts ? 3 : 2) : 100;
const resumeRatio = 100 - otherRatio * (hasFacts ? 2 : 1);
const children = [];
children.push(
<Box key="JobDescription" className="ChatBox" sx={{ display: 'flex', flexDirection: 'column', minWidth: `${otherRatio}%`, width: `${otherRatio}%`, maxWidth: `${otherRatio}%`, p: 0, flexGrow: 1, overflowY: 'auto' }}>
<Box key="JobDescription" className="ChatBox" sx={{
display: 'flex',
flexDirection: 'column',
minWidth: `${otherRatio}%`,
width: `${otherRatio}%`,
maxWidth: `${otherRatio}%`,
p: 0,
flexGrow: 1,
}}>
{renderJobDescriptionView(false)}
</Box>);
/* Resume panel - conditionally rendered if resume defined, or processing is in progress */
if (showResume) {
if (hasResume) {
children.push(
<Box key="ResumeView" className="ChatBox" sx={{ display: 'flex', flexDirection: 'column', minWidth: `${resumeRatio}%`, width: `${resumeRatio}%`, maxWidth: `${resumeRatio}%`, p: 0, flexGrow: 1, overflowY: 'auto' }}>
<Box key="ResumeView"
className="ChatBox"
sx={{
display: 'flex',
flexDirection: 'column',
minWidth: `${resumeRatio}%`,
width: `${resumeRatio}%`,
maxWidth: `${resumeRatio}%`,
p: 0,
flexGrow: 1
}}>
<Divider orientation="vertical" flexItem />
{renderResumeView(false)}
</Box>
@ -366,9 +377,19 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
}
/* Fact Check panel - conditionally rendered if facts defined, or processing is in progress */
if (showFactCheck) {
if (hasFacts) {
children.push(
<Box key="FactCheckView" className="ChatBox" sx={{ display: 'flex', flexDirection: 'column', minWidth: `${otherRatio}%`, width: `${otherRatio}%`, maxWidth: `${otherRatio}%`, p: 0, flexGrow: 1, overflowY: 'auto' }}>
<Box key="FactCheckView"
className="ChatBox"
sx={{
display: 'flex',
flexDirection: 'column',
minWidth: `${otherRatio}%`,
width: `${otherRatio}%`,
maxWidth: `${otherRatio}%`,
p: 0,
flexGrow: 1,
}}>
<Divider orientation="vertical" flexItem />
{renderFactCheckView(false)}
</Box>
@ -377,7 +398,7 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
/* Split control panel - conditionally rendered if either facts or resume is set */
let slider = <Box key="slider"></Box>;
if (showResume || showFactCheck) {
if (hasSlider) {
slider = (
<Paper key="slider" sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ width: '60%' }}>
@ -406,35 +427,49 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
}
return (
<Box sx={{ ...sx, display: 'flex', flexGrow: 1, flexDirection: 'column', p: 0 }}>
<Box sx={{ display: 'flex', flexGrow: 1, flexDirection: 'row', overflow: 'hidden', p: 0 }}>
<Box sx={{
p: 0,
m: 0,
display: "flex",
flexGrow: 1,
flexDirection: "column",
}}>
<Box sx={{
display: 'flex',
flexGrow: 1,
flexDirection: 'row',
overflow: 'hidden',
p: 0,
m: 0,
margin: "0 auto",
maxWidth: hasSlider ? "100%" : "1024px",
width: hasSlider ? "100%" : "1024px",
height: `calc(100vh - ${hasSlider ? 144 : 72}px)`,
backgroundColor: "#F5F5F5",
}}
>
{children}
</Box>
{slider}
</Box>
)
}, [renderFactCheckView, renderJobDescriptionView, renderResumeView, splitRatio, sx, hasFacts, hasResume]);
}, [renderFactCheckView, renderJobDescriptionView, renderResumeView, splitRatio, hasFacts, hasResume]);
// Render mobile view
if (isMobile) {
/**
* Gets the appropriate content based on active tab
*/
const getActiveMobileContent = () => {
switch (activeTab) {
case 0:
return renderJobDescriptionView(true);
case 1:
return renderResumeView(true);
case 2:
return renderFactCheckView(true);
default:
return renderJobDescriptionView(true);
}
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, ...sx }}>
<Box sx={{
p: 0,
m: 0,
display: "flex",
flexGrow: 1,
margin: "0 auto",
overflow: "hidden",
height: "calc(100vh - 72px)",
backgroundColor: "#F5F5F5",
flexDirection: "column"
}}
>
{/* Tabs */}
<Tabs
value={activeTab}
@ -449,47 +484,17 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
{/* Document display area */}
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, p: 0, width: "100%", ...sx }}>
{getActiveMobileContent()}
<Box sx={{ display: activeTab === 0 ? "flex" : "none" }}>{renderJobDescriptionView(true)}</Box>
<Box sx={{ display: activeTab === 1 ? "flex" : "none" }}>{renderResumeView(true)}</Box>
<Box sx={{ display: activeTab === 2 ? "flex" : "none" }}>{renderFactCheckView(true)}</Box>
</Box>
</Box>
);
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, width: "100%", ...sx }}>
{getActiveDesktopContent()}
</Box>
);
return getActiveDesktopContent();
};
interface ResumeBuilderProps {
connectionBase: string,
sessionId: string | undefined,
setSnack: (message: string, severity?: SeverityType) => void,
};
const ResumeBuilder = ({ connectionBase, sessionId, setSnack }: ResumeBuilderProps) => {
if (sessionId === undefined) {
return (<></>);
}
return (
<Box className="DocBox">
<Box className="Conversation" sx={{ p: 0, pt: 1 }}>
<DocumentViewer sx={{
p: 0,
m: 0,
display: "flex",
flexGrow: 1,
overflowY: "auto",
flexDirection: "column",
height: "calc(0vh - 0px)", // Hack to make the height work
}} {...{ setSnack, connectionBase, sessionId }} />
</Box>
</Box>
);
}
export type {
ResumeBuilderProps
};

View File

@ -12,6 +12,8 @@ import Switch from '@mui/material/Switch';
import { SetSnackType } from './Snack';
import './VectorVisualizer.css';
interface Metadata {
doc_type?: string;
[key: string]: any;
@ -299,16 +301,30 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, rag, inli
);
return (
<Box className="VectorVisualizer" sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, overflow: 'hidden' }}>
<Box className="VectorVisualizer"
sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1
}}>
{
!inline &&
<Card sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', mb: 1, pt: 0 }}>
<Card sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, minHeight: '2.5rem', justifyContent: 'center', alignItems: 'center', m: 0, p: 0, mb: 1 }}>
<Typography variant="h6" sx={{ p: 1, pt: 0 }}>
Similarity Visualization via Uniform Manifold Approximation and Projection (UMAP)
</Typography>
</Card>
}
<FormControlLabel sx={{ display: "inline-flex", width: "fit-content", mb: '-2.5rem', zIndex: 100, ml: 1, flexBasis: 0, flexGrow: 0 }} control={<Switch checked={!view2D} />} onChange={() => setView2D(!view2D)} label="3D" />
<FormControlLabel sx={{
display: "inline-flex",
width: "fit-content",
mb: '-2.5rem',
zIndex: 100,
ml: 1,
flexBasis: 0,
flexGrow: 0
}}
control={<Switch checked={!view2D} />} onChange={() => setView2D(!view2D)} label="3D" />
<Plot
onClick={(event: any) => {
const point = event.points[0];
@ -323,7 +339,6 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, rag, inli
content: `${emoji} ${type.toUpperCase()}\n${text}`,
});
}}
data={[plotData.data]}
useResizeHandler={true}
config={{
@ -333,7 +348,17 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, rag, inli
showSendToCloud: false,
staticPlot: false,
}}
style={{ display: "flex", flexGrow: 1, justifyContent: 'center', alignItems: 'center', minHeight: '30vh', height: '30vh', padding: 0, margin: 0 }}
style={{
display: "flex",
flexGrow: 1,
justifyContent: 'center',
alignItems: 'center',
minHeight: '240px',
padding: 0,
margin: 0,
width: "100%",
height: "100%",
}}
layout={plotData.layout}
/>
{!inline &&
@ -389,7 +414,7 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, rag, inli
);
};
export type { VectorVisualizerProps, ResultData, Metadata };
export type { VectorVisualizerProps, ResultData };
export {
VectorVisualizer

View File

@ -7,6 +7,7 @@ body {
-moz-osx-font-smoothing: grayscale;
padding: 0;
height: 100dvh;
overflow: hidden;
}
code {

View File

@ -161,6 +161,8 @@ When answering queries, follow these steps:
4. When both [INFO] and tool outputs are relevant, synthesize information from both sources to provide the most complete answer
5. Always prioritize the most up-to-date and relevant information, whether it comes from [INFO] or tools
6. If [INFO] and tool outputs contain conflicting information, prefer the tool outputs as they likely represent more current data
7. If there is information in the [INFO], [JOB DESCRIPTION], or [WORK HISTORY] sections to enhance the answer, incorporate it seamlessly and refer to it as 'the latest information' or 'recent data' instead of mentioning '[INFO]' (etc.) or quoting it directly.
8. Avoid phrases like 'According to the [INFO]' or similar references to the [INFO], [JOB DESCRIPTION], or [WORK HISTORY] tags.
Always use tools and [INFO] when possible. Be concise, and never make up information. If you do not know the answer, say so.
""".strip()
@ -182,6 +184,8 @@ When answering queries, follow these steps:
8. Use the [INTRO] to highlight the use of AI in generating this resume.
9. Use the [WORK HISTORY] to create a polished, professional resume.
10. Do not list any locations or mailing addresses in the resume.
11. If there is information in the [INFO], [JOB DESCRIPTION], [WORK HISTORY], or [RESUME] sections to enhance the answer, incorporate it seamlessly and refer to it using natural language instead of mentioning '[JOB DESCRIPTION]' (etc.) or quoting it directly.
12. Avoid phrases like 'According to the [INFO]' or similar references to the [INFO], [JOB DESCRIPTION], or [WORK HISTORY] tags.
Structure the resume professionally with the following sections where applicable:
@ -205,6 +209,8 @@ If there are inaccuracies, list them in a bullet point format.
When answering queries, follow these steps:
1. You must not invent or assume any information not explicitly present in the [WORK HISTORY].
2. Analyze the [RESUME] to identify any discrepancies or inaccuracies based on the [WORK HISTORY].
3. If there is information in the [INFO], [JOB DESCRIPTION], [WORK HISTORY], or [RESUME] sections to enhance the answer, incorporate it seamlessly and refer to it using natural language instead of mentioning '[JOB DESCRIPTION]' (etc.) or quoting it directly.
4. Avoid phrases like 'According to the [INFO]' or similar references to the [INFO], [JOB DESCRIPTION], [RESUME], or [WORK HISTORY] tags.
""".strip()
system_job_description = f"""
@ -215,6 +221,8 @@ You are a hiring and job placing specialist. Your task is to answers about a job
When answering queries, follow these steps:
1. Analyze the [JOB DESCRIPTION] to provide insights for the asked question.
2. If any financial information is requested, be sure to account for inflation.
3. If there is information in the [INFO], [JOB DESCRIPTION], [WORK HISTORY], or [RESUME] sections to enhance the answer, incorporate it seamlessly and refer to it using natural language instead of mentioning '[JOB DESCRIPTION]' (etc.) or quoting it directly.
4. Avoid phrases like 'According to the [INFO]' or similar references to the [INFO], [JOB DESCRIPTION], [RESUME], or [WORK HISTORY] tags.
""".strip()
def create_system_message(prompt):
@ -1057,14 +1065,15 @@ class WebServer:
if rag_context:
preamble = f"""
1. Respond to this query: {content}
2. If there is information in the [INFO] section to enhance the answer, incorporate it seamlessly and refer to it as 'the latest information' or 'recent data' instead of mentioning '[INFO]' or quoting it directly.
3. Avoid phrases like 'According to the [INFO]' or similar references to the [INFO] tag.
2. If there is information in the [INFO], [JOB DESCRIPTION], [WORK HISTORY], or [RESUME] sections to enhance the answer, incorporate it seamlessly and refer to it using natural language instead of mentioning '[JOB DESCRIPTION]' (etc.) or quoting it directly.
3. Avoid phrases like 'According to the [INFO]' or similar references to the [INFO], [JOB DESCRIPTION], or [WORK HISTORY] tags.
[INFO]
{rag_context}
[/INFO]
Use that information to respond to:"""
system_prompt = context["sessions"]["chat"]["system_prompt"]
# Use the mode specific system_prompt instead of 'chat'
system_prompt = context["sessions"][type]["system_prompt"]
# On first entry, a single job_description is provided ("user")
# Generate a resume to append to RESUME history