Files
dashfront/src/components/chat/ChatDrawer.jsx
2026-10-05 01:01:03 +02:00

643 lines
20 KiB
JavaScript

import React, { PureComponent } from 'react';
import Drawer from '@mui/material/Drawer';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import TextInput from '../TextInput';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import CircularProgress from '@mui/material/CircularProgress';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import SendRoundedIcon from '@mui/icons-material/SendRounded';
import AddRoundedIcon from '@mui/icons-material/AddRounded';
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded';
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
import userSettingsAutomat from '../../automats/userSettingsAutomat';
import ChatFlowList from './ChatFlowList';
import AskUserPrompt from './AskUserPrompt';
import {
BLUE_GLOW,
SHADOW_DRAWER,
} from '../../colors';
const DEFAULT_DRAWER_WIDTH = 460;
const MIN_DRAWER_WIDTH = 320;
export default class ChatDrawer extends PureComponent {
constructor(props) {
super(props);
const s = chatAutomat.state;
const settings = userSettingsAutomat.state;
this.state = {
isOpen: s.isOpen,
conversations: s.conversations,
selectedConversationId: s.selectedConversationId,
draftConversation: s.draftConversation,
conversationState: s.conversationState,
provider: s.provider,
model: s.model,
messages: s.messages,
loadingMessages: s.loadingMessages,
streamingText: s.streamingText,
isSending: s.isSending,
loadingConversations: s.loadingConversations,
sseStatus: s.sseStatus,
sendError: s.sendError,
inputText: '',
drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH,
isResizing: false,
};
this.inputRef = React.createRef();
this.focusTimeout = null;
}
focusInput = (delay = 50) => {
if (this.focusTimeout) clearTimeout(this.focusTimeout);
this.focusTimeout = setTimeout(() => {
this.inputRef.current?.focus?.();
}, delay);
};
componentDidMount() {
if (this.state.isOpen) {
this.focusInput(150);
}
this.unsub = chatAutomat.subscribe(this, (state) => {
const wasOpen = this.state.isOpen;
const willOpen = state.isOpen;
if (!wasOpen && willOpen) {
this.focusInput(150);
}
return {
isOpen: state.isOpen,
conversations: state.conversations,
selectedConversationId: state.selectedConversationId,
draftConversation: state.draftConversation,
conversationState: state.conversationState,
provider: state.provider,
model: state.model,
messages: state.messages,
loadingMessages: state.loadingMessages,
streamingText: state.streamingText,
isSending: state.isSending,
loadingConversations: state.loadingConversations,
sseStatus: state.sseStatus,
sendError: state.sendError,
};
});
this.unsubSettings = userSettingsAutomat.subscribe(this, (state) => {
if (!this.state.isResizing) {
return { drawerWidth: state.chatDrawerWidth || DEFAULT_DRAWER_WIDTH };
}
return null;
});
}
componentWillUnmount() {
if (this.focusTimeout) clearTimeout(this.focusTimeout);
this.unsub?.();
this.unsubSettings?.();
this.cleanupResizeListeners();
}
handleClose = () => {
chatAutomat.actions.closeChat();
};
handleResizeStart = (e) => {
if (e.button !== undefined && e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const startX = e.clientX ?? (e.touches && e.touches[0]?.clientX);
if (startX === undefined) return;
const startWidth = this.state.drawerWidth;
this.setState({ isResizing: true });
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
let currentWidth = startWidth;
const onMove = (moveEvt) => {
moveEvt.preventDefault();
const clientX = moveEvt.clientX ?? (moveEvt.touches && moveEvt.touches[0]?.clientX);
if (clientX === undefined) return;
const deltaX = startX - clientX;
const maxWidth = Math.min(window.innerWidth - 40, 1400);
const newWidth = Math.max(MIN_DRAWER_WIDTH, Math.min(maxWidth, Math.round(startWidth + deltaX)));
currentWidth = newWidth;
this.setState({ drawerWidth: newWidth });
};
const onEnd = () => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onEnd);
window.removeEventListener('pointercancel', onEnd);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onEnd);
window.removeEventListener('touchmove', onMove);
window.removeEventListener('touchend', onEnd);
this.setState({ isResizing: false });
userSettingsAutomat.actions.setChatDrawerWidth(currentWidth);
};
window.addEventListener('pointermove', onMove, { passive: false });
window.addEventListener('pointerup', onEnd);
window.addEventListener('pointercancel', onEnd);
window.addEventListener('mousemove', onMove, { passive: false });
window.addEventListener('mouseup', onEnd);
window.addEventListener('touchmove', onMove, { passive: false });
window.addEventListener('touchend', onEnd);
};
handleResizeDoubleClick = () => {
this.setState({ drawerWidth: DEFAULT_DRAWER_WIDTH });
userSettingsAutomat.actions.setChatDrawerWidth(DEFAULT_DRAWER_WIDTH);
};
cleanupResizeListeners = () => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
handleSelectConversation = (e) => {
const id = Number(e.target.value);
if (id) {
chatAutomat.actions.selectConversation(id);
this.focusInput(50);
}
};
handleNewChat = () => {
chatAutomat.actions.startDraft();
this.focusInput(50);
};
handleDeleteConversation = async (e, convId) => {
e?.stopPropagation();
e?.preventDefault();
if (!convId) return;
try {
await chatAutomat.actions.deleteConversation(convId);
} catch (err) {
console.error('[ChatDrawer] Failed to delete conversation:', err);
} finally {
this.focusInput(50);
}
};
handleSend = async (e) => {
e?.preventDefault();
const text = this.state.inputText.trim();
if (!text || this.state.conversationState === 'busy' || this.state.isSending) {
return;
}
this.setState({ inputText: '' });
try {
await chatAutomat.actions.sendMessage(text);
} catch {
// Error is tracked in chatAutomat state (sendError)
}
};
handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void this.handleSend();
}
};
handleAnswerTool = async ({ tool_call_id, answer }) => {
try {
await chatAutomat.actions.sendToolResponse({ tool_call_id, answer });
} catch (err) {
console.error('[ChatDrawer] Failed to send tool response:', err);
}
};
getPendingAskUser = () => selectPendingAskUser(this.state);
renderHeader() {
const {
conversations,
selectedConversationId,
loadingConversations,
} = this.state;
return (
<Box
sx={{
p: 2,
borderBottom: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
display: 'flex',
flexDirection: 'column',
gap: 1.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AutoAwesomeRoundedIcon sx={{ fontSize: 22, color: 'primary.main' }} />
<Typography variant="subtitle1" fontWeight={700}>
KI-Assistent
</Typography>
</Box>
<IconButton size="small" onClick={this.handleClose} aria-label="Chat schließen">
<CloseRoundedIcon fontSize="small" />
</IconButton>
</Box>
{/* Conversation Selector Bar */}
{conversations.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FormControl fullWidth size="small">
<InputLabel id="chat-conversation-label">Konversation</InputLabel>
<Select
labelId="chat-conversation-label"
value={selectedConversationId || ''}
label="Konversation"
onChange={this.handleSelectConversation}
disabled={loadingConversations || conversations.length === 0}
sx={{ borderRadius: 2 }}
renderValue={(selectedId) => {
const conv = conversations.find((c) => c.id === selectedId);
if (!conv) return '';
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, overflow: 'hidden' }}>
<Typography variant="body2" fontWeight={600} noWrap>
#{conv.id}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
{conv.model || 'Standard'}
</Typography>
</Box>
);
}}
>
{conversations.map((c) => (
<MenuItem key={c.id} value={c.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', overflow: 'hidden' }}>
<Typography variant="body2" fontWeight={600} noWrap>
#{c.id}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap sx={{ flexGrow: 1 }}>
{c.model || 'Standard'}
</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
<IconButton
size="small"
color="error"
disabled={!selectedConversationId}
onClick={(e) => this.handleDeleteConversation(e, selectedConversationId)}
aria-label="Konversation löschen"
sx={{
borderRadius: 2,
border: 1,
borderColor: 'divider',
p: 0.9,
flexShrink: 0,
}}
>
<DeleteOutlineRoundedIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="primary"
disabled={!selectedConversationId}
onClick={this.handleNewChat}
aria-label="Neuer Chat"
sx={{
borderRadius: 2,
border: 1,
borderColor: 'divider',
p: 0.9,
flexShrink: 0,
}}
>
<AddRoundedIcon fontSize="small" />
</IconButton>
</Box>
)}
</Box>
);
}
renderBody() {
const {
conversations,
selectedConversationId,
draftConversation,
conversationState,
messages,
streamingText,
loadingConversations,
model,
} = this.state;
if (loadingConversations && conversations.length === 0) {
return (
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', p: 3, gap: 2 }}>
<CircularProgress size={32} />
<Typography variant="body2" color="text.secondary">
Lade Konversationen…
</Typography>
</Box>
);
}
if (conversations.length === 0 && !draftConversation) {
return (
<Box
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 4,
textAlign: 'center',
gap: 2,
}}
>
<AutoAwesomeRoundedIcon sx={{ fontSize: 44, color: 'primary.main', opacity: 0.85 }} />
<Typography variant="h6" fontWeight={700}>
Willkommen im Chat
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 280 }}>
Starte eine Unterhaltung mit dem KI-Modell, um Fragen zu stellen oder Daten abzufragen.
</Typography>
<Button
variant="contained"
startIcon={<AddRoundedIcon />}
onClick={this.handleNewChat}
sx={{ borderRadius: 2, mt: 1 }}
>
Neuen Chat starten
</Button>
</Box>
);
}
const pendingAsk = this.getPendingAskUser();
return (
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
{messages.length === 0 ? (
<Box sx={{ flexGrow: 1 }} />
) : (
<ChatFlowList
messages={
streamingText
? [
...messages,
{
id: 'streaming',
conversation_id: selectedConversationId,
role: 'assistant',
message: streamingText,
streaming: true,
},
]
: messages
}
conversationState={conversationState}
model={model}
/>
)}
{pendingAsk && (
<AskUserPrompt
askData={pendingAsk}
onAnswer={this.handleAnswerTool}
disabled={this.state.isSending}
/>
)}
</Box>
);
}
renderFooter() {
const {
conversationState,
isSending,
selectedConversationId,
draftConversation,
inputText,
sendError,
} = this.state;
const pendingAsk = this.getPendingAskUser();
const isBusy = conversationState === 'busy';
const isWorking = isBusy || isSending;
const canChat = (Boolean(selectedConversationId) || draftConversation) && !pendingAsk;
const canSend = !isWorking && canChat && Boolean(inputText.trim());
return (
<Box
component="form"
onSubmit={this.handleSend}
sx={{
p: 2,
borderTop: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
{sendError && (
<Box
sx={{
p: 1,
borderRadius: 1.5,
bgcolor: 'error.light',
color: 'error.contrastText',
fontSize: '0.75rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography variant="caption">{sendError}</Typography>
<IconButton
size="small"
onClick={() => chatAutomat.setState({ sendError: null })}
sx={{ color: 'inherit', p: 0.2 }}
>
<CloseRoundedIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 1 }}>
<TextInput
inputRef={this.inputRef}
multiline
maxRows={4}
fullWidth
value={inputText}
onChange={(e) => this.setState({ inputText: e.target.value })}
onKeyDown={this.handleKeyDown}
disabled={!canChat}
placeholder={
pendingAsk
? 'Bitte Frage oben beantworten…'
: 'Nachricht eingeben…'
}
/>
<IconButton
type="submit"
color="primary"
disabled={!canSend}
title={
isWorking
? 'Modell antwortet gerade…'
: 'Senden (Enter)'
}
sx={{
bgcolor: isWorking ? 'action.hover' : 'primary.main',
color: isWorking ? 'warning.main' : 'primary.contrastText',
width: 40,
height: 40,
borderRadius: 2.5,
'&:hover': {
bgcolor: 'primary.dark',
},
'&.Mui-disabled': {
bgcolor: isWorking ? 'action.hover' : 'primary.main',
color: isWorking ? 'warning.main' : 'primary.contrastText',
cursor: 'default',
},
}}
>
{isWorking ? (
<CircularProgress size={18} color="inherit" thickness={4.5} />
) : (
<SendRoundedIcon sx={{ fontSize: 20 }} />
)}
</IconButton>
</Box>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.68rem', textAlign: 'center' }}>
Enter zum Senden • Shift+Enter für Zeilenumbruch
</Typography>
</Box>
);
}
render() {
const { isOpen, drawerWidth, isResizing } = this.state;
return (
<>
<Drawer
anchor="right"
open={isOpen}
onClose={this.handleClose}
sx={(theme) => ({
zIndex: theme.zIndex.drawer + 2,
'& .MuiDrawer-paper': {
width: { xs: '100vw', sm: `${drawerWidth}px` },
maxWidth: '100vw',
display: 'flex',
flexDirection: 'column',
boxShadow: SHADOW_DRAWER,
position: 'fixed',
overflow: 'visible',
transition: isResizing ? 'none !important' : undefined,
},
})}
slotProps={{
transition: {
onEntered: () => this.focusInput(20),
},
paper: {
sx: {
width: { xs: '100vw', sm: `${drawerWidth}px` },
maxWidth: '100vw',
display: 'flex',
flexDirection: 'column',
boxShadow: SHADOW_DRAWER,
position: 'fixed',
overflow: 'visible',
transition: isResizing ? 'none !important' : undefined,
},
},
}}
>
{/* Draggable Resizer Handle */}
<Box
onPointerDown={this.handleResizeStart}
onMouseDown={this.handleResizeStart}
onTouchStart={this.handleResizeStart}
onDoubleClick={this.handleResizeDoubleClick}
title="Ziehen zum Anpassen der Breite • Doppelklick zum Zurücksetzen"
sx={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: 16,
cursor: 'col-resize',
zIndex: 9999,
display: { xs: 'none', sm: 'flex' },
alignItems: 'center',
justifyContent: 'center',
touchAction: 'none',
userSelect: 'none',
'&:hover .drawer-resizer-line, &:active .drawer-resizer-line': {
bgcolor: 'primary.main',
height: 56,
width: 4,
opacity: 1,
boxShadow: `0 0 10px ${BLUE_GLOW}`,
},
}}
>
<Box
className="drawer-resizer-line"
sx={{
width: isResizing ? 4 : 3,
height: isResizing ? 56 : 36,
borderRadius: 2,
bgcolor: isResizing ? 'primary.main' : 'divider',
opacity: isResizing ? 1 : 0.6,
transition: 'background-color 0.15s, height 0.15s, width 0.15s, opacity 0.15s, box-shadow 0.15s',
boxShadow: isResizing ? `0 0 10px ${BLUE_GLOW}` : 'none',
}}
/>
</Box>
{this.renderHeader()}
{this.renderBody()}
{this.renderFooter()}
</Drawer>
</>
);
}
}