2025-04-26 16:06:51 -07:00

477 lines
15 KiB
TypeScript

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';
import Avatar from '@mui/material/Avatar';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Tooltip from '@mui/material/Tooltip';
import AppBar from '@mui/material/AppBar';
import Drawer from '@mui/material/Drawer';
import Toolbar from '@mui/material/Toolbar';
import SettingsIcon from '@mui/icons-material/Settings';
import IconButton from '@mui/material/IconButton';
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';
import { Message, ChatQuery, MessageList } from './Message';
import { Snack, SeverityType } from './Snack';
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';
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import MuiMarkdown from 'mui-markdown';
const getConnectionBase = (loc: any): string => {
if (!loc.host.match(/.*battle-linux.*/)) {
return loc.protocol + "//" + loc.host;
} else {
return loc.protocol + "//battle-linux.ketrenos.com:8912";
}
}
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 [menuOpen, setMenuOpen] = useState(false);
const [isMenuClosing, setIsMenuClosing] = useState(false);
const [activeTab, setActiveTab] = useState<number>(0);
const [about, setAbout] = useState<string>("");
const isDesktop = useMediaQuery('(min-width:650px)');
const prevIsDesktopRef = useRef<boolean>(isDesktop);
const chatRef = useRef<ConversationHandle>(null);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const snackRef = useRef<any>(null);
useEffect(() => {
if (prevIsDesktopRef.current === isDesktop)
return;
if (menuOpen) {
setMenuOpen(false);
}
prevIsDesktopRef.current = isDesktop;
}, [isDesktop, setMenuOpen, menuOpen])
const setSnack = useCallback((message: string, severity?: SeverityType) => {
snackRef.current?.setSnack(message, severity);
}, [snackRef]);
// Get the About markdown
useEffect(() => {
if (about !== "") {
return;
}
const fetchAbout = async () => {
try {
const response = await fetch("/docs/about.md", {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw Error("/docs/about.md not found");
}
const data = await response.text();
setAbout(data);
} catch (error: any) {
console.error('Error obtaining About content information:', error);
setAbout("No information provided.");
};
};
fetchAbout();
}, [about, setAbout])
const handleSubmitChatQuery = (query: string) => {
console.log(`handleSubmitChatQuery: ${query} -- `, chatRef.current ? ' sending' : 'no handler');
chatRef.current?.submitQuery(query);
setActiveTab(0);
};
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 () => {
try {
const response = await fetch(connectionBase + `/api/context`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw Error("Server is temporarily down.");
}
const data = await response.json();
setSessionId(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 < 1) {
console.log("No session id or path -- creating new session");
fetchSession();
} else {
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`);
tabIndex = 0;
}
setSessionId(session);
setActiveTab(tabIndex);
}
}, [setSessionId, connectionBase, setSnack, tabs]);
const handleMenuClose = () => {
setIsMenuClosing(true);
setMenuOpen(false);
};
const handleMenuTransitionEnd = () => {
setIsMenuClosing(false);
};
const handleMenuToggle = () => {
if (!isMenuClosing) {
setMenuOpen(!menuOpen);
}
};
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();
};
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];
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);
}, [setSessionId, tabs]);
/* toolbar height is 64px + 8px margin-top */
const Offset = styled('div')(() => ({ minHeight: '72px', height: '72px' }));
return (
<Box className="App"
sx={{ display: 'flex', flexDirection: 'column' }}>
<CssBaseline />
<AppBar
position="fixed"
sx={{
zIndex: (theme) => theme.zIndex.drawer + 1,
maxWidth: "100vw"
}}
>
<Toolbar>
<Box sx={{ display: "flex", flexGrow: 1, flexDirection: "row" }}>
{!isDesktop &&
<Box sx={{ display: "flex", flexGrow: 1, flexDirection: "row" }}>
<IconButton
sx={{ display: "flex", margin: 'auto 0px' }}
size="large"
edge="start"
color="inherit"
onClick={handleMenuToggle}
>
<Tooltip title="Navigation">
<MenuIcon />
</Tooltip>
</IconButton>
<Tooltip title="Backstory">
<Box
sx={{ m: 1, gap: 1, display: "flex", flexDirection: "row", alignItems: "center", fontWeight: "bold", fontSize: "1.0rem", cursor: "pointer" }}
onClick={() => { setActiveTab(0); setMenuOpen(false); }}
>
<Avatar sx={{
width: 24,
height: 24
}}
variant="rounded"
alt="Backstory logo"
src="/logo192.png" />
BACKSTORY
</Box>
</Tooltip>
</Box>
}
{menuOpen === false && isDesktop &&
<Tabs sx={{ display: "flex", flexGrow: 1 }}
value={activeTab}
indicatorColor="secondary"
textColor="inherit"
variant="fullWidth"
allowScrollButtonsMobile
onChange={handleTabChange}
aria-label="Backstory navigation">
{tabs.map((tab, index) => <Tab key={index} value={index} label={tab.label} {...tab.tabProps} />)}
</Tabs>
}
</Box>
</Toolbar>
</AppBar>
<Offset />
<Box
sx={{ display: "flex", flexGrow: 1, flexDirection: "column" }}
>
<Box
component="nav"
aria-label="mailbox folders"
>
<Drawer
container={window.document.body}
variant="temporary"
open={menuOpen}
onTransitionEnd={handleMenuTransitionEnd}
onClose={handleMenuClose}
sx={{
display: 'block',
'& .MuiDrawer-paper': { boxSizing: 'border-box' },
}}
slotProps={{
root: {
keepMounted: true, // Better open performance on mobile.
},
}}
>
<Toolbar />
<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) =>
<BackstoryTab key={i} active={i === activeTab}>{tab.children}</BackstoryTab>
)
}
</Box>
<Snack
ref={snackRef}
/>
</Box >
);
};
export default App;