458 lines
14 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } 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 Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
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 { ResumeBuilder } from './ResumeBuilder';
import { Message, ChatQuery, MessageList, MessageData } from './Message';
import { SeverityType } from './Snack';
import { VectorVisualizer } from './VectorVisualizer';
import { Controls } from './Controls';
import { Conversation, ConversationHandle } from './Conversation';
import './App.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 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}
>
{tab === index && children}
</div>
);
}
const App = () => {
const conversationRef = useRef<any>(null);
const [processing, setProcessing] = useState(false);
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 [snackOpen, setSnackOpen] = useState(false);
const [snackMessage, setSnackMessage] = useState("");
const [snackSeverity, setSnackSeverity] = useState<SeverityType>("success");
const [tab, setTab] = useState<number>(0);
const [about, setAbout] = useState<string>("");
const [resume, setResume] = useState<MessageData | undefined>(undefined);
const [facts, setFacts] = useState<MessageData | undefined>(undefined);
const isDesktop = useMediaQuery('(min-width:650px)');
const prevIsDesktopRef = useRef<boolean>(isDesktop);
const chatRef = useRef<ConversationHandle>(null);
// Set the snack pop-up and open it
const setSnack = useCallback((message: string, severity: SeverityType = "success") => {
setSnackMessage(message);
setSnackSeverity(severity);
setSnackOpen(true);
}, []);
useEffect(() => {
if (prevIsDesktopRef.current === isDesktop)
return;
if (menuOpen) {
setMenuOpen(false);
}
prevIsDesktopRef.current = isDesktop;
}, [isDesktop, setMenuOpen, menuOpen])
// 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);
};
const chatPreamble: MessageList = [
{
role: 'info',
content: `
# Welcome to Backstory
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: "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>,
<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>
];
// Extract the sessionId from the URL if present, otherwise
// request a sessionId from the server.
useEffect(() => {
const url = new URL(window.location.href);
const pathParts = url.pathname.split('/').filter(Boolean);
if (!pathParts.length) {
console.log("No session id -- creating a new session")
fetch(connectionBase + `/api/context`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
.then(response => response.json())
.then(data => {
console.log(`Session id: ${data.id} -- returned from server`)
setSessionId(data.id);
window.history.replaceState({}, '', `/${data.id}`);
})
.catch(error => console.error('Error generating session ID:', error));
} else {
console.log(`Session id: ${pathParts[0]} -- existing session`)
setSessionId(pathParts[0]);
}
}, [setSessionId, connectionBase]);
const handleMenuClose = () => {
setIsMenuClosing(true);
setMenuOpen(false);
};
const handleMenuTransitionEnd = () => {
setIsMenuClosing(false);
};
const handleMenuToggle = () => {
if (!isMenuClosing) {
setMenuOpen(!menuOpen);
}
};
const settingsPanel = (
<>
{sessionId !== undefined &&
<Controls {...{ sessionId, setSnack, connectionBase }} />}
</>
);
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTab(newValue);
handleMenuClose();
};
const menuDrawer = (
<Card className="MenuCard">
<Tabs sx={{ display: "flex", flexGrow: 1 }}
orientation="vertical"
value={tab}
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 handleSnackClose = (
event: React.SyntheticEvent | Event,
reason?: SnackbarCloseReason,
) => {
if (reason === 'clickaway') {
return;
}
setSnackOpen(false);
};
/* toolbar height is 56px + 8px margin-top */
const Offset = styled('div')(({ theme }) => ({ ...theme.mixins.toolbar, minHeight: '64px', height: '64px' }));
return (
<Box className="App" sx={{ display: 'flex', flexDirection: 'column', height: '100dvh' }}>
<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={() => { setTab(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={tab}
indicatorColor="secondary"
textColor="inherit"
variant="fullWidth"
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>
}
</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 />
{menuDrawer}
</Drawer>
</Box>
<CustomTabPanel tab={tab} index={0}>
<Box component="main" sx={{ flexGrow: 1, overflow: 'auto' }} className="ChatBox" ref={conversationRef}>
<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 {...{ facts, setFacts, resume, setResume, processing, setProcessing, setSnack, connectionBase: 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>
<Snackbar open={snackOpen} autoHideDuration={(snackSeverity === "success" || snackSeverity === "info") ? 1500 : 6000} onClose={handleSnackClose}>
<Alert
onClose={handleSnackClose}
severity={snackSeverity}
variant="filled"
sx={{ width: '100%' }}
>
{snackMessage}
</Alert>
</Snackbar>
</Box >
);
};
export default App;