880 lines
28 KiB
JavaScript
880 lines
28 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 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 (
|
|
<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 && (!this.state.activeTurns || this.state.activeTurns.length === 0)
|
|
? [
|
|
...messages,
|
|
{
|
|
id: 'streaming',
|
|
conversation_id: selectedConversationId,
|
|
role: 'assistant',
|
|
message: streamingText,
|
|
streaming: true,
|
|
},
|
|
]
|
|
: messages
|
|
}
|
|
activeTurns={this.state.activeTurns}
|
|
conversationState={conversationState}
|
|
model={model}
|
|
/>
|
|
)}
|
|
|
|
{pendingAsk && (
|
|
<AskUserPrompt
|
|
askData={pendingAsk}
|
|
onAnswer={this.handleAnswerTool}
|
|
disabled={this.state.isSending}
|
|
/>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Box
|
|
component="form"
|
|
onSubmit={this.handleSend}
|
|
onDragEnter={this.handleDragEnter}
|
|
onDragOver={this.handleDragOver}
|
|
onDragLeave={this.handleDragLeave}
|
|
onDrop={this.handleDrop}
|
|
sx={{
|
|
p: 2,
|
|
borderTop: 1,
|
|
borderColor: isDraggingFiles ? 'primary.main' : 'divider',
|
|
bgcolor: isDraggingFiles ? 'action.hover' : 'background.paper',
|
|
transition: 'border-color 0.15s, background-color 0.15s',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 1,
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{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>
|
|
)}
|
|
|
|
{attachmentError && (
|
|
<Box
|
|
sx={{
|
|
p: 1,
|
|
borderRadius: 1.5,
|
|
bgcolor: 'warning.light',
|
|
color: 'warning.contrastText',
|
|
fontSize: '0.75rem',
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<Typography variant="caption">{attachmentError}</Typography>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => this.setState({ attachmentError: null })}
|
|
sx={{ color: 'inherit', p: 0.2 }}
|
|
>
|
|
<CloseRoundedIcon sx={{ fontSize: 14 }} />
|
|
</IconButton>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Staged attachments tray */}
|
|
<ChatStagedAttachmentsTray
|
|
attachments={stagedAttachments}
|
|
onRemove={this.handleRemoveAttachment}
|
|
onPreview={this.handlePreviewAttachment}
|
|
/>
|
|
|
|
{isProcessingFiles && (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5, px: 1 }}>
|
|
<CircularProgress size={14} thickness={5} />
|
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.72rem' }}>
|
|
Dateien werden eingelesen…
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
|
|
{isDraggingFiles && (
|
|
<Box
|
|
sx={{
|
|
p: 1.5,
|
|
border: '2px dashed',
|
|
borderColor: 'primary.main',
|
|
borderRadius: 2,
|
|
textAlign: 'center',
|
|
bgcolor: 'action.selected',
|
|
color: 'primary.main',
|
|
fontWeight: 500,
|
|
fontSize: '0.85rem',
|
|
}}
|
|
>
|
|
Dateien hier ablegen (Bilder, PDFs, CSV, Text, XML, HTML)
|
|
</Box>
|
|
)}
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 1 }}>
|
|
{/* Attachment Icon Button */}
|
|
<Tooltip title="Dateien anhängen (Bilder, PDFs, CSV, Text, XML, HTML)">
|
|
<span>
|
|
<IconButton
|
|
size="small"
|
|
onClick={this.handleOpenFileDialog}
|
|
disabled={!canChat || isWorking}
|
|
aria-label="Dateien anhängen"
|
|
sx={{
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 2.5,
|
|
color: stagedAttachments.length > 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',
|
|
},
|
|
}}
|
|
>
|
|
<Badge badgeContent={stagedAttachments.length} color="primary">
|
|
<AttachFileRoundedIcon sx={{ fontSize: 22 }} />
|
|
</Badge>
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
|
|
{/* Hidden File Input */}
|
|
<input
|
|
ref={this.fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept={ACCEPT_STRING}
|
|
onChange={this.handleFileSelect}
|
|
style={{ display: 'none' }}
|
|
/>
|
|
|
|
<TextInput
|
|
inputRef={this.inputRef}
|
|
multiline
|
|
maxRows={4}
|
|
fullWidth
|
|
value={inputText}
|
|
onChange={(e) => 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
|
|
}
|
|
/>
|
|
|
|
<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,
|
|
flexShrink: 0,
|
|
'&: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>
|
|
|
|
{/* Modal for previewing staged attachments */}
|
|
<ChatAttachmentPreviewModal
|
|
open={Boolean(previewAttachment)}
|
|
attachment={previewAttachment}
|
|
onClose={this.handleClosePreview}
|
|
/>
|
|
</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>
|
|
</>
|
|
);
|
|
}
|
|
}
|