No more observer resize errors

This commit is contained in:
James Ketr 2025-05-07 14:39:40 -07:00
parent 2ee1356189
commit 6db749d21c
12 changed files with 530 additions and 441 deletions

View File

@ -25,7 +25,7 @@ import { VectorVisualizer } from './VectorVisualizer';
import { Controls } from './Controls';
import { Conversation, ConversationHandle } from './Conversation';
import { ChatQuery, QueryOptions } from './ChatQuery';
import { Scrollable } from './AutoScroll';
import { Scrollable } from './Scrollable';
import { BackstoryTab } from './BackstoryTab';
import './App.css';
@ -177,13 +177,11 @@ const App = () => {
iconPosition: "start"
},
children: (
<Scrollable
<Conversation
sx={{
maxWidth: "1024px",
height: "calc(100vh - 72px)",
}}
>
<Conversation
ref={chatRef}
{...{
type: "chat",
@ -196,7 +194,6 @@ const App = () => {
defaultPrompts: backstoryQuestions
}}
/>
</Scrollable>
)
}, {
label: "Resume Builder",
@ -323,7 +320,7 @@ const App = () => {
useEffect(() => {
const url = new URL(window.location.href);
const pathParts = url.pathname.split('/').filter(Boolean); // [path, sessionId]
console.log(tabs);
if (pathParts.length < 1) {
console.log("No session id or path -- creating new session");
fetchSession();

View File

@ -1,156 +0,0 @@
import { useEffect, useRef, 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>,
autoscroll?: boolean,
}
const Scrollable = (props: ScrollableProps) => {
const { sx, children, autoscroll } = props;
const scrollRef = useAutoScrollToBottom();
return <Box
sx={{
display: "flex",
margin: "0 auto",
flexGrow: 1,
overflow: "auto",
backgroundColor: "#F5F5F5",
...sx
}}
ref={(autoscroll !== undefined && autoscroll !== false) ? scrollRef : undefined}
>{children}</Box>;
};
export {
useAutoScrollToBottom,
Scrollable,
};

View File

@ -0,0 +1,124 @@
import React, { useRef, useEffect, ChangeEvent, KeyboardEvent } from 'react';
import { useTheme } from '@mui/material/styles';
interface BackstoryTextFieldProps {
value: string;
disabled?: boolean;
multiline?: boolean;
placeholder?: string;
onChange?: (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => void;
onKeyDown?: (e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => void;
}
const BackstoryTextField: React.FC<BackstoryTextFieldProps> = ({
value,
disabled = false,
multiline = false,
placeholder,
onChange,
onKeyDown,
}) => {
const theme = useTheme();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const shadowRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (multiline && textareaRef.current && shadowRef.current) {
const shadow = shadowRef.current;
const textarea = textareaRef.current;
shadow.value = value || placeholder || '';
window.setTimeout(() => {
const computed = getComputedStyle(textarea);
const paddingTop = parseFloat(computed.paddingTop || '0');
const paddingBottom = parseFloat(computed.paddingBottom || '0');
const totalPadding = paddingTop + paddingBottom;
const newHeight = shadow.scrollHeight + totalPadding;
textarea.style.height = `${newHeight}px`;
}, 100);
}
}, [value, multiline, placeholder]);
const handleChange = (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
if (onChange) {
onChange(e);
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => {
if (!multiline && e.key === 'Enter') {
e.preventDefault();
}
if (onKeyDown) {
onKeyDown(e);
}
};
const sharedStyle = {
width: '100%',
padding: '14px',
resize: 'none' as const,
overflow: 'hidden' as const,
boxSizing: 'border-box' as const,
lineHeight: '1.5',
borderRadius: '0.25rem',
fontSize: '16px',
opacity: disabled ? "0.38" : "1",
color: disabled ? 'rgba(0, 0, 0, 0.38)' : 'rgb(46, 46, 46)',
backgroundColor: 'rgba(0, 0, 0, 0)',
fontFamily: theme.typography.fontFamily,
};
if (!multiline) {
return (
<input
type="text"
value={value}
disabled={disabled}
placeholder={placeholder}
onChange={handleChange}
onKeyDown={handleKeyDown}
style={sharedStyle}
/>
);
}
return (
<>
<textarea
ref={textareaRef}
value={value}
disabled={disabled}
placeholder={placeholder}
onChange={handleChange}
onKeyDown={handleKeyDown}
style={{
...sharedStyle,
height: 'auto',
}}
/>
<textarea
ref={shadowRef}
aria-hidden="true"
style={{
...sharedStyle,
position: 'relative',
top: '-9999px',
left: '-9999px',
visibility: 'hidden',
padding: '0px',
margin: '0px',
overflow: 'auto',
height: '0px',
}}
readOnly
tabIndex={-1}
/>
</>
);
};
export { BackstoryTextField };

View File

@ -10,4 +10,5 @@
width: 100%;
margin: 0 auto;
overflow-y: auto;
height: calc(100vh - 72px);
}

View File

@ -1,5 +1,4 @@
import React, { useState, useImperativeHandle, forwardRef, useEffect, useRef, useCallback } from 'react';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import Button from '@mui/material/Button';
@ -7,14 +6,16 @@ import Box from '@mui/material/Box';
import SendIcon from '@mui/icons-material/Send';
import { SxProps, Theme } from '@mui/material';
import PropagateLoader from "react-spinners/PropagateLoader";
import { useTheme } from '@mui/material/styles';
import { Message, MessageList, MessageData } from './Message';
import { SetSnackType } from './Snack';
import { ContextStatus } from './ContextStatus';
import { useAutoScrollToBottom } from './AutoScroll';
import { Scrollable } from './Scrollable';
import { DeleteConfirmation } from './DeleteConfirmation';
import { QueryOptions } from './ChatQuery';
import './Conversation.css';
import { BackstoryTextField } from './BackstoryTextField';
const loadingMessage: MessageData = { "role": "status", "content": "Establishing connection with server..." };
@ -93,6 +94,7 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
const [conversation, setConversation] = useState<MessageList>([]);
const [filteredConversation, setFilteredConversation] = useState<MessageList>([]);
const [processingMessage, setProcessingMessage] = useState<MessageData | undefined>(undefined);
const [streamingMessage, setStreamingMessage] = useState<MessageData | undefined>(undefined);
const timerRef = useRef<any>(null);
const [lastEvalTPS, setLastEvalTPS] = useState<number>(35);
const [lastPromptTPS, setLastPromptTPS] = useState<number>(430);
@ -100,7 +102,8 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
const [contextWarningShown, setContextWarningShown] = useState<boolean>(false);
const [noInteractions, setNoInteractions] = useState<boolean>(true);
const conversationRef = useRef<MessageList>([]);
const scrollRef = useAutoScrollToBottom();
const viewableElementRef = useRef<HTMLDivElement>(null);
const theme = useTheme();
// Keep the ref updated whenever items changes
useEffect(() => {
@ -214,6 +217,7 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
setNoInteractions(false);
}
setProcessingMessage(undefined);
setStreamingMessage(undefined);
updateContextStatus();
} catch (error) {
console.error('Error generating session ID:', error);
@ -402,6 +406,7 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
if (onResponse) {
update = onResponse(update);
}
setStreamingMessage(undefined);
setProcessingMessage(undefined);
const backstoryMessage: BackstoryMessage = update;
setConversation([
@ -425,10 +430,13 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
break;
case 'error':
// Show error
setProcessingMessage({ role: 'error', content: update.response });
setTimeout(() => {
setProcessingMessage(undefined);
}, 5000);
setConversation([
...conversationRef.current, {
...update,
role: 'error',
origin: type,
content: update.response || "",
}] as MessageList);
// Add a small delay to ensure React has time to update the UI
await new Promise(resolve => setTimeout(resolve, 0));
@ -436,7 +444,11 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
default:
// Force an immediate state update based on the message type
// Update processing message with immediate re-render
if (update.status === "streaming") {
setStreamingMessage({ role: update.status, content: update.response });
} else {
setProcessingMessage({ role: update.status, content: update.response });
}
// Add a small delay to ensure React has time to update the UI
await new Promise(resolve => setTimeout(resolve, 0));
break;
@ -494,13 +506,17 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
};
return (
<Box className={className || "Conversation"}
ref={scrollRef}
<Scrollable
className={className || "Conversation"}
autoscroll
textFieldRef={viewableElementRef}
fallbackThreshold={0.5}
sx={{
p: 1,
mt: 0,
...sx
}}>
}}
>
{
filteredConversation.map((message, index) =>
<Message key={index} {...{ sendQuery, message, connectionBase, sessionId, setSnack }} />
@ -510,6 +526,10 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
processingMessage !== undefined &&
<Message {...{ sendQuery, connectionBase, sessionId, setSnack, message: processingMessage }} />
}
{
streamingMessage !== undefined &&
<Message {...{ sendQuery, connectionBase, sessionId, setSnack, message: streamingMessage }} />
}
<Box sx={{
display: "flex",
flexDirection: "column",
@ -535,18 +555,17 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
</Box>
<Box className="Query" sx={{ display: "flex", flexDirection: "column", p: 1 }}>
{prompt &&
<TextField
variant="outlined"
<div
ref={viewableElementRef}>
<BackstoryTextField
disabled={processing}
fullWidth={true}
multiline={multiline ? true : false}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onChange={(e: any) => setQuery(e.target.value)}
onKeyDown={handleKeyPress}
placeholder={prompt}
id="QueryInput"
/>
</div>
}
<Box key="jobActions" sx={{ display: "flex", justifyContent: "center", flexDirection: "row" }}>
@ -585,7 +604,7 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
}
</Box>
<Box sx={{ display: "flex", flexGrow: 1 }}></Box>
</Box>
</Scrollable>
);
});

View File

@ -2,20 +2,9 @@ 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 './ChatQuery';
@ -30,7 +19,6 @@ interface ResumeBuilderProps {
sx?: SxProps<Theme>;
};
/**
* ResumeBuilder component
*
@ -49,13 +37,11 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
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
@ -64,20 +50,6 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
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);
@ -250,10 +222,10 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
setHasFacts(false);
}, [setHasFacts]);
const renderJobDescriptionView = useCallback((small: boolean) => {
const renderJobDescriptionView = useCallback((sx: SxProps) => {
console.log('renderJobDescriptionView');
const jobDescriptionQuestions = [
<Box sx={{ display: "flex", flexDirection: small ? "column" : "row" }}>
<Box sx={{ display: "flex", flexDirection: "column" }}>
<ChatQuery prompt="What are the key skills necessary for this position?" tunables={{ enable_tools: false }} submitQuery={handleJobQuery} />
<ChatQuery prompt="How much should this position pay (accounting for inflation)?" tunables={{ enable_tools: false }} submitQuery={handleJobQuery} />
</Box>,
@ -274,6 +246,7 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
sessionId,
connectionBase,
setSnack,
sx,
}}
/>
@ -292,6 +265,7 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
sessionId,
connectionBase,
setSnack,
sx,
}}
/>
}
@ -300,9 +274,9 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
/**
* Renders the resume view with loading indicator
*/
const renderResumeView = useCallback((small: boolean) => {
const renderResumeView = useCallback((sx: SxProps) => {
const resumeQuestions = [
<Box sx={{ display: "flex", flexDirection: small ? "column" : "row" }}>
<Box sx={{ display: "flex", flexDirection: "column" }}>
<ChatQuery prompt="Is this resume a good fit for the provided job description?" tunables={{ enable_tools: false }} submitQuery={handleResumeQuery} />
<ChatQuery prompt="Provide a more concise resume." tunables={{ enable_tools: false }} submitQuery={handleResumeQuery} />
</Box>,
@ -322,6 +296,7 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
sessionId,
connectionBase,
setSnack,
sx,
}}
/>
} else {
@ -339,6 +314,7 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
connectionBase,
setSnack,
defaultPrompts: resumeQuestions,
sx,
}}
/>
}
@ -347,9 +323,9 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
/**
* Renders the fact check view
*/
const renderFactCheckView = useCallback((small: boolean) => {
const renderFactCheckView = useCallback((sx: SxProps) => {
const factsQuestions = [
<Box sx={{ display: "flex", flexDirection: small ? "column" : "row" }}>
<Box sx={{ display: "flex", flexDirection: "column" }}>
<ChatQuery prompt="Rewrite the resume to address any discrepancies." tunables={{ enable_tools: false }} submitQuery={handleFactsQuery} />
</Box>,
];
@ -368,133 +344,11 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
sessionId,
connectionBase,
setSnack,
sx,
}}
/>
}, [connectionBase, sessionId, setSnack, factsResponse, filterFactsMessages, resetFacts, hasResume, hasFacts]);
/**
* Gets the appropriate content based on active state for Desktop
*/
const getActiveDesktopContent = useCallback(() => {
const hasSlider = hasResume || hasFacts;
const ratio = 75 + 25 * splitRatio / 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,
}}>
{renderJobDescriptionView(false)}
</Box>);
/* Resume panel - conditionally rendered if resume defined, or processing is in progress */
if (hasResume) {
children.push(
<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>
);
}
/* Fact Check panel - conditionally rendered if facts defined, or processing is in progress */
if (hasFacts) {
children.push(
<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>
);
}
/* Split control panel - conditionally rendered if either facts or resume is set */
let slider = <Box key="slider"></Box>;
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%' }}>
<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={{
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, hasFacts, hasResume]);
// Render mobile view
if (isMobile) {
return (
<Box sx={{
p: 0,
@ -503,9 +357,9 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
flexGrow: 1,
margin: "0 auto",
overflow: "hidden",
height: "calc(100vh - 72px)",
backgroundColor: "#F5F5F5",
flexDirection: "column"
flexDirection: "column",
maxWidth: "1024px",
}}
>
{/* Tabs */}
@ -521,16 +375,16 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
</Tabs>
{/* Document display area */}
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, p: 0, width: "100%", ...sx }}>
<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 sx={{
display: 'flex', flexDirection: 'column', flexGrow: 1, p: 0, width: "100%", ...sx,
overflow: "hidden"
}}>
<Box sx={{ display: activeTab === 0 ? "flex" : "none" }}>{renderJobDescriptionView({ height: "calc(100vh - 72px - 48px)" })}</Box>
<Box sx={{ display: activeTab === 1 ? "flex" : "none" }}>{renderResumeView({ height: "calc(100vh - 72px - 48px)" })}</Box>
<Box sx={{ display: activeTab === 2 ? "flex" : "none" }}>{renderFactCheckView({ height: "calc(100vh - 72px - 48px)" })}</Box>
</Box>
</Box>
);
}
return getActiveDesktopContent();
};
export type {

View File

@ -0,0 +1,40 @@
import Box from '@mui/material/Box';
import { SxProps, Theme } from '@mui/material';
import { RefObject, useRef } from 'react';
import { useAutoScrollToBottom } from './useAutoScrollToBottom';
interface ScrollableProps {
children?: React.ReactNode;
sx?: SxProps<Theme>;
autoscroll?: boolean;
textFieldRef?: RefObject<HTMLElement | null>; // Reference to the element that triggers auto-scroll
fallbackThreshold?: number;
contentUpdateTrigger?: any;
className?: string;
}
const Scrollable = (props: ScrollableProps) => {
const { sx, className, children, autoscroll, textFieldRef, fallbackThreshold = 0.33, contentUpdateTrigger } = props;
// Create a default ref if textFieldRef is not provided
const defaultTextFieldRef = useRef<HTMLElement | null>(null);
const scrollRef = useAutoScrollToBottom(textFieldRef ?? defaultTextFieldRef, true, fallbackThreshold, contentUpdateTrigger);
return (
<Box
className={className || "Scrollable"}
sx={{
display: 'flex',
margin: '0 auto',
flexGrow: 1,
overflow: 'auto',
backgroundColor: '#F5F5F5',
...sx,
}}
ref={autoscroll !== undefined && autoscroll !== false ? scrollRef : undefined}
>
{children}
</Box>
);
};
export { useAutoScrollToBottom, Scrollable };

View File

@ -153,7 +153,7 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
if (plotlyRef.current) {
Plot.Plots.resize(plotlyRef.current);
}
}, [])
}, [plotlyRef])
useEffect(() => {
if (!result || !result.embeddings) return;

View File

@ -0,0 +1,173 @@
import { useEffect, useRef, RefObject, useCallback } from 'react';
type ResizeCallback = () => void;
const useResizeObserverAndMutationObserver = (
targetRef: RefObject<HTMLElement | null>,
scrollToRef: RefObject<HTMLElement | null>,
callback: ResizeCallback
) => {
const callbackRef = useRef(callback);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const mutationObserverRef = useRef<MutationObserver | null>(null);
const debounceTimeout = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
const container = targetRef.current;
const scrollTo = scrollToRef.current;
if (!container) return;
const debouncedCallback = (entries: ResizeObserverEntry[] | undefined) => {
if (debounceTimeout.current) clearTimeout(debounceTimeout.current);
debounceTimeout.current = setTimeout(() => {
requestAnimationFrame(() => callbackRef.current());
}, 50);
};
const resizeObserver = new ResizeObserver(debouncedCallback);
const mutationObserver = new MutationObserver(() => { debouncedCallback(undefined); });
// Observe container size
resizeObserver.observe(container);
// Observe TextField size if available
if (scrollTo) {
resizeObserver.observe(scrollTo);
}
// Observe child additions/removals and text content changes
mutationObserver.observe(container, {
childList: true,
subtree: true,
characterData: true,
});
resizeObserverRef.current = resizeObserver;
mutationObserverRef.current = mutationObserver;
return () => {
if (debounceTimeout.current) clearTimeout(debounceTimeout.current);
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, [targetRef, scrollToRef]);
};
/**
* Auto-scroll hook for scrollable containers.
* Scrolls to the bottom of the container on paste or when TextField is fully/partially visible.
*/
export const useAutoScrollToBottom = (
scrollToRef: RefObject<HTMLElement | null>,
smooth: boolean = true,
fallbackThreshold: number = 0.33,
contentUpdateTrigger?: any
): RefObject<HTMLDivElement | null> => {
const containerRef = useRef<HTMLDivElement | null>(null);
const lastScrollTop = useRef(0);
const scrollTimeout = useRef<NodeJS.Timeout | null>(null);
const isUserScrollingUpRef = useRef(false);
const checkAndScrollToBottom = useCallback((isPasteEvent: boolean = false) => {
const container = containerRef.current;
if (!container) return;
let shouldScroll = false;
const scrollTo = scrollToRef.current;
if (scrollTo) {
// Get positions
const containerRect = container.getBoundingClientRect();
const scrollToRect = scrollTo.getBoundingClientRect();
const containerTop = containerRect.top;
const containerBottom = containerTop + container.clientHeight;
// Check if TextField is fully or partially visible (for non-paste events)
const isTextFieldVisible =
scrollToRect.top < containerBottom && scrollToRect.bottom > containerTop;
// Scroll on paste or if TextField is visible and user isn't scrolling up
shouldScroll = (isPasteEvent || isTextFieldVisible) && !isUserScrollingUpRef.current;
if (shouldScroll) {
requestAnimationFrame(() => {
console.debug('Scrolling to container bottom:', {
scrollHeight: container.scrollHeight,
scrollToHeight: scrollToRect.height,
containerHeight: container.clientHeight,
isPasteEvent,
isTextFieldVisible,
isUserScrollingUp: isUserScrollingUpRef.current,
});
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
});
});
}
} else {
// Fallback to threshold-based check
const scrollHeight = container.scrollHeight;
const isNearBottom =
scrollHeight - container.scrollTop - container.clientHeight <=
container.clientHeight * fallbackThreshold;
shouldScroll = isNearBottom && !isUserScrollingUpRef.current;
if (shouldScroll) {
requestAnimationFrame(() => {
console.debug('Scrolling to container bottom (fallback):', { scrollHeight });
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
});
});
}
}
}, [fallbackThreshold, smooth, scrollToRef]);
useEffect(() => {
const container = containerRef.current;
const scrollTo = scrollToRef.current;
if (!container) return;
const handleScroll = () => {
const currentScrollTop = container.scrollTop;
isUserScrollingUpRef.current = currentScrollTop < lastScrollTop.current;
lastScrollTop.current = currentScrollTop;
if (scrollTimeout.current) clearTimeout(scrollTimeout.current);
scrollTimeout.current = setTimeout(() => {
isUserScrollingUpRef.current = false;
}, 500);
};
const handlePaste = () => {
// Delay scroll check to ensure DOM updates
setTimeout(() => {
requestAnimationFrame(() => checkAndScrollToBottom(true));
}, 0);
};
container.addEventListener('scroll', handleScroll);
if (scrollTo) {
scrollTo.addEventListener('paste', handlePaste);
}
checkAndScrollToBottom();
return () => {
container.removeEventListener('scroll', handleScroll);
if (scrollTo) {
scrollTo.removeEventListener('paste', handlePaste);
}
if (scrollTimeout.current) clearTimeout(scrollTimeout.current);
};
}, [smooth, scrollToRef, fallbackThreshold, contentUpdateTrigger, checkAndScrollToBottom]);
// Observe container and TextField size, plus DOM changes
useResizeObserverAndMutationObserver(containerRef, scrollToRef, checkAndScrollToBottom);
return containerRef;
};

