Lots of UI fixes

This commit is contained in:
James Ketr 2025-04-26 16:06:51 -07:00
parent 889a3f9e3b
commit 5f184b4a1d
7 changed files with 230 additions and 233 deletions

View File

@ -1,5 +1,3 @@
# About Backstory
The backstory about Backstory...
## Backstory is two things

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import React, { ReactElement, JSXElementConstructor, 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';
@ -15,6 +15,7 @@ import Box from '@mui/material/Box';
import CssBaseline from '@mui/material/CssBaseline';
import MenuIcon from '@mui/icons-material/Menu';
import { useTheme } from '@mui/material/styles';
import { SxProps } from '@mui/material';
import { ResumeBuilder } from './ResumeBuilder';
@ -24,6 +25,7 @@ import { VectorVisualizer } from './VectorVisualizer';
import { Controls } from './Controls';
import { Conversation, ConversationHandle } from './Conversation';
import { Scrollable } from './AutoScroll';
import { BackstoryTab } from './BackstoryTab';
import './App.css';
import './Conversation.css';
@ -43,34 +45,20 @@ const getConnectionBase = (loc: any): string => {
}
}
interface TabPanelProps {
children?: React.ReactNode;
index: number;
tab: number;
}
function CustomTabPanel(props: TabPanelProps) {
const { children, tab, index, ...other } = props;
return (
<div
className="TabPanel"
role="tabpanel"
style={{ "display": tab === index ? "flex": "none" }}
id={`tabpanel-${index}`}
aria-labelledby={`tab-${index}`}
{...other}
>
{children}
</div>
);
}
interface TabProps {
label?: string,
path: string,
tabProps?: {
label?: string,
sx?: SxProps,
icon?: string | ReactElement<unknown, string | JSXElementConstructor<any>> | undefined,
iconPosition?: "bottom" | "top" | "start" | "end" | undefined
}
};
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 [activeTab, setActiveTab] = useState<number>(0);
@ -132,15 +120,154 @@ const App = () => {
};
// Extract the sessionId from the URL if present, otherwise
// request a sessionId from the server.
const validPaths = useMemo(() => ['chat', 'notes', 'tasks'], []); // allowed paths
const tabs: TabProps[] = useMemo(() => {
const backstoryPreamble: 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 backstoryQuestions = [
<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>
];
const tabSx = { flexGrow: 1, fontSize: '1rem' };
return [{
label: "",
path: "",
tabProps: {
label: "Backstory",
sx: tabSx,
icon:
<Avatar sx={{
width: 24,
height: 24
}}
variant="rounded"
alt="Backstory logo"
src="/logo192.png" />,
iconPosition: "start"
},
children: (
<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: backstoryPreamble,
defaultPrompts: backstoryQuestions
}}
/>
</Scrollable>
)
}, {
label: "Resume Builder",
path: "resume-builder",
children: (
<ResumeBuilder sx={{
margin: "0 auto",
height: "calc(100vh - 72px)",
overflow: "auto",
backgroundColor: "#F5F5F5",
display: "flex",
flexGrow: 1
}} {...{ setSnack, connectionBase, sessionId }}
/>
)
}, {
label: "Context Visualizer",
path: "context-visualizer",
children:
<Scrollable
sx={{
maxWidth: "1024px",
height: "calc(100vh - 72px)",
}}
>
<VectorVisualizer sx={{ p: 1 }} {...{ connectionBase, sessionId, setSnack }} />
</Scrollable>
}, {
label: "About",
path: "about",
children: (
<Scrollable
sx={{
maxWidth: "1024px",
height: "calc(100vh - 72px)",
flexDirection: "column",
margin: "0 auto",
p: 1,
}}
>
<Message
{...{
sx: {
display: 'flex',
flexDirection: 'column',
p: 1,
m: 0,
flexGrow: 0,
},
message: { role: 'content', title: "About Backstory", content: about },
submitQuery: handleSubmitChatQuery,
connectionBase,
sessionId,
setSnack
}} />
<Box sx={{ display: "flex", flexGrow: 1, p: 0, m: 0 }} />
</Scrollable>
)
}, {
path: "settings",
tabProps: {
sx: { flexShrink: 1, flexGrow: 0, fontSize: '1rem' },
icon: <SettingsIcon />
},
children: (
<Box className="ChatBox">
{sessionId !== undefined &&
<Controls {...{ sessionId, setSnack, connectionBase }} />
}
</Box >
)
}];
}, [about, connectionBase, sessionId, setSnack, isMobile]);
useEffect(() => {
const url = new URL(window.location.href);
const pathParts = url.pathname.split('/').filter(Boolean); // [path, sessionId]
const fetchSession = async (pathOverride?: string) => {
const fetchSession = async () => {
try {
const response = await fetch(connectionBase + `/api/context`, {
method: 'POST',
@ -155,30 +282,29 @@ const App = () => {
const data = await response.json();
setSessionId(data.id);
const newPath = pathOverride || 'chat'; // default fallback
window.history.replaceState({}, '', `/${newPath}/${data.id}`);
const newPath = `/${data.id}`;
window.history.replaceState({}, '', newPath);
} catch (error: any) {
console.error(error);
setSnack("Server is temporarily down", "error");
}
};
if (pathParts.length < 2) {
if (pathParts.length < 1) {
console.log("No session id or path -- creating new session");
fetchSession();
} else {
const currentPath = pathParts[0];
const session = pathParts[1];
if (!validPaths.includes(currentPath)) {
const currentPath = pathParts.length < 2 ? '' : pathParts[0];
const session = pathParts.length < 2 ? pathParts[0] : pathParts[1];
let tabIndex = tabs.findIndex((tab) => tab.path === currentPath);
if (-1 === tabIndex) {
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}`);
tabIndex = 0;
}
setSessionId(session);
setSelectedPath(currentPath);
setActiveTab(tabIndex);
}
}
}, [setSessionId, setSelectedPath, connectionBase, setSnack, validPaths]);
}, [setSessionId, connectionBase, setSnack, tabs]);
const handleMenuClose = () => {
setIsMenuClosing(true);
@ -196,159 +322,38 @@ const App = () => {
};
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
if (newValue > tabs.length) {
return;
}
setActiveTab(newValue);
const tabPath = tabs[newValue].path;
if (tabPath) {
window.history.pushState({}, '', `/${tabPath}/${sessionId}`);
} else {
window.history.pushState({}, '', `/${sessionId}`);
}
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);
const currentPath = pathParts.length < 2 ? '' : pathParts[0];
const session = pathParts.length < 2 ? pathParts[0] : pathParts[1];
if (pathParts.length >= 2) {
const path = pathParts[0];
const session = pathParts[1];
if (validPaths.includes(path)) {
setSelectedPath(path);
let tabIndex = tabs.findIndex((tab) => tab.path === currentPath);
if (-1 === tabIndex) {
console.log(`Invalid path "${currentPath}" -- redirecting to default`);
tabIndex = 0;
}
setSessionId(session);
}
}
setActiveTab(tabIndex);
};
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={activeTab}
indicatorColor="secondary"
textColor="inherit"
variant="scrollable"
allowScrollButtonsMobile
onChange={handleTabChange}
aria-label="Backstory navigation">
<Tab sx={{ fontSize: '1rem' }} label="Backstory"
value={0}
icon={
<Avatar sx={{
width: 24,
height: 24
}}
variant="rounded"
alt="Backstory logo"
src="/logo192.png" />
}
iconPosition="start" />
<Tab
value={1}
sx={{ fontSize: '1rem' }} wrapped
label="Resume Builder"
/>
<Tab
value={2}
sx={{ fontSize: '1rem' }} wrapped
label="Context Visualizer"
/>
<Tab
value={3}
sx={{ fontSize: '1rem' }} label="About" />
<Tab
value={4}
sx={{ fontSize: '1rem' }} icon={<SettingsIcon />} />
</Tabs>
</Card>
);
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]);
}, [setSessionId, tabs]);
/* toolbar height is 64px + 8px margin-top */
const Offset = styled('div')(() => ({ minHeight: '72px', height: '72px' }));
@ -406,34 +411,7 @@ const App = () => {
allowScrollButtonsMobile
onChange={handleTabChange}
aria-label="Backstory navigation">
<Tab sx={{ fontSize: '1rem' }} label="Backstory"
value={0}
icon={
<Avatar sx={{
width: 24,
height: 24
}}
variant="rounded"
alt="Backstory logo"
src="/logo192.png" />
}
iconPosition="start" />
<Tab
value={1}
sx={{ fontSize: '1rem' }} wrapped
label="Resume Builder"
/>
<Tab
value={2}
sx={{ fontSize: '1rem' }} wrapped
label="Context Visualizer"
/>
<Tab
value={3}
sx={{ fontSize: '1rem' }} label="About" />
<Tab
value={4}
sx={{ flexShrink: 1, flexGrow: 0, fontSize: '1rem' }} icon={<SettingsIcon />} />
{tabs.map((tab, index) => <Tab key={index} value={index} label={tab.label} {...tab.tabProps} />)}
</Tabs>
}
</Box>
@ -467,12 +445,24 @@ const App = () => {
}}
>
<Toolbar />
{menuDrawer}
<Card className="MenuCard">
<Tabs sx={{ display: "flex", flexGrow: 1 }}
orientation="vertical"
value={activeTab}
indicatorColor="secondary"
textColor="inherit"
variant="scrollable"
allowScrollButtonsMobile
onChange={handleTabChange}
aria-label="Backstory navigation">
{tabs.map((tab, index) => <Tab key={index} value={index} label={tab.label} {...tab.tabProps} />)}
</Tabs>
</Card>
</Drawer>
</Box>
{
tabs.map((tab: any, i: number) =>
<CustomTabPanel key={i} tab={activeTab} index={i}>{tab}</CustomTabPanel>
<BackstoryTab key={i} active={i === activeTab}>{tab.children}</BackstoryTab>
)
}
</Box>

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState, RefObject } from 'react';
import { useEffect, useRef, RefObject } from 'react';
import Box from '@mui/material/Box';
import { SxProps, Theme } from '@mui/material';
@ -53,15 +53,15 @@ const useAutoScrollToBottom = (
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
// });
console.log('Not scrolling', {
isNearBottom,
isUserScrollingUp,
scrollHeight: container.scrollHeight,
scrollTop: container.scrollTop,
clientHeight: container.clientHeight,
threshold,
delta: container.scrollHeight - container.scrollTop - container.clientHeight
});
}
};

View File

@ -19,7 +19,8 @@ interface ChatBubbleProps {
title?: string;
}
function ChatBubble({ role, isFullWidth, children, sx, className, title }: ChatBubbleProps) {
function ChatBubble(props: ChatBubbleProps) {
const { role, isFullWidth, children, sx, className, title } = props;
const theme = useTheme();
const defaultRadius = '16px';
@ -122,7 +123,7 @@ function ChatBubble({ role, isFullWidth, children, sx, className, title }: ChatB
<Accordion
defaultExpanded
className={className}
sx={{ ...styles[role] }}
sx={{ ...styles[role], ...sx }}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
@ -134,9 +135,7 @@ function ChatBubble({ role, isFullWidth, children, sx, className, title }: ChatB
{children}
</AccordionDetails>
</Accordion>
);
}
return (

View File

@ -444,7 +444,8 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
<Box className={className || "Conversation"}
ref={scrollRef}
sx={{
display: "flex", flexDirection: "column", flexGrow: 1, p: 1, mt: 0,
p: 1,
mt: 0,
...sx
}}>
{

View File

@ -18,6 +18,7 @@ import Collapse from '@mui/material/Collapse';
import Typography from '@mui/material/Typography';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { ExpandMore } from './ExpandMore';
import { SxProps, Theme } from '@mui/material';
import { ChatBubble } from './ChatBubble';
import { StyledMarkdown } from './StyledMarkdown';
@ -61,12 +62,14 @@ interface MessageMetaProps {
type MessageList = MessageData[];
interface MessageProps {
sx?: SxProps<Theme>,
message?: MessageData,
isFullWidth?: boolean,
submitQuery?: (text: string) => void,
sessionId?: string,
connectionBase: string,
setSnack: SetSnackType,
className?: string,
};
interface ChatQueryInterface {
@ -216,7 +219,8 @@ const ChatQuery = ({ text, submitQuery }: ChatQueryInterface) => {
);
}
const Message = ({ message, submitQuery, isFullWidth, sessionId, setSnack, connectionBase }: MessageProps) => {
const Message = (props: MessageProps) => {
const { message, submitQuery, isFullWidth, sessionId, setSnack, connectionBase, sx, className } = props;
const [expanded, setExpanded] = useState<boolean>(false);
const textFieldRef = useRef(null);
@ -237,7 +241,7 @@ const Message = ({ message, submitQuery, isFullWidth, sessionId, setSnack, conne
return (
<ChatBubble
className="Message"
className={className || "Message"}
isFullWidth={isFullWidth}
role={message.role}
title={message.title}
@ -246,9 +250,10 @@ const Message = ({ message, submitQuery, isFullWidth, sessionId, setSnack, conne
flexDirection: "column",
pb: message.metadata ? 0 : "8px",
m: 0,
mb: 1,
mt: 1,
marginBottom: "0px !important", // Remove whitespace from expanded Accordion
// overflowX: "auto"
...sx,
}}>
<CardContent ref={textFieldRef} sx={{ position: "relative", display: "flex", flexDirection: "column", overflowX: "auto", m: 0, p: 0 }}>
<CopyBubble content={message?.content} />

View File

@ -9,6 +9,7 @@ import Button from '@mui/material/Button';
import SendIcon from '@mui/icons-material/Send';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';
import { SxProps, Theme } from '@mui/material';
import { SetSnackType } from './Snack';
@ -47,6 +48,7 @@ interface VectorVisualizerProps {
setSnack: SetSnackType;
inline?: boolean;
rag?: any;
sx?: SxProps<Theme>;
}
interface ChromaResult {
@ -98,7 +100,8 @@ const symbolMap: Record<string, string> = {
'query': 'circle',
};
const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, rag, inline, connectionBase, sessionId }) => {
const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualizerProps) => {
const { setSnack, rag, inline, connectionBase, sessionId, sx } = props;
const [plotData, setPlotData] = useState<PlotData | null>(null);
const [newQuery, setNewQuery] = useState<string>('');
const [newQueryEmbedding, setNewQueryEmbedding] = useState<ChromaResult | undefined>(undefined);
@ -305,7 +308,8 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, rag, inli
sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1
flexGrow: 1,
...sx
}}>
{
!inline &&