458 lines
14 KiB
TypeScript
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; |