477 lines
15 KiB
TypeScript
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; |