theme updates and better chat config components

This commit is contained in:
its-a-feature
2026-05-22 11:42:37 -07:00
parent 2c25e31dbf
commit 7dbecdea37
6 changed files with 2981 additions and 2065 deletions
+6 -4
View File
@@ -4,7 +4,7 @@ import { Typography } from '@mui/material';
import { useReactiveVar } from '@apollo/client';
import { useDarkMode } from './utilities/useDarkMode';
import { createTheme, ThemeProvider, StyledEngineProvider } from '@mui/material/styles';
import { GlobalStyles } from '../themes/GlobalStyles';
import { GlobalStyles, ThemeVariables } from '../themes/GlobalStyles';
import CssBaseline from '@mui/material/CssBaseline';
import {FailedRefresh, mePreferences, meState, operatorSettingDefaults} from '../cache';
import { Tooltip } from 'react-tooltip';
@@ -939,7 +939,8 @@ export function App(props) {
return (
<StyledEngineProvider injectFirst>
<ThemeProvider theme={theme}>
<GlobalStyles theme={theme} />
<ThemeVariables theme={theme} />
<GlobalStyles />
<CssBaseline />
<div style={{width: '100%', height: '100%', display: "flex", position: "relative",}}>
<MythicLoadingState compact title="Loading Preferences" description="Fetching user preferences." sx={{color: "inherit"}} />
@@ -958,7 +959,8 @@ export function App(props) {
return (
<StyledEngineProvider injectFirst>
<ThemeProvider theme={theme}>
<GlobalStyles theme={theme} />
<ThemeVariables theme={theme} />
<GlobalStyles />
<CssBaseline />
<MeContext.Provider value={me}>
<Tooltip id={"my-tooltip"} style={{zIndex: 100000, wordBreak: "break-word", maxWidth: "80%", whiteSpace: "pre-wrap"}}/>
@@ -981,7 +983,7 @@ export function App(props) {
/>
}
{me.loggedIn && me.user !== undefined && me.user !== null &&
<TopAppBarVertical me={me} toggleTheme={themeToggler} />
<TopAppBarVertical me={me} toggleTheme={themeToggler} themeMode={themeMode} />
}
<div style={{
maxHeight: '100%',
+77 -106
View File
@@ -1,7 +1,6 @@
import React from 'react';
import { styled } from '@mui/material/styles';
import CameraAltTwoToneIcon from '@mui/icons-material/CameraAltTwoTone';
import { useTheme } from '@mui/material/styles';
import MuiDrawer from '@mui/material/Drawer';
import IconButton from '@mui/material/IconButton';
import MenuIcon from '@mui/icons-material/Menu';
@@ -90,36 +89,36 @@ const classes = {
listSubHeader: `${PREFIX}-listSubHeader`,
};
const openedMixin = (theme) => ({
const openedMixin = () => ({
width: drawerWidth,
overflowX: 'hidden',
borderRadius: "0 !important",
border: "0px !important",
background: `${theme.navigation.background} !important`,
backgroundColor: `${theme.navigation.backgroundColor} !important`,
background: "var(--mythic-nav-background) !important",
backgroundColor: "var(--mythic-nav-background-color) !important",
});
const closedMixin = (theme) => ({
const closedMixin = () => ({
overflowX: 'hidden',
width: "60px",
borderRadius: "0 !important",
border: "0px !important",
background: `${theme.navigation.background} !important`,
backgroundColor: `${theme.navigation.backgroundColor} !important`,
[theme.breakpoints.up('sm')]: {
background: "var(--mythic-nav-background) !important",
backgroundColor: "var(--mythic-nav-background-color) !important",
'@media (min-width: 600px)': {
width: "60px",
borderRadius: "0 !important",
border: "0px !important",
},
});
const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== 'open' })(
({ theme }) => ({
() => ({
width: drawerWidth,
flexShrink: 0,
whiteSpace: 'nowrap',
boxSizing: 'border-box',
background: `${theme.navigation.background} !important`,
backgroundColor: `${theme.navigation.backgroundColor} !important`,
color: theme.navigation.text,
background: "var(--mythic-nav-background) !important",
backgroundColor: "var(--mythic-nav-background-color) !important",
color: "var(--mythic-nav-text)",
'& .MuiDrawer-paper': {
border: "0 !important",
borderRight: "0 !important",
@@ -137,18 +136,18 @@ const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== 'open'
width: "auto",
},
[`& .${classes.listSubHeader}:hover`]: {
color: `${theme.navigation.text} !important`,
backgroundColor: `${theme.navigation.hover} !important`,
color: "var(--mythic-nav-text) !important",
backgroundColor: "var(--mythic-nav-hover) !important",
},
variants: [
{
props: ({ open }) => open,
style: {
...openedMixin(theme),
...openedMixin(),
'& .MuiDrawer-paper': {
...openedMixin(theme),
background: `${theme.navigation.background} !important`,
backgroundColor: `${theme.navigation.backgroundColor} !important`,
...openedMixin(),
background: "var(--mythic-nav-background) !important",
backgroundColor: "var(--mythic-nav-background-color) !important",
border: "0 !important",
borderRight: "0 !important",
borderRadius: "0 !important",
@@ -164,11 +163,11 @@ const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== 'open'
{
props: ({ open }) => !open,
style: {
...closedMixin(theme),
...closedMixin(),
'& .MuiDrawer-paper': {
...closedMixin(theme),
background: `${theme.navigation.background} !important`,
backgroundColor: `${theme.navigation.backgroundColor} !important`,
...closedMixin(),
background: "var(--mythic-nav-background) !important",
backgroundColor: "var(--mythic-nav-background-color) !important",
border: "0 !important",
borderRight: "0 !important",
borderRadius: "0 !important",
@@ -196,15 +195,15 @@ const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== 'open'
}),
);
export const StyledListItem = styled(ListItem)(
({ theme }) => ({
() => ({
minHeight: "34px",
paddingTop: "4px",
paddingLeft: "8px",
paddingRight: "8px",
margin: "2px 4px",
paddingBottom: "4px",
borderRadius: theme.shape.borderRadius,
color: theme.navigation.text,
borderRadius: "var(--mythic-radius)",
color: "var(--mythic-nav-text)",
backgroundColor: "transparent !important",
boxSizing: "border-box",
maxWidth: "calc(100% - 8px)",
@@ -214,23 +213,23 @@ export const StyledListItem = styled(ListItem)(
minWidth: 0,
},
"& .MuiListItemText-primary": {
fontSize: theme.typography.pxToRem(12.5),
fontSize: "0.78125rem",
fontWeight: 600,
},
"&:hover": {
backgroundColor: `${theme.navigation.hover} !important`,
backgroundColor: "var(--mythic-nav-hover) !important",
},
}),
);
export const StyledListItemIcon = styled(ListItemIcon)(
({ theme }) => ({
() => ({
paddingTop:0,
marginTop: 0,
paddingBottom: 0,
minWidth: "36px",
width: "36px",
justifyContent: "center",
color: theme.navigation.icon,
color: "var(--mythic-nav-icon)",
backgroundColor: "transparent !important",
overflow: "visible",
}),
@@ -318,12 +317,11 @@ const getUnreadChatCount = (channels, readState) => {
};
const Dashboard = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new' key={"home"} >
<StyledListItemIcon >
<MythicStyledTooltip title={"Operation Dashboard"} tooltipStyle={{display: "inline-flex"}}>
<SpaceDashboardTwoToneIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement" />
<SpaceDashboardTwoToneIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon" />
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Operation Dashboard"} />
@@ -331,12 +329,11 @@ const Dashboard = () => {
)
}
const ActiveCallbacks = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/callbacks' key={"callbacks"} >
<StyledListItemIcon>
<MythicStyledTooltip title={"Active Callbacks"} tooltipStyle={{display: "inline-flex"}}>
<PhoneCallbackIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
<PhoneCallbackIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon"/>
</MythicStyledTooltip>
</StyledListItemIcon>
@@ -345,12 +342,11 @@ const ActiveCallbacks = () => {
)
}
const Payloads = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/payloads' key={"payloads"} >
<StyledListItemIcon>
<MythicStyledTooltip title={"Payloads"} tooltipStyle={{display: "inline-flex"}}>
<FontAwesomeIcon style={{color: theme.navBarTextIconColor}} icon={faBiohazard} size="lg"/>
<FontAwesomeIcon className="mythic-navigation-icon" icon={faBiohazard} size="lg"/>
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Payloads"} />
@@ -358,13 +354,12 @@ const Payloads = () => {
)
}
const SearchCallbacks = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/search?tab=callbacks&searchField=Host&search=' >
<StyledListItemIcon>
<MythicStyledTooltip title={"Search Callbacks"} tooltipStyle={{display: "inline-flex"}}>
<PhoneCallbackIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
<ManageSearchIcon style={{color: theme.navBarTextIconColor, marginLeft: "-8px", marginTop: "7px", borderRadius: "5px"}} fontSize={"small"} />
<PhoneCallbackIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon"/>
<ManageSearchIcon className="mythic-navigation-icon" style={{marginLeft: "-8px", marginTop: "7px", borderRadius: "5px"}} fontSize={"small"} />
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Search Callbacks"} />
@@ -372,12 +367,11 @@ const SearchCallbacks = () => {
)
}
const SearchTasks = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/search?tab=tasks&searchField=Command+and+Parameters&search=&taskStatus=' >
<StyledListItemIcon>
<MythicStyledTooltip title={"Search Tasks"} tooltipStyle={{display: "inline-flex"}}>
<AssignmentIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
<AssignmentIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon"/>
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Search Tasks"} />
@@ -385,13 +379,12 @@ const SearchTasks = () => {
)
}
const SearchPayloads = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/search?tab=payloads&searchField=Filename&search=&taskStatus=&c2=All+C2&payloadtype=All+Payload+Types'>
<StyledListItemIcon>
<MythicStyledTooltip title={"Search Payloads"} tooltipStyle={{display: "inline-flex"}}>
<FontAwesomeIcon style={{color: theme.navBarTextIconColor}} size={"lg"} icon={faBiohazard} />
<ManageSearchIcon style={{color: theme.navBarTextIconColor, marginLeft: "-8px", marginTop: "7px", borderRadius: "5px"}} fontSize={"small"} />
<FontAwesomeIcon className="mythic-navigation-icon" size={"lg"} icon={faBiohazard} />
<ManageSearchIcon className="mythic-navigation-icon" style={{marginLeft: "-8px", marginTop: "7px", borderRadius: "5px"}} fontSize={"small"} />
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Search Payloads"} />
@@ -399,12 +392,11 @@ const SearchPayloads = () => {
)
}
const SearchFiles = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/search?searchField=Filename&tab=files&location=Downloads&host=&search=' >
<StyledListItemIcon>
<MythicStyledTooltip title={"Search Files"} tooltipStyle={{display: "inline-flex"}}>
<AttachmentIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
<AttachmentIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon"/>
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Search Files"} />
@@ -412,12 +404,11 @@ const SearchFiles = () => {
)
}
const SearchScreenshots = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/search?searchField=Filename&tab=files&location=Screenshots' >
<StyledListItemIcon>
<MythicStyledTooltip title={"Search Screenshots"} tooltipStyle={{display: "inline-flex"}}>
<CameraAltTwoToneIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
<CameraAltTwoToneIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon"/>
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Search Screenshots"} />
@@ -425,12 +416,11 @@ const SearchScreenshots = () => {
)
}
const SearchCredentials = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/search?t?searchField=Account&tab=credentials&search='>
<StyledListItemIcon>
<MythicStyledTooltip title={"Search Credentials"} tooltipStyle={{display: "inline-flex"}}>
<VpnKeyIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement" />
<VpnKeyIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon" />
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Search Credentials"} />
@@ -438,12 +428,11 @@ const SearchCredentials = () => {
)
}
const SearchKeylogs = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/search?tab=keylogs&searchField=Host&search='>
<StyledListItemIcon>
<MythicStyledTooltip title={"Search Keylogs"} tooltipStyle={{display: "inline-flex"}}>
<KeyboardIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
<KeyboardIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon"/>
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Search Keylogs"} />
@@ -451,12 +440,11 @@ const SearchKeylogs = () => {
)
}
const SearchArtifacts = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/search?tab=artifacts&searchField=Host&search=' >
<StyledListItemIcon>
<MythicStyledTooltip title={"Search Artifacts"} tooltipStyle={{display: "inline-flex"}}>
<FingerprintIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
<FingerprintIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon"/>
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Search Artifacts"} />
@@ -464,12 +452,11 @@ const SearchArtifacts = () => {
)
}
const SearchTokens = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/search?tab=tokens&searchField=Host&search=' >
<StyledListItemIcon>
<MythicStyledTooltip title={"Search Tokens"} tooltipStyle={{display: "inline-flex"}}>
<ConfirmationNumberIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
<ConfirmationNumberIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon"/>
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Search Tokens"} />
@@ -477,12 +464,11 @@ const SearchTokens = () => {
)
}
const SearchProxies = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/search?tab=proxies'>
<StyledListItemIcon>
<MythicStyledTooltip title={"Search Proxies"} tooltipStyle={{display: "inline-flex"}}>
<FontAwesomeIcon style={{color: theme.navBarTextIconColor}} size={"lg"} icon={faSocks} />
<FontAwesomeIcon className="mythic-navigation-icon" size={"lg"} icon={faSocks} />
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Search Proxies"} />
@@ -490,12 +476,11 @@ const SearchProxies = () => {
)
}
const SearchProcesses = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/search?tab=processes&searchField=Name&search=&host=' >
<StyledListItemIcon>
<MythicStyledTooltip title={"Search Processes"} tooltipStyle={{display: "inline-flex"}}>
<AccountTreeIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
<AccountTreeIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon"/>
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Search Processes"} />
@@ -503,13 +488,12 @@ const SearchProcesses = () => {
)
}
const SearchTags = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/search?tab=tags&searchField=TagType&search=&host='>
<StyledListItemIcon>
<MythicStyledTooltip title={"Search Tags"} tooltipStyle={{display: "inline-flex"}}>
<LocalOfferTwoToneIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
<ManageSearchIcon style={{color: theme.navBarTextIconColor, marginLeft: "-8px", marginTop: "7px", borderRadius: "5px"}} fontSize={"small"} />
<LocalOfferTwoToneIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon"/>
<ManageSearchIcon className="mythic-navigation-icon" style={{marginLeft: "-8px", marginTop: "7px", borderRadius: "5px"}} fontSize={"small"} />
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Search Tags"} />
@@ -517,12 +501,11 @@ const SearchTags = () => {
)
}
const Mitre = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/mitre' >
<StyledListItemIcon>
<MythicStyledTooltip title={"MITRE ATT&CK"} tooltipStyle={{display: "inline-flex"}}>
<TableChartTwoToneIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
<TableChartTwoToneIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon"/>
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"MITRE ATT&CK"} />
@@ -530,12 +513,11 @@ const Mitre = () => {
)
}
const Reporting = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/reporting' >
<StyledListItemIcon>
<MythicStyledTooltip title={"Reporting"} tooltipStyle={{display: "inline-flex"}}>
<SportsScoreIcon style={{color: theme.navBarTextIconColor}} size={"medium"} />
<SportsScoreIcon className="mythic-navigation-icon" size={"medium"} />
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Reporting"} />
@@ -543,12 +525,11 @@ const Reporting = () => {
)
}
const Tags = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/tagtypes' >
<StyledListItemIcon>
<MythicStyledTooltip title={"Tags"} tooltipStyle={{display: "inline-flex"}}>
<LocalOfferTwoToneIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
<LocalOfferTwoToneIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon"/>
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Tags"} />
@@ -556,12 +537,11 @@ const Tags = () => {
)
}
const Eventing = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/eventing' >
<StyledListItemIcon>
<MythicStyledTooltip title={"Eventing"} tooltipStyle={{display: "inline-flex"}}>
<PlayCircleFilledTwoToneIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
<PlayCircleFilledTwoToneIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon"/>
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Eventing"} />
@@ -569,7 +549,6 @@ const Eventing = () => {
)
}
const Chat = ({me}) => {
const theme = useTheme();
const streamStart = React.useRef(getSkewedNow().toISOString());
const [channels, setChannels] = React.useState([]);
const [readState, setReadState] = React.useState({});
@@ -634,7 +613,7 @@ const Chat = ({me}) => {
invisible={!error && unreadCount === 0}
max={99}
>
<ForumTwoToneIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
<ForumTwoToneIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon"/>
</Badge>
</MythicStyledTooltip>
</StyledListItemIcon>
@@ -667,12 +646,11 @@ const GraphQL = () => {
)
}
const CreatePayload = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/createpayload' key={"createpayload"} state={{from: 'TopAppBar'}}>
<StyledListItemIcon>
<MythicStyledTooltip title={"Create Payload"} tooltipStyle={{display: "inline-flex"}}>
<FontAwesomeIcon style={{color: theme.navBarTextIconColor}} size={"lg"} icon={faBiohazard} />
<FontAwesomeIcon className="mythic-navigation-icon" size={"lg"} icon={faBiohazard} />
<AddCircleIcon color={"success"} style={{marginLeft: "-8px", marginTop: "7px", backgroundColor: "white", borderRadius: "10px"}} fontSize={"small"} />
</MythicStyledTooltip>
</StyledListItemIcon>
@@ -681,12 +659,11 @@ const CreatePayload = () => {
)
}
const CreateWrapper = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/createwrapper' key={"createwrapper"} >
<StyledListItemIcon>
<MythicStyledTooltip title={"Create Wrapper"} tooltipStyle={{display: "inline-flex"}}>
<PostAddIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
<PostAddIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon"/>
<AddCircleIcon color={"success"} style={{marginLeft: "-8px", marginTop: "7px", backgroundColor: "white", borderRadius: "10px"}} fontSize={"small"} />
</MythicStyledTooltip>
</StyledListItemIcon>
@@ -695,12 +672,11 @@ const CreateWrapper = () => {
)
}
const PayloadTypesAndC2 = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/payloadtypes' key={"payloadtypes"}>
<StyledListItemIcon>
<MythicStyledTooltip title={"Installed Services"} tooltipStyle={{display: "inline-flex"}}>
<HeadsetTwoToneIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
<HeadsetTwoToneIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon"/>
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Installed Services"} />
@@ -708,12 +684,11 @@ const PayloadTypesAndC2 = () => {
)
}
const Operations = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/operations' key={"modifyoperations"}>
<StyledListItemIcon>
<MythicStyledTooltip title={"Modify Operations"} tooltipStyle={{display: "inline-flex"}}>
<EditIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
<EditIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon"/>
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Modify Operations"} />
@@ -721,12 +696,11 @@ const Operations = () => {
)
}
const BrowserScripts = () => {
const theme = useTheme();
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/browserscripts' key={"browserscripts"} >
<StyledListItemIcon>
<MythicStyledTooltip title={"Browser Scripts"} tooltipStyle={{display: "inline-flex"}}>
<CodeOffIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
<CodeOffIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon"/>
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"BrowserScripts"} />
@@ -866,7 +840,6 @@ const TopAppBarVerticalAdjustShortcutsDialog = ({onClose, onSave, sideShortcuts}
}
export function TopAppBarVertical(props) {
const theme = useTheme();
const me = props.me;
const navigate = useNavigate();
const initialNavBarOpen = GetMythicSetting({setting_name: 'navBarOpen', default_value: operatorSettingDefaults.navBarOpen});
@@ -976,8 +949,7 @@ export function TopAppBarVertical(props) {
<>
{me?.user?.current_operation_id ? (<EventFeedNotifications me={me} />) : null }
<Drawer anchor="left" variant="permanent" open={menuOpen} onClose={handleDrawerClose}
PaperProps={{sx: {border: "0 !important", borderRight: "0 !important", borderRadius: "0 !important", boxShadow: "none !important"}}}
style={{background: theme.navigation.background, backgroundColor: theme.navigation.backgroundColor}}>
PaperProps={{sx: {border: "0 !important", borderRight: "0 !important", borderRadius: "0 !important", boxShadow: "none !important"}}}>
<List style={{paddingTop: 0, marginTop: 0, height: "100%", display: "flex", flexDirection: "column",
backgroundColor: "transparent !important",
border: "0 !important", borderRadius: 0}}>
@@ -990,37 +962,37 @@ export function TopAppBarVertical(props) {
paddingLeft: "8px",
paddingRight: "8px",
paddingBottom: "5px",
borderRadius: theme.shape.borderRadius,
borderRadius: "var(--mythic-radius)",
}}>
<StyledListItemIcon>
<img src={ReactLogo} onClick={()=>navigate('/new')} width={"35px"} height={"35px"} alt="Mythic" style={{cursor: "pointer"}}/>
</StyledListItemIcon>
<ListItemText style={{margin: 0}} primary={
<>
<Typography style={{ fontSize: 12, color: theme.navigation.muted, display: "inline-block",
<Typography className="mythic-navigation-muted-text" style={{ fontSize: 12, display: "inline-block",
marginLeft: "1rem",
lineHeight: 1.35}}>
<b>Mythic:</b> v{serverVersion}<br/>
<b>UI:</b> v{mythicUIVersion}<br/>
</Typography>
<IconButton onClick={props.toggleTheme} size="small" style={{float:"right", display: menuOpen ? "" : "none", color: theme.navigation.icon}} >
{theme.palette.mode === 'light' &&
<DarkModeTwoToneIcon style={{color: theme.navigation.icon}} fontSize={"medium"} className="mythicElement" />
<IconButton onClick={props.toggleTheme} size="small" style={{float:"right", display: menuOpen ? "" : "none"}} >
{props.themeMode === 'light' &&
<DarkModeTwoToneIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon" />
}
{theme.palette.mode === 'dark' &&
<LightModeTwoToneIcon style={{color: theme.palette.warning.main}} fontSize={"medium"} className="mythicElement" />
{props.themeMode === 'dark' &&
<LightModeTwoToneIcon fontSize={"medium"} className="mythicElement mythic-navigation-warning-icon" />
}
</IconButton>
</>
} />
</ListItem>
<StyledListItem className={classes.listSubHeader} onClick={toggleDrawerOpen} style={{height: "32px"}} >
<StyledListItemIcon ><MenuIcon style={{color: theme.navBarTextIconColor}} onClick={toggleDrawerOpen} fontSize={"medium"} className="mythicElement" /></StyledListItemIcon>
<StyledListItemIcon ><MenuIcon onClick={toggleDrawerOpen} fontSize={"medium"} className="mythicElement mythic-navigation-icon" /></StyledListItemIcon>
<ListItemText primary={
<>
<MythicStyledTooltip title={"Edit Shortcuts"} tooltipStyle={{float: menuOpen ? 'right' : '', margin: 0, padding: 0}}>
<Button onClick={openEditShortcuts} style={{color: theme.navBarTextColor}}>
<EditIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"}/> Edit
<Button onClick={openEditShortcuts} className="mythic-navigation-action-text">
<EditIcon className="mythic-navigation-icon" fontSize={"medium"}/> Edit
</Button>
</MythicStyledTooltip>
@@ -1039,7 +1011,7 @@ export function TopAppBarVertical(props) {
<StyledListItem className={classes.listSubHeader} style={{display: me?.user?.current_operation_id === 0 ? "" : "none"}}>
<ListItemText primary={
<>
<Link style={{display: "inline-flex", alignItems: "center", paddingRight: "10px", color: theme.palette.error.main,
<Link style={{display: "inline-flex", alignItems: "center", paddingRight: "10px", color: "var(--mythic-error-main)",
fontWeight: "bold",}} to="/new/operations">
{"CLICK TO SET OPERATION!"}
</Link>
@@ -1047,19 +1019,19 @@ export function TopAppBarVertical(props) {
} />
</StyledListItem>
<Divider style={{borderColor: theme.navigation.muted, margin: "4px 8px"}} />
<Divider style={{borderColor: "var(--mythic-nav-muted)", margin: "4px 8px"}} />
<div style={{flexGrow: 1, overflowY: "auto", overflowX: "hidden"}}>
{getShortcuts({shortcuts: sideShortcuts})}
<Divider style={{borderColor: theme.navigation.muted, margin: "4px 8px"}} />
<Divider style={{borderColor: "var(--mythic-nav-muted)", margin: "4px 8px"}} />
<StyledListItem className={classes.listSubHeader} onClick={handleToggleExtra}>
<StyledListItemIcon>
<MoreHorizIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} />
<MoreHorizIcon className="mythic-navigation-icon" fontSize={"medium"} />
</StyledListItemIcon>
<ListItemText>Extra Shortcuts</ListItemText>
{openExtra ? <ExpandLess /> : <ExpandMore />}
</StyledListItem>
{openExtra && getExtraShortcuts()}
<Divider style={{borderColor: theme.navigation.muted, margin: "4px 8px"}} />
<Divider style={{borderColor: "var(--mythic-nav-muted)", margin: "4px 8px"}} />
<div className={classes.listSubHeader} style={{ flexGrow: 1}}></div>
</div>
<TopBarRightShortcutsVertical me={me} menuOpen={menuOpen} serverName={serverName} />
@@ -1070,7 +1042,6 @@ export function TopAppBarVertical(props) {
}
function TopBarRightShortcutsVertical({me, menuOpen, serverName}){
const theme = useTheme();
const documentationRef = React.useRef(null);
const [documentationOpen, setDocumentationOpen] = React.useState(false);
const settingsRef = React.useRef(null);
@@ -1233,7 +1204,7 @@ function TopBarRightShortcutsVertical({me, menuOpen, serverName}){
<StyledListItem className={classes.listSubHeader} onClick={() => setOpenFeedbackForm(true)} >
<StyledListItemIcon>
<MythicStyledTooltip title={"Submit feedback via Webhook"} tooltipStyle={{display: "inline-flex"}}>
<ThumbDownTwoTone style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement" />
<ThumbDownTwoTone fontSize={"medium"} className="mythicElement mythic-navigation-icon" />
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Send Feedback"} />
@@ -1249,11 +1220,11 @@ function TopBarRightShortcutsVertical({me, menuOpen, serverName}){
<StyledListItem className={classes.listSubHeader} onClick={handleDocumentationMenu} >
<StyledListItemIcon>
<MythicStyledTooltip title={"Documentation Links"} tooltipStyle={{display: "inline-flex"}}>
<HelpTwoToneIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
<HelpTwoToneIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon"/>
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Help"} />
<KeyboardArrowDownIcon style={{color: theme.navBarTextIconColor, display: menuOpen ? "" : "none", flex: "0 0 auto", marginLeft: "auto"}} />
<KeyboardArrowDownIcon className="mythic-navigation-icon" style={{display: menuOpen ? "" : "none", flex: "0 0 auto", marginLeft: "auto"}} />
</StyledListItem>
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/EventFeed' >
@@ -1268,11 +1239,11 @@ function TopBarRightShortcutsVertical({me, menuOpen, serverName}){
<StyledListItem className={classes.listSubHeader} onClick={handleSettingsMenu} >
<StyledListItemIcon>
<MythicStyledTooltip title={"User Settings"} tooltipStyle={{display: "inline-flex"}}>
<ManageAccountsTwoToneIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement" />
<ManageAccountsTwoToneIcon fontSize={"medium"} className="mythicElement mythic-navigation-icon" />
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Settings"} />
<KeyboardArrowDownIcon style={{color: theme.navBarTextIconColor, display: menuOpen ? "" : "none", flex: "0 0 auto", marginLeft: "auto"}} />
<KeyboardArrowDownIcon className="mythic-navigation-icon" style={{display: menuOpen ? "" : "none", flex: "0 0 auto", marginLeft: "auto"}} />
</StyledListItem>
</>
)
+461 -57
View File
@@ -43,9 +43,10 @@ import UnarchiveIcon from '@mui/icons-material/Unarchive';
import {MythicPageBody} from "../../MythicComponents/MythicPageBody";
import {MythicPageHeader, MythicPageHeaderChip} from "../../MythicComponents/MythicPageHeader";
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
import {MythicConfirmDialog} from "../../MythicComponents/MythicConfirmDialog";
import {MeContext} from "../../App";
import {snackActions} from "../../utilities/Snackbar";
import {getSkewedNow, toLocalTime} from "../../utilities/Time";
import {getSkewedNow} from "../../utilities/Time";
const CHAT_MESSAGE_LIMIT = 250;
const CHAT_REQUEST_LIMIT = 50;
@@ -63,6 +64,7 @@ fragment ChatChannelFields on chat_channel {
last_message_id
chat_container_id
chat_model
ai_metadata
updated_at
chat_container {
id
@@ -340,11 +342,123 @@ const allowedLinkSchemes = ["http:", "https:", "mailto:"];
const isGeneralChatChannel = (channel) => channel?.channel_type === "standard" && channel?.slug === "general";
const formatTimestamp = (timestamp, viewUTCTime) => {
if(!timestamp){ return ""; }
return toLocalTime(timestamp, viewUTCTime);
const CHAT_SEARCH_SNIPPET_LENGTH = 260;
const CHAT_SEARCH_SNIPPET_CONTEXT = 90;
const timestampHasTimeZone = (timestampText) => /(?:[zZ]|[+-]\d{2}:?\d{2})$/.test(timestampText);
const utcWeekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const utcMonths = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const padTimePart = (value) => String(value).padStart(2, "0");
const parseChatTimestamp = (timestamp) => {
if(!timestamp){ return null; }
if(timestamp instanceof Date){
return Number.isNaN(timestamp.getTime()) ? null : timestamp;
}
if(typeof timestamp === "number"){
const parsedNumber = new Date(timestamp);
return Number.isNaN(parsedNumber.getTime()) ? null : parsedNumber;
}
const timestampText = String(timestamp).trim();
if(timestampText === ""){ return null; }
const normalizedTimestamp = timestampHasTimeZone(timestampText) ? timestampText : `${timestampText}Z`;
const parsedTimestamp = new Date(normalizedTimestamp);
return Number.isNaN(parsedTimestamp.getTime()) ? null : parsedTimestamp;
};
const formatUTCTimestamp = (date) => (
`${utcWeekdays[date.getUTCDay()]} ${utcMonths[date.getUTCMonth()]} ${padTimePart(date.getUTCDate())} ` +
`${date.getUTCFullYear()} ${padTimePart(date.getUTCHours())}:${padTimePart(date.getUTCMinutes())}:${padTimePart(date.getUTCSeconds())} UTC`
);
const formatTimestamp = (timestamp, viewUTCTime) => {
const parsedTimestamp = parseChatTimestamp(timestamp);
if(!parsedTimestamp){ return ""; }
if(viewUTCTime){
return formatUTCTimestamp(parsedTimestamp);
}
return `${parsedTimestamp.toDateString()} ${parsedTimestamp.toLocaleString(["en-us"], {hour12: true, hour: "2-digit", minute: "2-digit"})}`;
};
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const getSearchTerms = (query) => {
const trimmedQuery = (query || "").trim();
if(trimmedQuery === ""){
return [];
}
const uniqueTerms = new Set();
return [trimmedQuery, ...trimmedQuery.split(/\s+/)]
.map((term) => term.replace(/^[^\w]+|[^\w]+$/g, ""))
.filter((term) => term.length > 1 || trimmedQuery.length === 1)
.filter((term) => {
const normalizedTerm = term.toLocaleLowerCase();
if(uniqueTerms.has(normalizedTerm)){
return false;
}
uniqueTerms.add(normalizedTerm);
return true;
})
.sort((a, b) => b.length - a.length);
};
const buildSearchSnippetParts = (message, query) => {
const messageText = String(message || "");
if(messageText === ""){
return [];
}
const searchTerms = getSearchTerms(query);
const lowerMessageText = messageText.toLocaleLowerCase();
const firstMatch = searchTerms.reduce((bestMatch, term) => {
const index = lowerMessageText.indexOf(term.toLocaleLowerCase());
if(index === -1){
return bestMatch;
}
if(!bestMatch || index < bestMatch.index){
return {index, term};
}
return bestMatch;
}, null);
let snippetStart = 0;
let snippetEnd = Math.min(messageText.length, CHAT_SEARCH_SNIPPET_LENGTH);
if(firstMatch){
snippetStart = Math.max(0, firstMatch.index - CHAT_SEARCH_SNIPPET_CONTEXT);
snippetEnd = Math.min(messageText.length, snippetStart + CHAT_SEARCH_SNIPPET_LENGTH);
snippetStart = Math.max(0, snippetEnd - CHAT_SEARCH_SNIPPET_LENGTH);
}
const hasLeadingText = snippetStart > 0;
const hasTrailingText = snippetEnd < messageText.length;
const snippetText = `${hasLeadingText ? "..." : ""}${messageText.slice(snippetStart, snippetEnd).replace(/\s+/g, " ").trim()}${hasTrailingText ? "..." : ""}`;
if(searchTerms.length === 0){
return [{text: snippetText, highlight: false}];
}
const highlightExpression = new RegExp(searchTerms.map(escapeRegExp).join("|"), "gi");
const parts = [];
let lastIndex = 0;
snippetText.replace(highlightExpression, (match, offset) => {
if(offset > lastIndex){
parts.push({text: snippetText.slice(lastIndex, offset), highlight: false});
}
parts.push({text: match, highlight: true});
lastIndex = offset + match.length;
return match;
});
if(lastIndex < snippetText.length){
parts.push({text: snippetText.slice(lastIndex), highlight: false});
}
return parts;
};
const renderSearchSnippet = (message, query) => (
buildSearchSnippetParts(message, query).map((part, index) => (
part.highlight ? (
<mark className="mythic-chat-search-highlight" key={`highlight-${index}`}>{part.text}</mark>
) : (
<React.Fragment key={`text-${index}`}>{part.text}</React.Fragment>
)
))
);
const timestampValue = (timestamp) => {
if(!timestamp){ return 0; }
const value = new Date(timestamp).getTime();
@@ -524,6 +638,173 @@ const parseChatContainerModels = (container) => {
}).filter((model) => model.name);
};
const parseJSONLikeObject = (value) => {
if(!value){
return {};
}
if(typeof value === "object" && !Array.isArray(value)){
return value;
}
if(typeof value === "string"){
try{
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
}catch(error){
return {};
}
}
return {};
};
const getChannelAIMetadata = (channel) => parseJSONLikeObject(channel?.ai_metadata);
const getChannelAIConfig = (channel) => {
const metadata = getChannelAIMetadata(channel);
return parseJSONLikeObject(metadata.config || metadata.configuration);
};
const modelForChannel = (channel, chatContainers) => {
if(!channel || channel.channel_type !== "ai"){
return null;
}
const container = chatContainers.find((item) => item.id === channel.chat_container_id) || channel.chat_container;
return parseChatContainerModels(container).find((model) => model.name === channel.chat_model) || null;
};
const getModelConfigOptions = (model) => {
const metadata = model?.metadata || {};
const rawOptions = metadata.configuration_options ||
metadata.config_options ||
metadata?.configuration?.options ||
metadata?.config?.options ||
[];
if(!Array.isArray(rawOptions)){
return [];
}
return rawOptions.map((option) => {
const name = option.name || option.key || option.Name || option.Key || "";
const choices = Array.isArray(option.choices || option.Choices) ? (option.choices || option.Choices).map((choice) => {
if(choice && typeof choice === "object"){
const value = choice.value ?? choice.Value ?? choice.name ?? choice.Name ?? choice.label ?? choice.Label ?? "";
return {
value,
label: `${choice.label ?? choice.Label ?? value}`,
description: choice.description || choice.Description || "",
};
}
return {value: choice, label: `${choice}`, description: ""};
}) : [];
const type = `${option.type || option.Type || (choices.length > 0 ? "choice" : "string")}`.toLowerCase();
return {
name: `${name}`,
displayName: option.display_name || option.displayName || option.DisplayName || option.label || option.Label || `${name}`,
description: option.description || option.Description || "",
type: choices.length > 0 ? "choice" : type,
required: Boolean(option.required || option.Required),
defaultValue: option.default_value ?? option.defaultValue ?? option.DefaultValue ?? option.default ?? option.Default ?? "",
choices,
};
}).filter((option) => option.name);
};
const configValueForField = (value) => {
if(value === undefined || value === null){
return "";
}
return `${value}`;
};
const buildDefaultConfigValues = (options, existing = {}) => {
return options.reduce((prev, option) => {
const existingValue = existing[option.name];
prev[option.name] = configValueForField(existingValue !== undefined ? existingValue : option.defaultValue);
return prev;
}, {});
};
const normalizeConfigForSubmit = (values, options) => {
return options.reduce((prev, option) => {
const rawValue = values[option.name];
if(rawValue === undefined || rawValue === null || `${rawValue}`.trim() === ""){
return prev;
}
if(option.type === "number"){
const numberValue = Number(rawValue);
if(!Number.isNaN(numberValue)){
prev[option.name] = numberValue;
}
return prev;
}
prev[option.name] = rawValue;
return prev;
}, {});
};
const configHasMissingRequiredValues = (values, options) => options.some((option) => (
option.required && `${values[option.name] ?? ""}`.trim() === ""
));
const applyConfigToMetadata = (metadata, config) => ({
...parseJSONLikeObject(metadata),
config,
});
const ChatConfigurationFields = ({options, values, setValues}) => {
if(options.length === 0){
return null;
}
return (
<Box sx={{display: "flex", flexDirection: "column", gap: 1.25}}>
<Typography variant="subtitle2">AI Configuration</Typography>
{options.map((option) => {
if(option.type === "choice"){
return (
<FormControl size="small" fullWidth key={option.name}>
<InputLabel>{option.displayName}</InputLabel>
<Select
label={option.displayName}
value={configValueForField(values[option.name])}
onChange={(e) => setValues((prev) => ({...prev, [option.name]: e.target.value}))}
>
{option.choices.map((choice) => (
<MenuItem value={configValueForField(choice.value)} key={`${option.name}-${choice.value}`}>
<Box sx={{display: "flex", flexDirection: "column", py: 0.25}}>
<Typography variant="body2">{choice.label}</Typography>
{choice.description &&
<Typography variant="caption" color="text.secondary" sx={{whiteSpace: "normal"}}>
{choice.description}
</Typography>
}
</Box>
</MenuItem>
))}
</Select>
{option.description &&
<Typography variant="caption" color="text.secondary" sx={{mt: 0.5}}>
{option.description}
</Typography>
}
</FormControl>
);
}
return (
<TextField
key={option.name}
fullWidth
size="small"
type={option.type === "number" ? "number" : "text"}
label={option.displayName}
required={option.required}
value={configValueForField(values[option.name])}
helperText={option.description}
onChange={(e) => setValues((prev) => ({...prev, [option.name]: e.target.value}))}
/>
);
})}
</Box>
);
};
const ChatEmptyState = ({icon, title, detail}) => (
<Box className="mythic-chat-empty-state">
{icon}
@@ -676,6 +957,7 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
const [channelType, setChannelType] = React.useState("standard");
const [containerID, setContainerID] = React.useState("");
const [model, setModel] = React.useState("");
const [configValues, setConfigValues] = React.useState({});
const [locked, setLocked] = React.useState(true);
React.useEffect(() => {
if(open){
@@ -684,6 +966,7 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
setChannelType("standard");
setContainerID("");
setModel("");
setConfigValues({});
setLocked(true);
}
}, [open]);
@@ -693,6 +976,10 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
const selectedContainerModels = React.useMemo(() => (
parseChatContainerModels(selectedContainer)
), [selectedContainer]);
const selectedModel = React.useMemo(() => (
selectedContainerModels.find((containerModel) => containerModel.name === model) || null
), [selectedContainerModels, model]);
const configOptions = React.useMemo(() => getModelConfigOptions(selectedModel), [selectedModel]);
React.useEffect(() => {
if(channelType !== "ai" || !containerID){
return;
@@ -705,12 +992,20 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
setModel(selectedContainerModels[0].name);
}
}, [channelType, containerID, model, selectedContainerModels]);
React.useEffect(() => {
if(channelType === "ai" && model){
setConfigValues(buildDefaultConfigValues(configOptions));
} else {
setConfigValues({});
}
}, [channelType, containerID, model, configOptions]);
const changeChannelType = (event) => {
const nextType = event.target.value;
setChannelType(nextType);
if(nextType !== "ai"){
setContainerID("");
setModel("");
setConfigValues({});
}
};
const changeContainer = (event) => {
@@ -719,10 +1014,12 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
const nextModels = parseChatContainerModels(nextContainer);
setContainerID(nextContainerID);
setModel(nextModels.length === 1 ? nextModels[0].name : "");
setConfigValues({});
};
const createDisabled = name.trim() === "" ||
(channelType === "ai" && (!containerID || selectedContainerModels.length === 0 || model === ""));
(channelType === "ai" && (!containerID || selectedContainerModels.length === 0 || model === "" || configHasMissingRequiredValues(configValues, configOptions)));
const submit = () => {
const aiConfig = channelType === "ai" ? normalizeConfigForSubmit(configValues, configOptions) : {};
onCreate({
name,
description,
@@ -730,7 +1027,7 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
chat_container_id: channelType === "ai" ? Number(containerID) : null,
chat_model: channelType === "ai" ? model : "",
locked: channelType === "ai" ? locked : false,
ai_metadata: {},
ai_metadata: channelType === "ai" ? applyConfigToMetadata({}, aiConfig) : {},
});
};
return (
@@ -796,6 +1093,9 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
)}
</>
}
{configOptions.length > 0 &&
<ChatConfigurationFields options={configOptions} values={configValues} setValues={setConfigValues} />
}
<Box sx={{border: `1px solid ${alpha(theme.palette.info.main, 0.22)}`, borderRadius: 1, p: 1.25, backgroundColor: alpha(theme.palette.info.main, theme.palette.mode === "dark" ? 0.12 : 0.07)}}>
<FormControlLabel
control={<Switch checked={locked} onChange={(e) => setLocked(e.target.checked)} />}
@@ -818,59 +1118,95 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
);
};
const ChatSearchDialog = ({open, onClose, onSearch, searchText, setSearchText, results, onSelectResult, viewUTCTime}) => (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>Search Chat</DialogTitle>
<DialogContent className="mythic-chat-dialog-content" sx={{display: "flex", flexDirection: "column", gap: 1.75, pt: "20px !important", px: 3}}>
<Box sx={{display: "flex", gap: 1, alignItems: "flex-start"}}>
<TextField
fullWidth
size="small"
label="Search"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => {
if(e.key === "Enter"){
onSearch();
}
}}
/>
<Button variant="contained" startIcon={<SearchIcon />} onClick={onSearch}>Search</Button>
</Box>
<Box className="mythic-chat-search-results">
{(results || []).map((result) => (
<button
type="button"
className="mythic-chat-search-result"
key={result.id}
onClick={() => onSelectResult(result)}
>
<span className="mythic-chat-search-channel">
{result.channel_type === "ai" ? <SmartToyTwoToneIcon fontSize="small" /> : <ForumTwoToneIcon fontSize="small" />}
{result.channel_type === "ai" ? result.channel_name : `#${result.channel_name}`}
</span>
<span className="mythic-chat-search-message">{result.message}</span>
<span className="mythic-chat-search-meta">{result.sender_display_name} · {formatTimestamp(result.created_at, viewUTCTime)}</span>
</button>
))}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
const ChatSearchDialog = ({open, onClose, onSearch, searchText, setSearchText, searchQuery, results, loading, hasSearched, onSelectResult, viewUTCTime}) => {
const trimmedSearchText = searchText.trim();
const searchResults = results || [];
const highlightQuery = searchQuery || trimmedSearchText;
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth PaperProps={{className: "mythic-chat-search-dialog"}}>
<DialogTitle>Search Chat</DialogTitle>
<DialogContent className="mythic-chat-dialog-content mythic-chat-search-content" sx={{display: "flex", flexDirection: "column", gap: 1.75, pt: "20px !important", px: 3}}>
<Box className="mythic-chat-search-form">
<TextField
autoFocus
fullWidth
size="small"
label="Search"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => {
if(e.key === "Enter"){
onSearch();
}
}}
/>
<Button variant="contained" startIcon={<SearchIcon />} disabled={trimmedSearchText === "" || loading} onClick={onSearch}>
{loading ? "Searching" : "Search"}
</Button>
</Box>
<Box className="mythic-chat-search-results">
{loading && <Box className="mythic-chat-search-empty">Searching...</Box>}
{!loading && hasSearched && searchResults.length === 0 && <Box className="mythic-chat-search-empty">No matches</Box>}
{!loading && searchResults.map((result) => (
<button
type="button"
className="mythic-chat-search-result"
key={result.id}
onClick={() => onSelectResult(result)}
>
<span className="mythic-chat-search-result-header">
<span className="mythic-chat-search-channel">
{result.channel_type === "ai" ? <SmartToyTwoToneIcon fontSize="small" /> : <ForumTwoToneIcon fontSize="small" />}
<span>{result.channel_type === "ai" ? result.channel_name : `#${result.channel_name}`}</span>
</span>
<span className="mythic-chat-search-meta">{result.sender_display_name} · {formatTimestamp(result.created_at, viewUTCTime)}</span>
</span>
<span className="mythic-chat-search-message">{renderSearchSnippet(result.message, highlightQuery)}</span>
</button>
))}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
};
const ChatEditChannelDialog = ({open, channel, onClose, onSave}) => {
const ChatEditChannelDialog = ({open, channel, onClose, onSave, chatContainers = []}) => {
const [name, setName] = React.useState("");
const [description, setDescription] = React.useState("");
const [chatModel, setChatModel] = React.useState("");
const [configValues, setConfigValues] = React.useState({});
const isGeneralChannel = isGeneralChatChannel(channel);
const isAIChannel = channel?.channel_type === "ai";
const containerModels = React.useMemo(() => {
if(!isAIChannel){
return [];
}
const container = chatContainers.find((item) => item.id === channel.chat_container_id) || channel.chat_container;
return parseChatContainerModels(container);
}, [channel, chatContainers, isAIChannel]);
const selectedModel = React.useMemo(() => (
containerModels.find((containerModel) => containerModel.name === chatModel) || (chatModel ? null : modelForChannel(channel, chatContainers))
), [channel, chatContainers, chatModel, containerModels]);
const configOptions = React.useMemo(() => getModelConfigOptions(selectedModel), [selectedModel]);
React.useEffect(() => {
if(open && channel){
setName(channel.name || "");
setDescription(channel.description || "");
setChatModel(channel.chat_model || "");
const initialModel = modelForChannel(channel, chatContainers);
setConfigValues(buildDefaultConfigValues(getModelConfigOptions(initialModel), getChannelAIConfig(channel)));
}
}, [open, channel]);
}, [open, channel, chatContainers]);
const changeModel = (event) => {
const nextModelName = event.target.value;
const nextModel = containerModels.find((containerModel) => containerModel.name === nextModelName) || null;
const nextOptions = getModelConfigOptions(nextModel);
setChatModel(nextModelName);
setConfigValues((prev) => buildDefaultConfigValues(nextOptions, prev));
};
const submit = () => {
if(!channel){
return;
@@ -882,11 +1218,20 @@ const ChatEditChannelDialog = ({open, channel, onClose, onSave}) => {
if(!isGeneralChatChannel(channel)){
update.name = name.trim();
}
if(isAIChannel){
update.chat_model = chatModel;
update.ai_metadata = applyConfigToMetadata(
getChannelAIMetadata(channel),
normalizeConfigForSubmit(configValues, configOptions),
);
}
onSave(update);
};
const saveDisabled = (!isGeneralChannel && name.trim() === "") ||
(isAIChannel && configHasMissingRequiredValues(configValues, configOptions));
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Edit Channel</DialogTitle>
<DialogTitle>{isAIChannel ? "Edit AI Chat" : "Edit Channel"}</DialogTitle>
<DialogContent className="mythic-chat-dialog-content" sx={{display: "flex", flexDirection: "column", gap: 1.75, pt: "20px !important", px: 3}}>
<TextField
autoFocus={!isGeneralChannel}
@@ -907,10 +1252,40 @@ const ChatEditChannelDialog = ({open, channel, onClose, onSave}) => {
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
{isAIChannel &&
<>
<FormControl size="small" fullWidth>
<InputLabel>Model</InputLabel>
<Select
label="Model"
value={chatModel}
onChange={changeModel}
renderValue={(selected) => selected}
>
{containerModels.length === 0 &&
<MenuItem value={chatModel} disabled>{chatModel || "No models reported"}</MenuItem>
}
{containerModels.map((containerModel) => (
<MenuItem value={containerModel.name} key={`${channel?.id}-${containerModel.name}`}>
<Box sx={{display: "flex", flexDirection: "column", py: 0.5, minWidth: 0}}>
<Typography variant="body2">{containerModel.name}</Typography>
{containerModel.description &&
<Typography variant="caption" color="text.secondary" sx={{whiteSpace: "normal"}}>
{containerModel.description}
</Typography>
}
</Box>
</MenuItem>
))}
</Select>
</FormControl>
<ChatConfigurationFields options={configOptions} values={configValues} setValues={setConfigValues} />
</>
}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={submit} variant="contained" disabled={!isGeneralChannel && name.trim() === ""}>
<Button onClick={submit} variant="contained" disabled={saveDisabled}>
Save
</Button>
</DialogActions>
@@ -988,8 +1363,10 @@ export function Chat({me}) {
const [createOpen, setCreateOpen] = React.useState(false);
const [editChannelOpen, setEditChannelOpen] = React.useState(false);
const [systemMessageOpen, setSystemMessageOpen] = React.useState(false);
const [archiveTarget, setArchiveTarget] = React.useState(null);
const [searchOpen, setSearchOpen] = React.useState(false);
const [searchText, setSearchText] = React.useState("");
const [searchQuery, setSearchQuery] = React.useState("");
const [editingID, setEditingID] = React.useState(null);
const [editText, setEditText] = React.useState("");
const messagesEndRef = React.useRef(null);
@@ -1225,7 +1602,7 @@ export function Chat({me}) {
onError: (error) => snackActions.error(error.message),
});
const [markRead] = useMutation(MARK_READ);
const [runSearch, {data: searchData}] = useLazyQuery(CHAT_SEARCH, {
const [runSearch, {data: searchData, loading: searchLoading}] = useLazyQuery(CHAT_SEARCH, {
fetchPolicy: "no-cache",
onCompleted: (data) => {
if(data.chatSearch.status !== "success"){
@@ -1305,8 +1682,18 @@ export function Chat({me}) {
const composerDisabled = disabledReason !== "";
const onCreateChannel = (variables) => createChannel({variables});
const toggleArchive = () => {
if(selectedChannel && !isGeneralChatChannel(selectedChannel)){
updateChannel({variables: {channel_id: selectedChannel.id, archived: !selectedChannel.archived}});
if(!selectedChannel || isGeneralChatChannel(selectedChannel)){
return;
}
if(selectedChannel.archived){
updateChannel({variables: {channel_id: selectedChannel.id, archived: false}});
} else {
setArchiveTarget(selectedChannel);
}
};
const confirmArchiveChannel = () => {
if(archiveTarget){
updateChannel({variables: {channel_id: archiveTarget.id, archived: true}});
}
};
const toggleLock = () => {
@@ -1331,8 +1718,10 @@ export function Chat({me}) {
}
};
const runChatSearch = () => {
if(searchText.trim()){
runSearch({variables: {query: searchText.trim(), limit: 50}});
const trimmedSearchText = searchText.trim();
if(trimmedSearchText){
setSearchQuery(trimmedSearchText);
runSearch({variables: {query: trimmedSearchText, limit: 50}});
}
};
const selectSearchResult = (result) => {
@@ -1559,6 +1948,7 @@ export function Chat({me}) {
<ChatEditChannelDialog
open={editChannelOpen}
channel={selectedChannel}
chatContainers={chatContainers}
onClose={() => setEditChannelOpen(false)}
onSave={saveChannelDetails}
/>
@@ -1569,13 +1959,27 @@ export function Chat({me}) {
onClose={() => setSystemMessageOpen(false)}
onSend={submitSystemMessage}
/>
{archiveTarget &&
<MythicConfirmDialog
open={Boolean(archiveTarget)}
title="Archive Channel?"
dialogText={`Archive ${channelDisplayName(archiveTarget)}? This hides the channel from the default chat list until archived channels are shown.`}
acceptText="Archive"
acceptColor="warning"
onClose={() => setArchiveTarget(null)}
onSubmit={confirmArchiveChannel}
/>
}
<ChatSearchDialog
open={searchOpen}
onClose={() => setSearchOpen(false)}
onSearch={runChatSearch}
searchText={searchText}
setSearchText={setSearchText}
searchQuery={searchQuery}
results={searchData?.chatSearch?.results || []}
loading={searchLoading}
hasSearched={searchQuery !== ""}
onSelectResult={selectSearchResult}
viewUTCTime={currentMe?.user?.view_utc_time}
/>
File diff suppressed because it is too large Load Diff
@@ -25,6 +25,7 @@ type ChatContainerRequestMessage struct {
ResponseMessageID int `json:"response_message_id" mapstructure:"response_message_id"`
Model string `json:"model" mapstructure:"model"`
Prompt string `json:"prompt" mapstructure:"prompt"`
Config map[string]interface{} `json:"config" mapstructure:"config"`
Context []ChatContainerContextMessage `json:"context" mapstructure:"context"`
Secrets map[string]interface{} `json:"secrets" mapstructure:"secrets"`
}
@@ -776,6 +776,7 @@ func createAIChatMessage(c *gin.Context, operatorOperation *databaseStructs.Oper
chatRespondError(c, err.Error())
return
}
chatConfig := getChatChannelConfig(channel)
contextIDs := make([]int, len(contextMessages))
for i := range contextMessages {
contextIDs[i] = contextMessages[i].ID
@@ -786,6 +787,7 @@ func createAIChatMessage(c *gin.Context, operatorOperation *databaseStructs.Oper
"response_message_id": responseMessageID,
"retry_of_id": retryOf,
"context_message_limit": chatContextMessageLimit,
"config": chatConfig,
})
_, _ = database.DB.Exec(`UPDATE chat_request
SET context_snapshot=$3::jsonb
@@ -801,6 +803,7 @@ func createAIChatMessage(c *gin.Context, operatorOperation *databaseStructs.Oper
ResponseMessageID: responseMessageID,
Model: channel.ChatModel,
Prompt: message,
Config: chatConfig,
Context: contextMessages,
Secrets: rabbitmq.GetSecrets(operatorOperation.CurrentOperator.ID, 0),
}, authContext)
@@ -961,6 +964,16 @@ func chatJSONText(input interface{}) databaseStructs.MythicJSONText {
return rabbitmq.GetMythicJSONTextFromStruct(input)
}
func getChatChannelConfig(channel databaseStructs.ChatChannel) map[string]interface{} {
metadata := channel.AIMetadata.StructValue()
for _, key := range []string{"config", "configuration"} {
if config, ok := metadata[key].(map[string]interface{}); ok {
return config
}
}
return map[string]interface{}{}
}
func getChatContainer(containerID int) (databaseStructs.ConsumingContainer, error) {
container := databaseStructs.ConsumingContainer{}
err := database.DB.Get(&container, `SELECT *