This commit is contained in:
seb
2026-10-06 11:40:02 +02:00
parent 03608017f3
commit 4769a87c54
6 changed files with 1197 additions and 20 deletions

View File

@@ -16,10 +16,17 @@ 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,
@@ -50,10 +57,16 @@ export default class ChatDrawer extends PureComponent {
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;
}
@@ -204,17 +217,122 @@ export default class ChatDrawer extends PureComponent {
handleSend = async (e) => {
e?.preventDefault();
const text = this.state.inputText.trim();
if (!text || this.state.conversationState === 'busy' || this.state.isSending) {
const attachments = this.state.stagedAttachments || [];
if ((!text && attachments.length === 0) || this.state.conversationState === 'busy' || this.state.isSending) {
return;
}
this.setState({ inputText: '' });
this.setState({ inputText: '', stagedAttachments: [], attachmentError: null });
try {
await chatAutomat.actions.sendMessage(text);
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();
@@ -449,26 +567,37 @@ export default class ChatDrawer extends PureComponent {
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());
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: 'divider',
bgcolor: 'background.paper',
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 && (
@@ -495,7 +624,105 @@ export default class ChatDrawer extends PureComponent {
</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
@@ -504,11 +731,14 @@ export default class ChatDrawer extends PureComponent {
value={inputText}
onChange={(e) => this.setState({ inputText: e.target.value })}
onKeyDown={this.handleKeyDown}
onPaste={this.handlePaste}
disabled={!canChat}
placeholder={
pendingAsk
? 'Bitte Frage oben beantworten…'
: undefined
: stagedAttachments.length > 0
? 'Nachricht hinzufügen oder direkt absenden…'
: undefined
}
/>
@@ -527,6 +757,7 @@ export default class ChatDrawer extends PureComponent {
width: 40,
height: 40,
borderRadius: 2.5,
flexShrink: 0,
'&:hover': {
bgcolor: 'primary.dark',
},
@@ -544,6 +775,13 @@ export default class ChatDrawer extends PureComponent {
)}
</IconButton>
</Box>
{/* Modal for previewing staged attachments */}
<ChatAttachmentPreviewModal
open={Boolean(previewAttachment)}
attachment={previewAttachment}
onClose={this.handleClosePreview}
/>
</Box>
);
}