This commit is contained in:
seb
2026-10-04 02:13:50 +02:00
parent 0d0c5dfdfb
commit f2fce9097b
19 changed files with 3087 additions and 9 deletions

View File

@@ -0,0 +1,220 @@
import React, { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Fab from '@mui/material/Fab';
import Badge from '@mui/material/Badge';
import Tooltip from '@mui/material/Tooltip';
import CircularProgress from '@mui/material/CircularProgress';
import ChatRoundedIcon from '@mui/icons-material/ChatRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded';
import chatAutomat, { selectPendingAskUser, selectHasPendingAsk } from '../../automats/chatAutomat';
export default class ChatFab extends PureComponent {
constructor(props) {
super(props);
const s = chatAutomat.state;
this.state = {
isOpen: s.isOpen,
conversationState: s.conversationState,
streamPhase: s.streamPhase,
isSending: s.isSending,
hasPendingAsk: selectHasPendingAsk(s),
pendingAsk: selectPendingAskUser(s),
sseStatus: s.sseStatus,
};
}
componentDidMount() {
this.unsub = chatAutomat.subscribe(this, (state) => ({
isOpen: state.isOpen,
conversationState: state.conversationState,
streamPhase: state.streamPhase,
isSending: state.isSending,
hasPendingAsk: selectHasPendingAsk(state),
pendingAsk: selectPendingAskUser(state),
sseStatus: state.sseStatus,
}));
}
componentWillUnmount() {
this.unsub?.();
}
handleClick = () => {
chatAutomat.actions.toggleChat();
};
render() {
const { isOpen, conversationState, streamPhase, isSending, hasPendingAsk, pendingAsk } = this.state;
const isBusy = conversationState === 'busy' || streamPhase === 'thinking' || streamPhase === 'working' || isSending;
let tooltipTitle = 'KI-Chat öffnen';
let ariaLabel = 'KI-Chat';
if (hasPendingAsk) {
tooltipTitle = pendingAsk?.question
? `Antwort erforderlich: „${pendingAsk.question}“`
: 'Antwort erforderlich: Das Modell wartet auf Ihre Eingabe';
ariaLabel = 'KI-Chat: Antwort erforderlich';
} else if (isBusy) {
tooltipTitle = 'Modell antwortet gerade…';
ariaLabel = 'KI-Chat: Modell antwortet';
}
return (
<Box
sx={(theme) => ({
position: 'fixed',
bottom: { xs: 20, sm: 28 },
right: { xs: 20, sm: 28 },
zIndex: theme.zIndex.speedDial || 1050,
opacity: isOpen ? 0 : 1,
transform: isOpen ? 'scale(0)' : 'scale(1)',
pointerEvents: isOpen ? 'none' : 'auto',
transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s linear',
})}
>
<Box sx={{ position: 'relative' }}>
{/* Animated pulsing halo when awaiting user answer */}
{hasPendingAsk && !isOpen && (
<Box
sx={{
position: 'absolute',
top: -8,
left: -8,
right: -8,
bottom: -8,
borderRadius: '50%',
bgcolor: '#f59e0b',
opacity: 0.5,
animation: 'fabAskPulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'@keyframes fabAskPulse': {
'0%': { transform: 'scale(0.95)', opacity: 0.8 },
'50%': { transform: 'scale(1.35)', opacity: 0.12 },
'100%': { transform: 'scale(0.95)', opacity: 0.8 },
},
}}
/>
)}
{/* Animated pulsating halo when model is busy thinking/working */}
{!hasPendingAsk && isBusy && !isOpen && (
<Box
sx={{
position: 'absolute',
top: -6,
left: -6,
right: -6,
bottom: -6,
borderRadius: '50%',
bgcolor: 'warning.main',
opacity: 0.4,
animation: 'fabPulse 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'@keyframes fabPulse': {
'0%': { transform: 'scale(0.95)', opacity: 0.7 },
'50%': { transform: 'scale(1.25)', opacity: 0.15 },
'100%': { transform: 'scale(0.95)', opacity: 0.7 },
},
}}
/>
)}
{/* Circular spinner ring when model is busy */}
{!hasPendingAsk && isBusy && !isOpen && (
<CircularProgress
size={66}
thickness={2.5}
sx={{
position: 'absolute',
top: -6,
left: -6,
color: '#f59e0b',
pointerEvents: 'none',
}}
/>
)}
<Tooltip title={tooltipTitle} placement="left" arrow disableHoverListener={isOpen}>
<Badge
badgeContent={hasPendingAsk ? '?' : undefined}
color={hasPendingAsk ? 'error' : isBusy ? 'warning' : 'default'}
variant={hasPendingAsk ? 'standard' : 'dot'}
invisible={!hasPendingAsk && !isBusy}
overlap="circular"
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{
'& .MuiBadge-badge': hasPendingAsk
? {
bgcolor: '#ef4444',
color: '#ffffff',
fontWeight: 900,
fontSize: '0.85rem',
minWidth: 22,
height: 22,
borderRadius: '50%',
boxShadow: '0 2px 10px rgba(239, 68, 68, 0.6)',
animation: 'badgePop 1.5s ease infinite',
'@keyframes badgePop': {
'0%, 100%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.2)' },
},
}
: undefined,
}}
>
<Fab
color="primary"
aria-label={ariaLabel}
onClick={this.handleClick}
sx={(theme) => ({
width: 54,
height: 54,
boxShadow: hasPendingAsk
? '0 8px 24px rgba(245, 158, 11, 0.45)'
: isBusy
? '0 8px 24px rgba(245, 158, 11, 0.35)'
: '0 8px 24px rgba(59, 91, 219, 0.35)',
transition: 'transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.2s',
'&:hover': {
transform: 'scale(1.06)',
},
'&:active': {
transform: 'scale(0.96)',
},
background: hasPendingAsk
? 'linear-gradient(135deg, #f59e0b 0%, #ea580c 100%)'
: isBusy
? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)'
: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
})}
>
{isOpen ? (
<CloseRoundedIcon sx={{ fontSize: 24 }} />
) : hasPendingAsk ? (
<HelpOutlineRoundedIcon
sx={{
fontSize: 26,
animation: 'fabWiggle 2s ease infinite',
'@keyframes fabWiggle': {
'0%, 100%': { transform: 'rotate(0deg)' },
'15%': { transform: 'rotate(-12deg)' },
'30%': { transform: 'rotate(12deg)' },
'45%': { transform: 'rotate(-8deg)' },
'60%': { transform: 'rotate(0deg)' },
},
}}
/>
) : isBusy ? (
<AutoAwesomeRoundedIcon sx={{ fontSize: 24 }} />
) : (
<ChatRoundedIcon sx={{ fontSize: 24 }} />
)}
</Fab>
</Badge>
</Tooltip>
</Box>
</Box>
);
}
}