Files
IT-Nexus/frontend/src/components/common/AiChatWidget.jsx

297 lines
12 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useRef, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import aiService from '../../services/aiService';
import { marked } from 'marked';
marked.use({ breaks: true, gfm: true });
const renderMd = (text) => {
try {
const html = marked.parse(String(text || ''), { async: false });
return { __html: typeof html === 'string' ? html : String(html) };
} catch {
return { __html: String(text || '').replace(/\n/g, '<br>') };
}
};
const STAFF_ROLES = ['super_admin', 'admin', 'support', 'bearbeiter'];
const WELCOME_MSG = {
role: 'assistant',
content: 'Hallo! Ich bin dein KI-Assistent für IT-Support. Wie kann ich dir helfen?\n\nDu kannst mich z.B. nach Troubleshooting-Schritten, Lösungen für bekannte Probleme oder Ticket-Einschätzungen fragen.',
};
export default function AiChatWidget() {
const { user } = useAuth();
const location = useLocation();
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState([WELCOME_MSG]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [configured, setConfigured] = useState(true);
const bottomRef = useRef(null);
const inputRef = useRef(null);
const isStaff = user && STAFF_ROLES.includes(user.role_name);
const isPublicPage = ['/health', '/defect', '/login', '/tv'].some(p => location.pathname.startsWith(p));
// Status prüfen (immer, auch wenn nicht Staff)
useEffect(() => {
if (!isStaff) return;
aiService.getStatus()
.then(s => setConfigured(s.configured))
.catch(() => setConfigured(false));
}, [isStaff]);
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [open]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Nur für Staff-Rollen und nicht auf öffentlichen Seiten anzeigen
if (!isStaff || isPublicPage) return null;
async function sendMessage() {
const text = input.trim();
if (!text || loading) return;
const userMsg = { role: 'user', content: text };
const newMessages = [...messages, userMsg];
setMessages(newMessages);
setInput('');
setLoading(true);
try {
// Nur die letzten 20 Nachrichten als Kontext senden (ohne Welcome-Msg)
const contextMsgs = newMessages
.filter(m => m !== WELCOME_MSG)
.slice(-20);
const reply = await aiService.chat(contextMsgs);
setMessages(prev => [...prev, { role: 'assistant', content: reply }]);
} catch (err) {
setMessages(prev => [...prev, {
role: 'assistant',
content: `Fehler: ${err.response?.data?.message || err.message || 'Unbekannter Fehler'}`,
isError: true,
}]);
} finally {
setLoading(false);
setTimeout(() => inputRef.current?.focus(), 50);
}
}
function handleKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
function clearChat() {
setMessages([WELCOME_MSG]);
}
return (
<>
{/* Floating Button */}
<button
onClick={() => setOpen(o => !o)}
style={{
position: 'fixed',
bottom: 24,
right: 24,
width: 52,
height: 52,
borderRadius: '50%',
background: configured ? 'linear-gradient(135deg, #6366f1, #8b5cf6)' : '#6b7280',
border: 'none',
color: '#fff',
fontSize: 22,
cursor: 'pointer',
boxShadow: '0 4px 16px rgba(99,102,241,0.4)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'transform 0.2s',
}}
title={configured ? 'KI-Assistent' : 'KI nicht konfiguriert'}
>
{open ? '✕' : '🤖'}
</button>
{/* Chat Panel */}
{open && (
<div style={{
position: 'fixed',
bottom: 88,
right: 24,
width: 380,
maxWidth: 'calc(100vw - 48px)',
height: 520,
background: 'var(--bg-primary, #fff)',
border: '1px solid var(--border-color, #e5e7eb)',
borderRadius: 16,
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
display: 'flex',
flexDirection: 'column',
zIndex: 999,
overflow: 'hidden',
}}>
{/* Header */}
<div style={{
padding: '12px 16px',
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
color: '#fff',
display: 'flex',
alignItems: 'center',
gap: 10,
}}>
<span style={{ fontSize: 20 }}>🤖</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>KI-Assistent</div>
<div style={{ fontSize: 11, opacity: 0.8 }}>
{configured ? 'Claude claude-sonnet-4-6 · IT Support' : 'Nicht konfiguriert'}
</div>
</div>
<button
onClick={clearChat}
title="Chat leeren"
style={{
background: 'rgba(255,255,255,0.2)',
border: 'none',
color: '#fff',
borderRadius: 6,
padding: '2px 8px',
fontSize: 11,
cursor: 'pointer',
}}
>
Leeren
</button>
</div>
{!configured && (
<div style={{
padding: '10px 16px',
background: '#fef3c7',
color: '#92400e',
fontSize: 12,
borderBottom: '1px solid #fde68a',
}}>
ANTHROPIC_API_KEY nicht konfiguriert
</div>
)}
{/* Messages */}
<div style={{
flex: 1,
overflowY: 'auto',
padding: '12px 14px',
display: 'flex',
flexDirection: 'column',
gap: 10,
}}>
{messages.map((msg, i) => (
<div key={i} style={{
display: 'flex',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
}}>
<div style={{
maxWidth: '85%',
padding: '8px 12px',
borderRadius: msg.role === 'user' ? '12px 12px 2px 12px' : '12px 12px 12px 2px',
background: msg.role === 'user'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: msg.isError
? '#fee2e2'
: 'var(--bg-secondary, #f3f4f6)',
color: msg.role === 'user' ? '#fff' : msg.isError ? '#991b1b' : 'var(--text-primary, #111)',
fontSize: 13,
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}>
{msg.role === 'assistant'
? <div className="md-content" dangerouslySetInnerHTML={renderMd(msg.content)} />
: msg.content}
</div>
</div>
))}
{loading && (
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
<div style={{
padding: '10px 14px',
borderRadius: '12px 12px 12px 2px',
background: 'var(--bg-secondary, #f3f4f6)',
fontSize: 18,
letterSpacing: 2,
}}>
<span style={{ animation: 'pulse 1.2s infinite' }}></span>
<span style={{ animation: 'pulse 1.2s 0.3s infinite', opacity: 0.5 }}></span>
<span style={{ animation: 'pulse 1.2s 0.6s infinite', opacity: 0.25 }}></span>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
{/* Input */}
<div style={{
padding: '10px 12px',
borderTop: '1px solid var(--border-color, #e5e7eb)',
display: 'flex',
gap: 8,
}}>
<textarea
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Nachricht eingeben... (Enter zum Senden)"
disabled={loading || !configured}
rows={2}
style={{
flex: 1,
resize: 'none',
padding: '8px 10px',
borderRadius: 8,
border: '1px solid var(--border-color, #d1d5db)',
fontSize: 13,
background: 'var(--bg-primary, #fff)',
color: 'var(--text-primary, #111)',
outline: 'none',
fontFamily: 'inherit',
}}
/>
<button
onClick={sendMessage}
disabled={loading || !input.trim() || !configured}
style={{
padding: '0 14px',
borderRadius: 8,
border: 'none',
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
color: '#fff',
fontSize: 18,
cursor: 'pointer',
opacity: loading || !input.trim() || !configured ? 0.5 : 1,
alignSelf: 'flex-end',
height: 38,
}}
>
</button>
</div>
</div>
)}
</>
);
}