Initial commit: IT Nexus Web-App
This commit is contained in:
296
frontend/src/components/common/AiChatWidget.jsx
Normal file
296
frontend/src/components/common/AiChatWidget.jsx
Normal file
@@ -0,0 +1,296 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user