Files
reactShop/src/components/ChatAssistant.js
2025-10-05 22:21:03 +02:00

657 lines
20 KiB
JavaScript

import React, { Component } from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import CircularProgress from '@mui/material/CircularProgress';
import Avatar from '@mui/material/Avatar';
import SmartToyIcon from '@mui/icons-material/SmartToy';
import PersonIcon from '@mui/icons-material/Person';
import MicIcon from '@mui/icons-material/Mic';
import StopIcon from '@mui/icons-material/Stop';
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
import parse, { domToReact } from 'html-react-parser';
import { Link } from 'react-router-dom';
import { isUserLoggedIn } from './LoginComponent.js';
// Initialize window object for storing messages
if (!window.chatMessages) {
window.chatMessages = [];
}
class ChatAssistant extends Component {
constructor(props) {
super(props);
const privacyConfirmed = sessionStorage.getItem('privacyConfirmed') === 'true';
this.state = {
messages: window.chatMessages,
inputValue: '',
isTyping: false,
isRecording: false,
recordingTime: 0,
mediaRecorder: null,
audioChunks: [],
aiThink: false,
atDatabase: false,
atWeb: false,
privacyConfirmed: privacyConfirmed,
isGuest: false
};
this.messagesEndRef = React.createRef();
this.fileInputRef = React.createRef();
this.recordingTimer = null;
}
componentDidMount() {
// Add socket listeners if socket is available and connected
this.addSocketListeners();
const userStatus = isUserLoggedIn();
const isGuest = !userStatus.isLoggedIn;
if (isGuest && !this.state.privacyConfirmed) {
this.setState(prevState => {
if (prevState.messages.find(msg => msg.id === 'privacy-prompt')) {
return { isGuest: true };
}
const privacyMessage = {
id: 'privacy-prompt',
sender: 'bot',
text: 'Bitte bestätigen Sie, dass Sie die <a href="/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> gelesen haben und damit einverstanden sind. <button data-confirm-privacy="true">Gelesen & Akzeptiert</button>',
};
const updatedMessages = [privacyMessage, ...prevState.messages];
window.chatMessages = updatedMessages;
return {
messages: updatedMessages,
isGuest: true
};
});
} else {
this.setState({ isGuest });
}
}
componentDidUpdate(prevProps, prevState) {
if (prevState.messages !== this.state.messages || prevState.isTyping !== this.state.isTyping) {
this.scrollToBottom();
}
}
componentWillUnmount() {
this.removeSocketListeners();
this.stopRecording();
if (this.recordingTimer) {
clearInterval(this.recordingTimer);
}
}
addSocketListeners = () => {
this.removeSocketListeners();
window.socketManager.on('aiassyResponse', this.handleBotResponse);
window.socketManager.on('aiassyStatus', this.handleStateResponse);
}
removeSocketListeners = () => {
window.socketManager.off('aiassyResponse', this.handleBotResponse);
window.socketManager.off('aiassyStatus', this.handleStateResponse);
}
handleBotResponse = (msgId,response) => {
this.setState(prevState => {
// Check if a message with this msgId already exists
const existingMessageIndex = prevState.messages.findIndex(msg => msg.msgId === msgId);
let updatedMessages;
if (existingMessageIndex !== -1 && msgId) {
// If message with this msgId exists, append the response
updatedMessages = [...prevState.messages];
updatedMessages[existingMessageIndex] = {
...updatedMessages[existingMessageIndex],
text: updatedMessages[existingMessageIndex].text + response.content
};
} else {
// Create a new message
console.log('ChatAssistant: handleBotResponse', msgId, response);
if(response && response.content) {
const newBotMessage = {
id: Date.now(),
msgId: msgId,
sender: 'bot',
text: response.content,
};
updatedMessages = [...prevState.messages, newBotMessage];
}
}
// Store in window object
window.chatMessages = updatedMessages;
return {
messages: updatedMessages,
isTyping: false
};
});
}
handleStateResponse = (msgId,response) => {
if(response == 'think') this.setState({ aiThink: true });
if(response == 'nothink') this.setState({ aiThink: false });
if(response == 'database') this.setState({ atDatabase: true });
if(response == 'nodatabase') this.setState({ atDatabase: false });
if(response == 'web') this.setState({ atWeb: true });
if(response == 'noweb') this.setState({ atWeb: false });
}
scrollToBottom = () => {
this.messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
handleInputChange = (event) => {
this.setState({ inputValue: event.target.value });
}
handleSendMessage = () => {
const userMessage = this.state.inputValue.trim();
if (!userMessage) return;
const newUserMessage = {
id: Date.now(),
sender: 'user',
text: userMessage,
};
// Update messages in component state
this.setState(prevState => {
const updatedMessages = [...prevState.messages, newUserMessage];
// Store in window object
window.chatMessages = updatedMessages;
return {
messages: updatedMessages,
inputValue: '',
isTyping: true
};
}, () => {
// Emit message to socket server after state is updated
if (userMessage.trim()) {
window.socketManager.emit('aiassyMessage', userMessage);
}
});
}
handleKeyDown = (event) => {
if (event.key === 'Enter') {
this.handleSendMessage();
}
}
startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
const audioChunks = [];
mediaRecorder.addEventListener("dataavailable", event => {
audioChunks.push(event.data);
});
mediaRecorder.addEventListener("stop", () => {
if (audioChunks.length > 0) {
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
this.sendAudioMessage(audioBlob);
}
// Stop all tracks on the stream to release the microphone
stream.getTracks().forEach(track => track.stop());
});
// Start recording
mediaRecorder.start();
// Set up timer - limit to 60 seconds
this.recordingTimer = setInterval(() => {
this.setState(prevState => {
const newTime = prevState.recordingTime + 1;
// Auto-stop after 10 seconds
if (newTime >= 10) {
this.stopRecording();
}
return { recordingTime: newTime };
});
}, 1000);
this.setState({
isRecording: true,
mediaRecorder,
audioChunks,
recordingTime: 0
});
} catch (err) {
console.error("Error accessing microphone:", err);
alert("Could not access microphone. Please check your browser permissions.");
}
};
stopRecording = () => {
const { mediaRecorder, isRecording } = this.state;
if (this.recordingTimer) {
clearInterval(this.recordingTimer);
}
if (mediaRecorder && isRecording) {
mediaRecorder.stop();
this.setState({
isRecording: false,
recordingTime: 0
});
}
};
sendAudioMessage = async (audioBlob) => {
// Create a URL for the audio blob
const audioUrl = URL.createObjectURL(audioBlob);
// Create a user message with audio content
const newUserMessage = {
id: Date.now(),
sender: 'user',
text: `<audio controls src="${audioUrl}"></audio>`,
isAudio: true
};
// Update UI with the audio message
this.setState(prevState => {
const updatedMessages = [...prevState.messages, newUserMessage];
// Store in window object
window.chatMessages = updatedMessages;
return {
messages: updatedMessages,
isTyping: true
};
});
// Convert audio to base64 for sending to server
const reader = new FileReader();
reader.readAsDataURL(audioBlob);
reader.onloadend = () => {
const base64Audio = reader.result.split(',')[1];
// Send audio data to server
window.socketManager.emit('aiassyAudioMessage', {
audio: base64Audio,
format: 'wav'
});
};
};
handleImageUpload = () => {
this.fileInputRef.current?.click();
};
handleFileChange = (event) => {
const file = event.target.files[0];
if (file && file.type.startsWith('image/')) {
this.resizeAndSendImage(file);
}
// Reset the file input
event.target.value = '';
};
resizeAndSendImage = (file) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
// Calculate new dimensions (max 450px width/height)
const maxSize = 450;
let { width, height } = img;
if (width > height) {
if (width > maxSize) {
height *= maxSize / width;
width = maxSize;
}
} else {
if (height > maxSize) {
width *= maxSize / height;
height = maxSize;
}
}
// Set canvas dimensions
canvas.width = width;
canvas.height = height;
// Draw and compress image
ctx.drawImage(img, 0, 0, width, height);
// Convert to blob with compression
canvas.toBlob((blob) => {
this.sendImageMessage(blob);
}, 'image/jpeg', 0.8);
};
img.src = URL.createObjectURL(file);
};
sendImageMessage = async (imageBlob) => {
// Create a URL for the image blob
const imageUrl = URL.createObjectURL(imageBlob);
// Create a user message with image content
const newUserMessage = {
id: Date.now(),
sender: 'user',
text: `<img src="${imageUrl}" alt="Uploaded image" style="max-width: 100%; height: auto; border-radius: 8px;" />`,
isImage: true
};
// Update UI with the image message
this.setState(prevState => {
const updatedMessages = [...prevState.messages, newUserMessage];
// Store in window object
window.chatMessages = updatedMessages;
return {
messages: updatedMessages,
isTyping: true
};
});
// Convert image to base64 for sending to server
const reader = new FileReader();
reader.readAsDataURL(imageBlob);
reader.onloadend = () => {
const base64Image = reader.result.split(',')[1];
// Send image data to server
window.socketManager.emit('aiassyPicMessage', {
image: base64Image,
format: 'jpeg'
});
};
};
formatTime = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
};
handlePrivacyConfirm = () => {
sessionStorage.setItem('privacyConfirmed', 'true');
this.setState(prevState => {
const updatedMessages = prevState.messages.filter(msg => msg.id !== 'privacy-prompt');
window.chatMessages = updatedMessages;
return {
privacyConfirmed: true,
messages: updatedMessages
};
});
};
formatMarkdown = (text) => {
// Replace code blocks with formatted HTML
return text.replace(/```(.*?)\n([\s\S]*?)```/g, (match, language, code) => {
return `<pre class="code-block" data-language="${language.trim()}"><code>${code.trim()}</code></pre>`;
});
};
getParseOptions = () => ({
replace: (domNode) => {
// Convert <a> tags to React Router Links
if (domNode.name === 'a' && domNode.attribs && domNode.attribs.href) {
const href = domNode.attribs.href;
// Only convert internal links (not external URLs)
if (!href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('//')) {
return (
<Link to={href} style={{ color: 'inherit', textDecoration: 'underline' }}>
{domToReact(domNode.children, this.getParseOptions())}
</Link>
);
}
}
// Style pre/code blocks
if (domNode.name === 'pre' && domNode.attribs && domNode.attribs.class === 'code-block') {
const language = domNode.attribs['data-language'] || '';
return (
<pre style={{
backgroundColor: '#c0f5c0',
padding: '8px',
borderRadius: '4px',
overflowX: 'auto',
fontFamily: 'monospace',
fontSize: '0.9em',
whiteSpace: 'pre-wrap',
margin: '8px 0'
}}>
{language && <div style={{ marginBottom: '4px', color: '#666' }}>{language}</div>}
{domToReact(domNode.children, this.getParseOptions())}
</pre>
);
}
if (domNode.name === 'button' && domNode.attribs && domNode.attribs['data-confirm-privacy']) {
return <Button variant="contained" size="small" onClick={this.handlePrivacyConfirm}>Gelesen & Akzeptiert</Button>;
}
}
});
render() {
const { open, onClose } = this.props;
const { messages, inputValue, isTyping, isRecording, recordingTime, isGuest, privacyConfirmed } = this.state;
if (!open) {
return null;
}
const inputsDisabled = isGuest && !privacyConfirmed;
return (
<Paper
elevation={4}
sx={{
position: 'fixed',
bottom: { xs: 0, sm: 80 },
right: { xs: 0, sm: 16 },
left: { xs: 0, sm: 'auto' },
top: { xs: 0, sm: 'auto' },
width: { xs: '100vw', sm: 450, md: 600, lg: 750 },
height: { xs: '100vh', sm: 600, md: 650, lg: 700 },
maxWidth: { xs: 'none', sm: 450, md: 600, lg: 750 },
maxHeight: { xs: '100vh', sm: 600, md: 650, lg: 700 },
bgcolor: 'background.paper',
borderRadius: { xs: 0, sm: 2 },
display: 'flex',
flexDirection: 'column',
zIndex: 1300,
overflow: 'hidden'
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
px: 2,
py: 1,
borderBottom: 1,
borderColor: 'divider',
bgcolor: 'primary.main',
color: 'primary.contrastText',
borderTopLeftRadius: 'inherit',
borderTopRightRadius: 'inherit',
flexShrink: 0,
}}
>
<Typography variant="h6" component="div">
Assistent
<Typography component="span" color={this.state.aiThink ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🧠</Typography>
<Typography component="span" color={this.state.atDatabase ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🛢</Typography>
<Typography component="span" color={this.state.atWeb ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🌐</Typography>
</Typography>
<IconButton onClick={onClose} size="small" aria-label="Assistent schließen" sx={{ color: 'primary.contrastText' }}>
<CloseIcon />
</IconButton>
</Box>
<Box
sx={{
flexGrow: 1,
overflowY: 'auto',
p: 2,
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
{messages &&messages.map((message) => (
<Box
key={message.id}
sx={{
display: 'flex',
justifyContent: message.sender === 'user' ? 'flex-end' : 'flex-start',
gap: 1,
}}
>
{message.sender === 'bot' && (
<Avatar sx={{ bgcolor: 'primary.main', width: 30, height: 30 }}>
<SmartToyIcon fontSize="small" />
</Avatar>
)}
<Paper
elevation={1}
sx={{
py: 1,
px: 3,
borderRadius: 2,
bgcolor: message.sender === 'user' ? 'secondary.light' : 'grey.200',
maxWidth: '75%',
fontSize: '0.8em'
}}
>
{message.text ? parse(this.formatMarkdown(message.text), this.getParseOptions()) : ''}
</Paper>
{message.sender === 'user' && (
<Avatar sx={{ bgcolor: 'secondary.main', width: 30, height: 30 }}>
<PersonIcon fontSize="small" />
</Avatar>
)}
</Box>
))}
{isTyping && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar sx={{ bgcolor: 'primary.main', width: 30, height: 30 }}>
<SmartToyIcon fontSize="small" />
</Avatar>
<Paper elevation={1} sx={{ p: 1, borderRadius: 2, bgcolor: 'grey.200', display: 'inline-flex', alignItems: 'center' }}>
<CircularProgress size={16} sx={{ mx: 1 }} />
</Paper>
</Box>
)}
<div ref={this.messagesEndRef} />
</Box>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: { xs: 1, sm: 0 },
p: 1,
borderTop: 1,
borderColor: 'divider',
flexShrink: 0,
}}
>
<input
type="file"
ref={this.fileInputRef}
accept="image/*"
onChange={this.handleFileChange}
style={{ display: 'none' }}
/>
<TextField
fullWidth
variant="outlined"
size="small"
autoComplete="off"
autoFocus
autoCapitalize="off"
autoCorrect="off"
placeholder={isRecording ? "Aufnahme läuft..." : "Du kannst mich nach Cannabissorten fragen..."}
value={inputValue}
onChange={this.handleInputChange}
onKeyDown={this.handleKeyDown}
disabled={isRecording || inputsDisabled}
slotProps={{
input: {
maxLength: 300,
endAdornment: isRecording && (
<Typography variant="caption" color="primary" sx={{ mr: 1 }}>
{this.formatTime(recordingTime)}
</Typography>
)
}
}}
/>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
{isRecording ? (
<IconButton
color="error"
onClick={this.stopRecording}
aria-label="Aufnahme stoppen"
sx={{ ml: { xs: 0, sm: 1 } }}
>
<StopIcon />
</IconButton>
) : (
<IconButton
color="primary"
onClick={this.startRecording}
aria-label="Sprachaufnahme starten"
sx={{ ml: { xs: 0, sm: 1 } }}
disabled={isTyping || inputsDisabled}
>
<MicIcon />
</IconButton>
)}
<IconButton
color="primary"
onClick={this.handleImageUpload}
aria-label="Bild hochladen"
sx={{ ml: { xs: 0, sm: 1 } }}
disabled={isTyping || isRecording || inputsDisabled}
>
<PhotoCameraIcon />
</IconButton>
<Button
variant="contained"
sx={{ ml: { xs: 0, sm: 1 } }}
onClick={this.handleSendMessage}
disabled={isTyping || isRecording || inputsDisabled}
>
Senden
</Button>
</Box>
</Box>
</Paper>
);
}
}
export default ChatAssistant;