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 Tooltip from '@mui/material/Tooltip'; import Badge from '@mui/material/Badge'; import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded'; import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat'; import userSettingsAutomat from '../../automats/userSettingsAutomat'; import ChatFlowList from './ChatFlowList'; import AskUserPrompt from './AskUserPrompt'; import { ACCEPT_STRING, readFileAttachment } from './chatAttachments'; import { ChatStagedAttachmentsTray } from './ChatAttachmentItems'; import ChatAttachmentPreviewModal from './ChatAttachmentPreviewModal'; 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, activeTurns: s.activeTurns || [], inputText: '', stagedAttachments: [], isProcessingFiles: false, isDraggingFiles: false, attachmentError: null, previewAttachment: null, drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH, isResizing: false, }; this.inputRef = React.createRef(); this.fileInputRef = 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, activeTurns: state.activeTurns || [], }; }); 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(); const attachments = this.state.stagedAttachments || []; if ((!text && attachments.length === 0) || this.state.conversationState === 'busy' || this.state.isSending) { return; } this.setState({ inputText: '', stagedAttachments: [], attachmentError: null }); try { await chatAutomat.actions.sendMessage(text, attachments); } catch { // Error is tracked in chatAutomat state (sendError) } }; handleOpenFileDialog = () => { this.fileInputRef.current?.click(); }; handleFileSelect = (e) => { const files = e.target?.files; if (files && files.length > 0) { void this.handleFiles(Array.from(files)); } if (e.target) { e.target.value = ''; } }; handleFiles = async (fileList) => { if (!fileList || fileList.length === 0) return; this.setState({ isProcessingFiles: true, attachmentError: null }); const newAttachments = []; const errors = []; for (const file of fileList) { try { const att = await readFileAttachment(file); newAttachments.push(att); } catch (err) { errors.push(err.message || `Datei "${file.name}" konnte nicht verarbeitet werden.`); } } this.setState((prev) => ({ stagedAttachments: [...prev.stagedAttachments, ...newAttachments], isProcessingFiles: false, attachmentError: errors.length > 0 ? errors.join(' ') : null, })); this.focusInput(50); }; handleRemoveAttachment = (idOrIdx) => { this.setState((prev) => ({ stagedAttachments: prev.stagedAttachments.filter((a, idx) => (a.id ? a.id !== idOrIdx : idx !== idOrIdx)), })); }; handlePreviewAttachment = (attachment) => { this.setState({ previewAttachment: attachment }); }; handleClosePreview = () => { this.setState({ previewAttachment: null }); }; handlePaste = (e) => { const items = e.clipboardData?.items; if (!items || items.length === 0) return; const files = []; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.kind === 'file') { const file = item.getAsFile(); if (file) files.push(file); } } if (files.length > 0) { e.preventDefault(); void this.handleFiles(files); } }; handleDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer?.types?.includes('Files')) { this.setState({ isDraggingFiles: true }); } }; handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); if (!this.state.isDraggingFiles && e.dataTransfer?.types?.includes('Files')) { this.setState({ isDraggingFiles: true }); } }; handleDragLeave = (e) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget.contains(e.relatedTarget)) return; this.setState({ isDraggingFiles: false }); }; handleDrop = (e) => { e.preventDefault(); e.stopPropagation(); this.setState({ isDraggingFiles: false }); const files = e.dataTransfer?.files; if (files && files.length > 0) { void this.handleFiles(Array.from(files)); } }; 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 ( KI-Assistent {/* Conversation Selector Bar */} {conversations.length > 0 && ( Konversation this.handleDeleteConversation(e, selectedConversationId)} aria-label="Konversation löschen" sx={{ borderRadius: 2, border: 1, borderColor: 'divider', p: 0.9, flexShrink: 0, }} > )} ); } renderBody() { const { conversations, selectedConversationId, draftConversation, conversationState, messages, streamingText, loadingConversations, model, } = this.state; if (loadingConversations && conversations.length === 0) { return ( Lade Konversationen… ); } if (conversations.length === 0 && !draftConversation) { return ( Willkommen im Chat Starte eine Unterhaltung mit dem KI-Modell, um Fragen zu stellen oder Daten abzufragen. ); } const pendingAsk = this.getPendingAskUser(); return ( {messages.length === 0 ? ( ) : ( )} {pendingAsk && ( )} ); } renderFooter() { const { conversationState, isSending, selectedConversationId, draftConversation, inputText, sendError, stagedAttachments, isProcessingFiles, isDraggingFiles, attachmentError, previewAttachment, } = 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()) || stagedAttachments.length > 0); return ( {sendError && ( {sendError} chatAutomat.setState({ sendError: null })} sx={{ color: 'inherit', p: 0.2 }} > )} {attachmentError && ( {attachmentError} this.setState({ attachmentError: null })} sx={{ color: 'inherit', p: 0.2 }} > )} {/* Staged attachments tray */} {isProcessingFiles && ( Dateien werden eingelesen… )} {isDraggingFiles && ( Dateien hier ablegen (Bilder, PDFs, CSV, Text, XML, HTML) )} {/* Attachment Icon Button */} 0 ? 'primary.main' : 'text.secondary', bgcolor: stagedAttachments.length > 0 ? 'action.selected' : 'transparent', border: '1px solid', borderColor: stagedAttachments.length > 0 ? 'primary.main' : 'transparent', flexShrink: 0, '&:hover': { bgcolor: 'action.hover', color: 'primary.main', }, }} > {/* Hidden File Input */} this.setState({ inputText: e.target.value })} onKeyDown={this.handleKeyDown} onPaste={this.handlePaste} disabled={!canChat} placeholder={ pendingAsk ? 'Bitte Frage oben beantworten…' : stagedAttachments.length > 0 ? 'Nachricht hinzufügen oder direkt absenden…' : undefined } /> {isWorking ? ( ) : ( )} {/* Modal for previewing staged attachments */} ); } render() { const { isOpen, drawerWidth, isResizing } = this.state; return ( <> ({ 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 */} {this.renderHeader()} {this.renderBody()} {this.renderFooter()} ); } }