View File

@ -542,13 +542,14 @@ class WebServer:
agent = context.get_agent(agent_type)
except Exception as e:
logger.info(f"Attempt to create agent type: {agent_type} failed", e)
return JSONResponse({ "error": f"{agent_type} is not recognized", "context": context.id }, status_code=404)
return JSONResponse({"error": f"{agent_type} is not recognized", "context": context.id}, status_code=404)
query = await request.json()
prompt = query["prompt"]
if not isinstance(prompt, str) or len(prompt) == 0:
logger.info(f"Prompt is empty")
return JSONResponse({"error": "Prompt can not be empty"}, status_code=400)
return JSONResponse({"error": "Prompt cannot be empty"}, status_code=400)
try:
options = Tunables(**query["options"]) if "options" in query else None
except Exception as e:
@ -556,45 +557,76 @@ class WebServer:
return JSONResponse({"error": f"Invalid options: {query['options']}"}, status_code=400)
if not agent:
# job_description is the only agent that is dynamically generated from a
# Rest API endpoint.
# - 'chat' is created on context creation.
# - 'resume' is created on actions by 'job_description'
# - 'fact_check' is created on ations by 'fact_check'
match agent_type:
case "job_description":
logger.info(f"Agent {agent_type} not found. Returning empty history.")
agent = context.get_or_create_agent("job_description", job_description=prompt)
case _:
logger.info(f"Invalid agent creation sequence for {agent_type}. Returning error.")
return JSONResponse({ "error": f"{agent_type} is not recognized", "context": context.id }, status_code=404)
return JSONResponse({"error": f"{agent_type} is not recognized", "context": context.id}, status_code=404)
# Create a custom generator that ensures flushing
async def flush_generator():
logging.info(f"Message starting. Streaming partial results.")
logger.info(f"Message starting. Streaming partial results.")
# Create a cancellable task to manage the generator
loop = asyncio.get_running_loop()
stop_event = asyncio.Event()
async def process_generator():
try:
async for message in self.generate_response(context=context, agent=agent, prompt=prompt, options=options):
if stop_event.is_set():
logger.info("Stopping generator due to client disconnection.")
return
if message.status != "done":
result = {
"status": message.status,
"response": message.response
}
else:
logging.info(f"Message complete. Providing full response.")
logger.info(f"Message complete. Providing full response.")
try:
result = message.model_dump(by_alias=True, mode='json')
except Exception as e:
result = { "status": "error", "response": e }
exit(1)
result = {"status": "error", "response": str(e)}
yield json.dumps(result) + "\n"
return
# Convert to JSON and add newline
result = json.dumps(result) + "\n"
message.network_packets += 1
message.network_bytes += len(result)
yield result
# Explicitly flush after each yield
await asyncio.sleep(0) # Allow the event loop to process the write
# Save the history once completed
# Allow the event loop to process the write
await asyncio.sleep(0)
except Exception as e:
logger.error(f"Error in process_generator: {e}")
yield json.dumps({"status": "error", "response": str(e)}) + "\n"
finally:
# Save context on completion or error
self.save_context(context_id)
# Create a generator iterator
gen = process_generator()
try:
async for result in gen:
# Check if client has disconnected
if await request.is_disconnected():
logger.info("Client disconnected, stopping generator.")
stop_event.set() # Signal the generator to stop
return
yield result
except Exception as e:
logger.error(f"Error in flush_generator: {e}")
yield json.dumps({"status": "error", "response": str(e)}) + "\n"
finally:
stop_event.set() # Ensure generator stops if not already stopped
# Ensure generator is fully closed
try:
await gen.aclose()
except Exception as e:
logger.warning(f"Error closing generator: {e}")
# Return StreamingResponse with appropriate headers
return StreamingResponse(
flush_generator(),

View File

@ -374,6 +374,7 @@ class JobDescription(Agent):
2. Create a comprehensive inventory of the candidate's actual qualifications.
3. DO NOT consider any job requirements - this is a pure candidate analysis task.
4. For each qualification, cite exactly where in the materials it appears.
5. DO NOT duplicate or repeat time periods or skills once listed.
## OUTPUT FORMAT:
@ -778,7 +779,6 @@ class JobDescription(Agent):
# Extract JSON from response
json_str = self.extract_json_from_text(message.response)
verification_results = json.loads(json_str)
message.status = "done"
message.response = json_str
@ -862,12 +862,11 @@ class JobDescription(Agent):
"""
try:
message.status = "thinking"
message.response = "Starting multi-stage RAG resume generation process"
logger.info(message.response)
yield message
# Stage 1A: Analyze job requirements
message.response = "Stage 1A: Analyzing job requirements"
message.response = "Multi-stage RAG resume generation process: Stage 1A: Analyzing job requirements"
logger.info(message.response)
yield message
@ -878,10 +877,11 @@ class JobDescription(Agent):
return
job_requirements = json.loads(message.response)
message.metadata["job_requirements"] = job_requirements
# Stage 1B: Analyze candidate qualifications
message.status = "thinking"
message.response = "Stage 1B: Analyzing candidate qualifications"
message.response = "Multi-stage RAG resume generation process: Stage 1B: Analyzing candidate qualifications"
logger.info(message.response)
yield message
@ -892,10 +892,11 @@ class JobDescription(Agent):
return
candidate_qualifications = json.loads(message.response)
message.metadata["candidate_qualifications"] = job_requirements
# Stage 1C: Create skills mapping
message.status = "thinking"
message.response = "Stage 1C: Creating skills mapping"
message.response = "Multi-stage RAG resume generation process: Stage 1C: Creating skills mapping"
logger.info(message.response)
yield message
@ -906,13 +907,14 @@ class JobDescription(Agent):
return
skills_mapping = json.loads(message.response)
message.metadata["skills_mapping"] = skills_mapping
# Extract header from original resume
original_header = self.extract_header_from_resume(resume)
# Stage 2: Generate tailored resume
message.status = "thinking"
message.response = "Stage 2: Generating tailored resume"
message.response = "Multi-stage RAG resume generation process: Stage 2: Generating tailored resume"
logger.info(message.response)
yield message
@ -923,10 +925,13 @@ class JobDescription(Agent):
return
generated_resume = message.response
message.metadata["generated_resume"] = {
"first_pass": generated_resume
}
# Stage 3: Verify resume
message.status = "thinking"
message.response = "Stage 3: Verifying resume for accuracy"
message.response = "Multi-stage RAG resume generation process: Stage 3: Verifying resume for accuracy"
logger.info(message.response)
yield message
@ -937,6 +942,9 @@ class JobDescription(Agent):
return
verification_results = json.loads(message.response)
message.metadata["verification_results"] = {
"first_pass": verification_results
}
# Handle corrections if needed
if verification_results["verification_results"]["overall_assessment"] == "NEEDS REVISION":
@ -959,6 +967,7 @@ class JobDescription(Agent):
return
generated_resume = message.response
message.metadata["generated_resume"]["second_pass"] = generated_resume
# Re-verify after corrections
message.status = "thinking"
@ -973,16 +982,12 @@ class JobDescription(Agent):
yield message
if message.status == "error":
return
verification_results = json.loads(message.response)
message.metadata["verification_results"]["second_pass"] = verification_results
# Return the final results
message.status = "done"
message.response = json.dumps({
"job_requirements": job_requirements,
"candidate_qualifications": candidate_qualifications,
"skills_mapping": skills_mapping,
"generated_resume": generated_resume,
"verification_results": verification_results
})
message.response = generated_resume
yield message
logger.info("Resume generation process completed successfully")
@ -1006,9 +1011,9 @@ class JobDescription(Agent):
self.metrics.generate_count.labels(agent=self.agent_type).inc()
with self.metrics.generate_duration.labels(agent=self.agent_type).time():
job_description = "You write C and C++ code for 4 years."
resume = "I have worked on Cobol and QuickBasic for 18 years."
additional_context = ""
job_description = message.preamble["job_description"]
resume = message.preamble["resume"]
additional_context = message.preamble["context"]
async for message in self.generate_factual_tailored_resume(message=message, job_description=job_description, resume=resume, additional_context=additional_context):
if message.status != "done":