402 lines
13 KiB
TypeScript
402 lines
13 KiB
TypeScript
import React, { 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 { Snack, SeverityType } from './Snack';
|
|
import { ConversationHandle } from './Conversation';
|
|
import { QueryOptions } from './ChatQuery';
|
|
import { Scrollable } from './Scrollable';
|
|
import { BackstoryPage, BackstoryTabProps } from './BackstoryTab';
|
|
|
|
import { connectionBase } from './Global';
|
|
|
|
import { HomePage } from './HomePage';
|
|
import { ResumeBuilderPage } from './ResumeBuilderPage';
|
|
import { VectorVisualizerPage } from './VectorVisualizer';
|
|
import { AboutPage } from './AboutPage';
|
|
import { ControlsPage } from './ControlsPage';
|
|
|
|
|
|
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';
|
|
|
|
|
|
|
|
const isValidUUIDv4 = (str: string): boolean => {
|
|
const pattern = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89ab][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/i;
|
|
return pattern.test(str);
|
|
}
|
|
|
|
const App = () => {
|
|
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [isMenuClosing, setIsMenuClosing] = useState(false);
|
|
const [activeTab, setActiveTab] = useState<number>(0);
|
|
const isDesktop = useMediaQuery('(min-width:650px)');
|
|
const prevIsDesktopRef = useRef<boolean>(isDesktop);
|
|
const chatRef = useRef<ConversationHandle>(null);
|
|
const snackRef = useRef<any>(null);
|
|
const [subRoute, setSubRoute] = useState<string>("");
|
|
|
|
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]);
|
|
|
|
const handleSubmitChatQuery = (prompt: string, tunables?: QueryOptions) => {
|
|
console.log(`handleSubmitChatQuery: ${prompt} ${tunables || {}} -- `, chatRef.current ? ' sending' : 'no handler');
|
|
chatRef.current?.submitQuery(prompt, tunables);
|
|
setActiveTab(0);
|
|
};
|
|
|
|
const tabs: BackstoryTabProps[] = useMemo(() => {
|
|
const homeTab: BackstoryTabProps = {
|
|
label: "",
|
|
path: "",
|
|
tabProps: {
|
|
label: "Backstory",
|
|
sx: { flexGrow: 1, fontSize: '1rem' },
|
|
icon:
|
|
<Avatar sx={{
|
|
width: 24,
|
|
height: 24
|
|
}}
|
|
variant="rounded"
|
|
alt="Backstory logo"
|
|
src="/logo192.png" />,
|
|
iconPosition: "start"
|
|
},
|
|
children: <HomePage ref={chatRef} {...{ sessionId, setSnack, submitQuery: handleSubmitChatQuery, route: subRoute, setRoute: setSubRoute }} />
|
|
};
|
|
|
|
const resumeBuilderTab: BackstoryTabProps = {
|
|
label: "Resume Builder",
|
|
path: "resume-builder",
|
|
children: <ResumeBuilderPage {...{ sessionId, setSnack, submitQuery: handleSubmitChatQuery, route: subRoute, setRoute: setSubRoute }} />
|
|
};
|
|
|
|
const contextVisualizerTab: BackstoryTabProps = {
|
|
label: "Context Visualizer",
|
|
path: "context-visualizer",
|
|
children: <VectorVisualizerPage sx={{ p: 1 }} {...{ sessionId, setSnack, submitQuery: handleSubmitChatQuery, route: subRoute, setRoute: setSubRoute }} />
|
|
};
|
|
|
|
const aboutTab = {
|
|
label: "About",
|
|
path: "about",
|
|
children: <AboutPage {...{ sessionId, setSnack, submitQuery: handleSubmitChatQuery, route: subRoute, setRoute: setSubRoute }} />
|
|
};
|
|
|
|
const controlsTab: BackstoryTabProps = {
|
|
path: "controls",
|
|
tabProps: {
|
|
sx: { flexShrink: 1, flexGrow: 0, fontSize: '1rem' },
|
|
icon: <SettingsIcon />
|
|
},
|
|
children: (
|
|
<Scrollable
|
|
autoscroll={false}
|
|
sx={{
|
|
maxWidth: "1024px",
|
|
height: "calc(100vh - 72px)",
|
|
flexDirection: "column",
|
|
margin: "0 auto",
|
|
p: 1,
|
|
}}
|
|
>
|
|
{sessionId !== undefined &&
|
|
<ControlsPage {...{ sessionId, setSnack, submitQuery: handleSubmitChatQuery, route: subRoute, setRoute: setSubRoute }} />
|
|
}
|
|
</Scrollable>
|
|
)
|
|
};
|
|
|
|
return [
|
|
homeTab,
|
|
resumeBuilderTab,
|
|
contextVisualizerTab,
|
|
aboutTab,
|
|
controlsTab,
|
|
];
|
|
}, [sessionId, setSnack, subRoute]);
|
|
|
|
|
|
useEffect(() => {
|
|
if (sessionId === undefined || activeTab > tabs.length - 1) { return; }
|
|
console.log(`route - '${tabs[activeTab].path}', subRoute - '${subRoute}'`);
|
|
|
|
let path = tabs[activeTab].path ? `/${tabs[activeTab].path}` : '';
|
|
if (subRoute) {
|
|
path += `/${subRoute}`;
|
|
}
|
|
path += `/${sessionId}`;
|
|
console.log('pushState: ', path);
|
|
// window.history.pushState({}, '', path);
|
|
}, [activeTab, sessionId, subRoute, tabs]);
|
|
|
|
const fetchSession = useCallback((async (pathParts?: string[]) => {
|
|
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 new_session = (await response.json()).id;
|
|
console.log(`Session created: ${new_session}`);
|
|
|
|
if (pathParts === undefined) {
|
|
setSessionId(new_session);
|
|
const newPath = `/${new_session}`;
|
|
window.history.replaceState({}, '', newPath);
|
|
} else {
|
|
const currentPath = pathParts.length < 2 ? '' : pathParts[0];
|
|
let tabIndex = tabs.findIndex((tab) => tab.path === currentPath);
|
|
if (-1 === tabIndex) {
|
|
console.log(`Invalid path "${currentPath}" -- redirecting to default`);
|
|
window.history.replaceState({}, '', `/${new_session}`);
|
|
setActiveTab(0);
|
|
} else {
|
|
window.history.replaceState({}, '', `/${pathParts.join('/')}/${new_session}`);
|
|
// tabs[tabIndex].route = pathParts[2] || "";
|
|
setActiveTab(tabIndex);
|
|
}
|
|
setSessionId(new_session);
|
|
}
|
|
} catch (error: any) {
|
|
console.error(error);
|
|
setSnack("Server is temporarily down", "error");
|
|
}
|
|
}), [setSnack, tabs]);
|
|
|
|
useEffect(() => {
|
|
const url = new URL(window.location.href);
|
|
const pathParts = url.pathname.split('/').filter(Boolean); // [path, sessionId]
|
|
|
|
if (pathParts.length < 1) {
|
|
console.log("No session id or path -- creating new session");
|
|
fetchSession();
|
|
} else {
|
|
const currentPath = pathParts.length < 2 ? '' : pathParts[0];
|
|
const path_session = pathParts.length < 2 ? pathParts[0] : pathParts[1];
|
|
if (!isValidUUIDv4(path_session)) {
|
|
console.log(`Invalid session id ${path_session}-- creating new session`);
|
|
fetchSession();
|
|
} else {
|
|
let tabIndex = tabs.findIndex((tab) => tab.path === currentPath);
|
|
if (-1 === tabIndex) {
|
|
console.log(`Invalid path "${currentPath}" -- redirecting to default`);
|
|
tabIndex = 0;
|
|
}
|
|
// tabs[tabIndex].route = pathParts[2] || ""
|
|
setSessionId(path_session);
|
|
setActiveTab(tabIndex);
|
|
}
|
|
}
|
|
}, [setSessionId, setSnack, tabs, fetchSession]);
|
|
|
|
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;
|
|
let path = `/${sessionId}`;
|
|
if (tabPath) {
|
|
// if (openDocument) {
|
|
// path = `/${tabPath}/${openDocument}/${sessionId}`;
|
|
// } else {
|
|
path = `/${tabPath}/${sessionId}`;
|
|
// }
|
|
}
|
|
window.history.pushState({}, '', path);
|
|
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) =>
|
|
<BackstoryPage key={i} active={i === activeTab} path={tab.path}>{tab.children}</BackstoryPage>
|
|
)
|
|
}
|
|
</Box>
|
|
|
|
<Snack
|
|
ref={snackRef}
|
|
/>
|
|
</Box >
|
|
);
|
|
};
|
|
|
|
export default App; |