Restyling in progress

This commit is contained in:
James Ketr 2025-04-26 13:17:47 -07:00
parent 0173657477
commit 52ec9fdcf0
10 changed files with 420 additions and 252 deletions

View File

@ -1,3 +1,7 @@
.App {
overflow: hidden;
}
div { div {
box-sizing: border-box; box-sizing: border-box;
overflow-wrap: break-word; overflow-wrap: break-word;
@ -114,18 +118,6 @@ button {
background-color: #D3CDBF; 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 { .user-message.MuiCard-root {
background-color: #DCF8C6; background-color: #DCF8C6;
border: 1px solid #B2E0A7; 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 useMediaQuery from '@mui/material/useMediaQuery';
import Card from '@mui/material/Card'; import Card from '@mui/material/Card';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
@ -23,8 +23,10 @@ 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';
import { useAutoScrollToBottom } from './AutoScroll';
import './App.css'; import './App.css';
import './Conversation.css';
import '@fontsource/roboto/300.css'; import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css'; import '@fontsource/roboto/400.css';
@ -33,7 +35,6 @@ import '@fontsource/roboto/700.css';
import MuiMarkdown from 'mui-markdown'; import MuiMarkdown from 'mui-markdown';
const getConnectionBase = (loc: any): string => { const getConnectionBase = (loc: any): string => {
if (!loc.host.match(/.*battle-linux.*/)) { if (!loc.host.match(/.*battle-linux.*/)) {
return loc.protocol + "//" + loc.host; return loc.protocol + "//" + loc.host;
@ -69,9 +70,10 @@ function CustomTabPanel(props: TabPanelProps) {
const App = () => { const App = () => {
const [sessionId, setSessionId] = useState<string | undefined>(undefined); const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [connectionBase,] = useState<string>(getConnectionBase(window.location)) const [connectionBase,] = useState<string>(getConnectionBase(window.location))
const [selectedPath, setSelectedPath] = useState<string>("");
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [isMenuClosing, setIsMenuClosing] = 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 [about, setAbout] = useState<string>("");
const isDesktop = useMediaQuery('(min-width:650px)'); const isDesktop = useMediaQuery('(min-width:650px)');
const prevIsDesktopRef = useRef<boolean>(isDesktop); const prevIsDesktopRef = useRef<boolean>(isDesktop);
@ -79,6 +81,7 @@ const App = () => {
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); const snackRef = useRef<any>(null);
const scrollRef = useAutoScrollToBottom();
useEffect(() => { useEffect(() => {
if (prevIsDesktopRef.current === isDesktop) if (prevIsDesktopRef.current === isDesktop)
@ -126,46 +129,19 @@ const App = () => {
const handleSubmitChatQuery = (query: string) => { const handleSubmitChatQuery = (query: string) => {
console.log(`handleSubmitChatQuery: ${query} -- `, chatRef.current ? ' sending' : 'no handler'); console.log(`handleSubmitChatQuery: ${query} -- `, chatRef.current ? ' sending' : 'no handler');
chatRef.current?.submitQuery(query); 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 // Extract the sessionId from the URL if present, otherwise
// request a sessionId from the server. // request a sessionId from the server.
const validPaths = useMemo(() => ['chat', 'notes', 'tasks'], []); // allowed paths
useEffect(() => { useEffect(() => {
const url = new URL(window.location.href); 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 { try {
const response = await fetch(connectionBase + `/api/context`, { const response = await fetch(connectionBase + `/api/context`, {
method: 'POST', method: 'POST',
@ -179,20 +155,31 @@ What would you like to know about James?
} }
const data = await response.json(); const data = await response.json();
setSessionId(data.id); setSessionId(data.id);
window.history.replaceState({}, '', `/${data.id}`);
const newPath = pathOverride || 'chat'; // default fallback
window.history.replaceState({}, '', `/${newPath}/${data.id}`);
} catch (error: any) { } catch (error: any) {
setSnack("Server is temporarily down", "error"); setSnack("Server is temporarily down", "error");
}; }
}; };
if (!pathParts.length) { if (pathParts.length < 2) {
console.log("No session id -- creating a new session") console.log("No session id or path -- creating new session");
fetchSession(); fetchSession();
} else { } else {
console.log(`Session id: ${pathParts[0]} -- existing session`) const currentPath = pathParts[0];
setSessionId(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 = () => { const handleMenuClose = () => {
setIsMenuClosing(true); setIsMenuClosing(true);
@ -209,24 +196,42 @@ What would you like to know about James?
} }
}; };
const settingsPanel = (
<>
{sessionId !== undefined &&
<Controls {...{ sessionId, setSnack, connectionBase }} />}
</>
);
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTab(newValue); setActiveTab(newValue);
handleMenuClose(); 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 = ( const menuDrawer = (
<Card className="MenuCard"> <Card className="MenuCard">
<Tabs sx={{ display: "flex", flexGrow: 1 }} <Tabs sx={{ display: "flex", flexGrow: 1 }}
orientation="vertical" orientation="vertical"
value={tab} value={activeTab}
indicatorColor="secondary" indicatorColor="secondary"
textColor="inherit" textColor="inherit"
variant="scrollable" variant="scrollable"
@ -265,11 +270,105 @@ What would you like to know about James?
</Card> </Card>
); );
/* toolbar height is 56px + 8px margin-top */ const tabs = useMemo(() => {
const Offset = styled('div')(({ theme }) => ({ ...theme.mixins.toolbar, minHeight: '64px', height: '64px' })); 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 [
<Box
sx={{
maxWidth: "1024px",
margin: "0 auto",
flexGrow: 1,
display: "flex",
height: "calc(100vh - 72px)",
overflow: "auto",
backgroundColor: "#F5F5F5",
}}
ref={scrollRef}
>
<Conversation
ref={chatRef}
{...{
type: "chat",
prompt: "What would you like to know about James?",
sessionId,
connectionBase,
setSnack,
preamble: chatPreamble,
defaultPrompts: chatQuestions
}}
/>
</Box>,
<ResumeBuilder sx={{
margin: "0 auto",
height: "calc(100vh - 72px)",
overflow: "auto",
backgroundColor: "#F5F5F5",
display: "flex",
flexGrow: 1
}} {...{ setSnack, connectionBase, sessionId }} />,
<Box
sx={{
maxWidth: "1024px",
margin: "0 auto",
flexGrow: 1,
display: "flex",
height: "calc(100vh - 72px)",
overflow: "auto",
backgroundColor: "#F5F5F5",
}}
ref={scrollRef}
>
<VectorVisualizer {...{ connectionBase, sessionId, setSnack }} />
</Box>,
<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, scrollRef]);
/* toolbar height is 64px + 8px margin-top */
const Offset = styled('div')(() => ({ minHeight: '72px', height: '72px' }));
return ( return (
<Box className="App" sx={{ display: 'flex', flexDirection: 'column', height: '100dvh' }}> <Box className="App"
sx={{ display: 'flex', flexDirection: 'column' }}>
<CssBaseline /> <CssBaseline />
<AppBar <AppBar
position="fixed" position="fixed"
@ -296,7 +395,7 @@ What would you like to know about James?
<Tooltip title="Backstory"> <Tooltip title="Backstory">
<Box <Box
sx={{ m: 1, gap: 1, display: "flex", flexDirection: "row", alignItems: "center", fontWeight: "bold", fontSize: "1.0rem", cursor: "pointer" }} 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={{ <Avatar sx={{
width: 24, width: 24,
@ -313,7 +412,7 @@ What would you like to know about James?
{menuOpen === false && isDesktop && {menuOpen === false && isDesktop &&
<Tabs sx={{ display: "flex", flexGrow: 1 }} <Tabs sx={{ display: "flex", flexGrow: 1 }}
value={tab} value={activeTab}
indicatorColor="secondary" indicatorColor="secondary"
textColor="inherit" textColor="inherit"
variant="fullWidth" variant="fullWidth"
@ -357,7 +456,9 @@ What would you like to know about James?
<Offset /> <Offset />
<Box sx={{ display: "flex", flexGrow: 1, flexDirection: "column" }}> <Box
sx={{ display: "flex", flexGrow: 1, flexDirection: "column" }}
>
<Box <Box
component="nav" component="nav"
aria-label="mailbox folders" aria-label="mailbox folders"
@ -382,52 +483,11 @@ What would you like to know about James?
{menuDrawer} {menuDrawer}
</Drawer> </Drawer>
</Box> </Box>
{
<CustomTabPanel tab={tab} index={0}> tabs.map((tab: any, i: number) =>
<Box component="main" sx={{ flexGrow: 1, overflow: 'auto' }} className="ChatBox"> <CustomTabPanel key={i} tab={activeTab} index={i}>{tab}</CustomTabPanel>
<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>
</Box> </Box>
<Snack <Snack

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

@ -0,0 +1,131 @@
import { useEffect, useRef, useState, RefObject } from 'react';
/**
* 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 [isUserScrollingUp, setIsUserScrollingUp] = useState<boolean>(false);
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;
// 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;
setIsUserScrollingUp(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);
}
};
}, [isUserScrollingUp, smooth, threshold]); // Re-run when dependencies change or scrolling state changes
return containerRef as RefObject<HTMLDivElement>;
};
export {
useAutoScrollToBottom
};

View File

@ -79,7 +79,7 @@ function ChatBubble({ role, isFullWidth, children, sx, className, title }: ChatB
...defaultStyle, ...defaultStyle,
backgroundColor: 'rgba(74, 122, 125, 0.15)', // Translucent dusty teal backgroundColor: 'rgba(74, 122, 125, 0.15)', // Translucent dusty teal
border: `1px solid ${theme.palette.secondary.light}`, // Lighter dusty teal border: `1px solid ${theme.palette.secondary.light}`, // Lighter dusty teal
borderRadius: defaultRadius, borderRadius: '4px',
maxWidth: isFullWidth ? '100%' : '75%', maxWidth: isFullWidth ? '100%' : '75%',
alignSelf: 'center', alignSelf: 'center',
color: theme.palette.secondary.dark, // Darker dusty teal for text 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 { Message, MessageList, MessageData } from './Message';
import { SetSnackType } from './Snack'; import { SetSnackType } from './Snack';
import { ContextStatus } from './ContextStatus'; import { ContextStatus } from './ContextStatus';
import { useAutoScrollToBottom } from './AutoScroll';
import './Conversation.css';
const loadingMessage: MessageData = { "role": "status", "content": "Establishing connection with server..." }; 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 [contextWarningShown, setContextWarningShown] = useState<boolean>(false);
const [noInteractions, setNoInteractions] = useState<boolean>(true); const [noInteractions, setNoInteractions] = useState<boolean>(true);
const conversationRef = useRef<MessageList>([]); const conversationRef = useRef<MessageList>([]);
const scrollRef = useAutoScrollToBottom();
// Keep the ref updated whenever items changes // Keep the ref updated whenever items changes
useEffect(() => { useEffect(() => {
@ -182,27 +186,6 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
fetchHistory(); fetchHistory();
}, [setConversation, setFilteredConversation, updateContextStatus, connectionBase, setSnack, type, sessionId]); }, [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) => { const startCountdown = (seconds: number) => {
if (timerRef.current) clearInterval(timerRef.current); if (timerRef.current) clearInterval(timerRef.current);
setCountdown(seconds); setCountdown(seconds);
@ -211,11 +194,6 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
if (prev <= 1) { if (prev <= 1) {
clearInterval(timerRef.current); clearInterval(timerRef.current);
timerRef.current = null; timerRef.current = null;
if (isScrolledToBottom()) {
setTimeout(() => {
scrollToBottom();
}, 50)
}
return 0; return 0;
} }
return prev - 1; return prev - 1;
@ -317,14 +295,10 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0));
console.log(conversation); console.log(conversation);
let scrolledToBottom;
scrollToBottom();
// Clear input // Clear input
setQuery(''); setQuery('');
try { try {
scrolledToBottom = isScrolledToBottom();
setProcessing(true); setProcessing(true);
// Create a unique ID for the processing message // Create a unique ID for the processing message
const processingId = Date.now().toString(); 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 // Add a small delay to ensure React has time to update the UI
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0));
if (scrolledToBottom) {
setTimeout(() => { scrollToBottom() }, 50);
}
// Make the fetch request with proper headers // Make the fetch request with proper headers
const response = await fetch(connectionBase + `/api/chat/${sessionId}/${type}`, { const response = await fetch(connectionBase + `/api/chat/${sessionId}/${type}`, {
method: 'POST', method: 'POST',
@ -355,12 +325,8 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
const token_guess = 500; const token_guess = 500;
const estimate = Math.round(token_guess / lastEvalTPS + contextStatus.context_used / lastPromptTPS); const estimate = Math.round(token_guess / lastEvalTPS + contextStatus.context_used / lastPromptTPS);
scrolledToBottom = isScrolledToBottom();
setSnack(`Query sent. Response estimated in ${estimate}s.`, "info"); setSnack(`Query sent. Response estimated in ${estimate}s.`, "info");
startCountdown(Math.round(estimate)); startCountdown(Math.round(estimate));
if (scrolledToBottom) {
setTimeout(() => { scrollToBottom() }, 50);
}
if (!response.ok) { if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`); 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 // Force an immediate state update based on the message type
if (update.status === 'processing') { if (update.status === 'processing') {
scrolledToBottom = isScrolledToBottom();
// Update processing message with immediate re-render // Update processing message with immediate re-render
setProcessingMessage({ role: 'status', content: update.message }); setProcessingMessage({ role: 'status', content: update.message });
// Add a small delay to ensure React has time to update the UI // Add a small delay to ensure React has time to update the UI
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0));
if (scrolledToBottom) {
setTimeout(() => { scrollToBottom() }, 50);
}
} else if (update.status === 'done') { } else if (update.status === 'done') {
// Replace processing message with final result // Replace processing message with final result
scrolledToBottom = isScrolledToBottom();
if (onResponse) { if (onResponse) {
update.message = onResponse(update.message); update.message = onResponse(update.message);
} }
@ -425,12 +386,8 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
setLastPromptTPS(promptTPS ? promptTPS : 35); setLastPromptTPS(promptTPS ? promptTPS : 35);
updateContextStatus(); updateContextStatus();
} }
if (scrolledToBottom) {
setTimeout(() => { scrollToBottom() }, 50);
}
} else if (update.status === 'error') { } else if (update.status === 'error') {
// Show error // Show error
scrolledToBottom = isScrolledToBottom();
setProcessingMessage({ role: 'error', content: update.message }); setProcessingMessage({ role: 'error', content: update.message });
setTimeout(() => { setTimeout(() => {
setProcessingMessage(undefined); setProcessingMessage(undefined);
@ -438,9 +395,6 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
// Add a small delay to ensure React has time to update the UI // Add a small delay to ensure React has time to update the UI
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0));
if (scrolledToBottom) {
setTimeout(() => { scrollToBottom() }, 50);
}
} }
} catch (e) { } catch (e) {
setSnack("Error processing query", "error") setSnack("Error processing query", "error")
@ -455,7 +409,6 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
const update = JSON.parse(buffer); const update = JSON.parse(buffer);
if (update.status === 'done') { if (update.status === 'done') {
scrolledToBottom = isScrolledToBottom();
if (onResponse) { if (onResponse) {
update.message = onResponse(update.message); update.message = onResponse(update.message);
} }
@ -464,25 +417,17 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
...conversationRef.current, ...conversationRef.current,
update.message update.message
]); ]);
if (scrolledToBottom) {
setTimeout(() => { scrollToBottom() }, 500);
}
} }
} catch (e) { } catch (e) {
setSnack("Error processing query", "error") setSnack("Error processing query", "error")
} }
} }
scrolledToBottom = isScrolledToBottom();
stopCountdown(); stopCountdown();
setProcessing(false); setProcessing(false);
if (scrolledToBottom) {
setTimeout(() => { scrollToBottom() }, 50);
}
} catch (error) { } catch (error) {
console.error('Fetch error:', error); console.error('Fetch error:', error);
setSnack("Unable to process query", "error"); setSnack("Unable to process query", "error");
scrolledToBottom = isScrolledToBottom();
setProcessingMessage({ role: 'error', content: "Unable to process query" }); setProcessingMessage({ role: 'error', content: "Unable to process query" });
setTimeout(() => { setTimeout(() => {
setProcessingMessage(undefined); setProcessingMessage(undefined);
@ -490,19 +435,18 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
setProcessing(false); setProcessing(false);
stopCountdown(); stopCountdown();
if (scrolledToBottom) {
setTimeout(() => { scrollToBottom() }, 50);
}
// Add a small delay to ensure React has time to update the UI // Add a small delay to ensure React has time to update the UI
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0));
} }
}; };
return ( return (
<Box className={className || "Conversation"} sx={{ <Box className={className || "Conversation"}
display: "flex", flexDirection: "column", flexGrow: 1, p: 1, mt: 1, ref={scrollRef}
...sx sx={{
}}> display: "flex", flexDirection: "column", flexGrow: 1, p: 1, mt: 0,
...sx
}}>
{ {
filteredConversation.map((message, index) => filteredConversation.map((message, index) =>
<Message key={index} {...{ sendQuery, message, connectionBase, sessionId, setSnack }} /> <Message key={index} {...{ sendQuery, message, connectionBase, sessionId, setSnack }} />

View File

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

View File

@ -18,38 +18,31 @@ import {
} from '@mui/icons-material'; } from '@mui/icons-material';
import { SxProps, Theme } from '@mui/material'; import { SxProps, Theme } from '@mui/material';
import { SeverityType } from './Snack';
import { ChatQuery } from './Message'; import { ChatQuery } from './Message';
import { MessageList, MessageData } from './Message'; import { MessageList, MessageData } from './Message';
import { SetSnackType } from './Snack'; import { SetSnackType } from './Snack';
import { Conversation } from './Conversation'; import { Conversation } from './Conversation';
/** interface ResumeBuilderProps {
* Props for the DocumentViewer component connectionBase: string,
* @interface DocumentViewerProps sessionId: string | undefined,
* @property {SxProps<Theme>} [sx] - Optional styling properties setSnack: SetSnackType,
* @property {string} [connectionBase] - Base URL for fetch calls
* @property {string} [sessionId] - Session ID
* @property {SetSnackType} - setSnack UI callback
*/
export interface DocumentViewerProps {
sx?: SxProps<Theme>; 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 * A responsive component that displays job descriptions, generated resumes and fact checks
* with different layouts for mobile and desktop views. * with different layouts for mobile and desktop views.
*/ */
const DocumentViewer: React.FC<DocumentViewerProps> = ({ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
sx, sx,
connectionBase, connectionBase,
sessionId, sessionId,
setSnack setSnack
}) => { }) => {
// State for editing job description // State for editing job description
const [hasJobDescription, setHasJobDescription] = useState<boolean>(false); const [hasJobDescription, setHasJobDescription] = useState<boolean>(false);
@ -339,26 +332,43 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
/> />
}, [connectionBase, sessionId, setSnack, factsResponse, filterFactsMessages]); }, [connectionBase, sessionId, setSnack, factsResponse, filterFactsMessages]);
/** /**
* Gets the appropriate content based on active state for Desktop * Gets the appropriate content based on active state for Desktop
*/ */
const getActiveDesktopContent = useCallback(() => { const getActiveDesktopContent = useCallback(() => {
/* Left panel - Job Description */ const hasSlider = hasResume || hasFacts;
const showResume = hasResume
const showFactCheck = hasFacts
const ratio = 75 + 25 * splitRatio / 100; 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 resumeRatio = 100 - otherRatio * (hasFacts ? 2 : 1);
const children = []; const children = [];
children.push( 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)} {renderJobDescriptionView(false)}
</Box>); </Box>);
/* Resume panel - conditionally rendered if resume defined, or processing is in progress */ /* Resume panel - conditionally rendered if resume defined, or processing is in progress */
if (showResume) { if (hasResume) {
children.push( 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 /> <Divider orientation="vertical" flexItem />
{renderResumeView(false)} {renderResumeView(false)}
</Box> </Box>
@ -366,9 +376,19 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
} }
/* Fact Check panel - conditionally rendered if facts defined, or processing is in progress */ /* Fact Check panel - conditionally rendered if facts defined, or processing is in progress */
if (showFactCheck) { if (hasFacts) {
children.push( 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 /> <Divider orientation="vertical" flexItem />
{renderFactCheckView(false)} {renderFactCheckView(false)}
</Box> </Box>
@ -377,7 +397,7 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
/* Split control panel - conditionally rendered if either facts or resume is set */ /* Split control panel - conditionally rendered if either facts or resume is set */
let slider = <Box key="slider"></Box>; let slider = <Box key="slider"></Box>;
if (showResume || showFactCheck) { if (hasSlider) {
slider = ( slider = (
<Paper key="slider" sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <Paper key="slider" sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ width: '60%' }}> <Stack direction="row" spacing={2} alignItems="center" sx={{ width: '60%' }}>
@ -406,14 +426,33 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
} }
return ( return (
<Box sx={{ ...sx, display: 'flex', flexGrow: 1, flexDirection: 'column', p: 0 }}> <Box sx={{
<Box sx={{ display: 'flex', flexGrow: 1, flexDirection: 'row', overflow: 'hidden', p: 0 }}> 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} {children}
</Box> </Box>
{slider} {slider}
</Box> </Box>
) )
}, [renderFactCheckView, renderJobDescriptionView, renderResumeView, splitRatio, sx, hasFacts, hasResume]); }, [renderFactCheckView, renderJobDescriptionView, renderResumeView, splitRatio, hasFacts, hasResume]);
// Render mobile view // Render mobile view
if (isMobile) { if (isMobile) {
@ -434,7 +473,18 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
}; };
return ( 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 */}
<Tabs <Tabs
value={activeTab} value={activeTab}
@ -455,41 +505,9 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
); );
} }
return ( return getActiveDesktopContent();
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, width: "100%", ...sx }}>
{getActiveDesktopContent()}
</Box>
);
}; };
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 { export type {
ResumeBuilderProps ResumeBuilderProps
}; };

View File

@ -7,6 +7,7 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
padding: 0; padding: 0;
height: 100dvh; height: 100dvh;
overflow: hidden;
} }
code { 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 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 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 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. Always use tools and [INFO] when possible. Be concise, and never make up information. If you do not know the answer, say so.
""".strip() """.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. 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. 9. Use the [WORK HISTORY] to create a polished, professional resume.
10. Do not list any locations or mailing addresses in the 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: 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: When answering queries, follow these steps:
1. You must not invent or assume any information not explicitly present in the [WORK HISTORY]. 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]. 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() """.strip()
system_job_description = f""" 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: When answering queries, follow these steps:
1. Analyze the [JOB DESCRIPTION] to provide insights for the asked question. 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. 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() """.strip()
def create_system_message(prompt): def create_system_message(prompt):
@ -1057,14 +1065,15 @@ class WebServer:
if rag_context: if rag_context:
preamble = f""" preamble = f"""
1. Respond to this query: {content} 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. 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] tag. 3. Avoid phrases like 'According to the [INFO]' or similar references to the [INFO], [JOB DESCRIPTION], or [WORK HISTORY] tags.
[INFO] [INFO]
{rag_context} {rag_context}
[/INFO] [/INFO]
Use that information to respond to:""" 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") # On first entry, a single job_description is provided ("user")
# Generate a resume to append to RESUME history # Generate a resume to append to RESUME history