376 lines
22 KiB
React
376 lines
22 KiB
React
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|||
|
|
import { useAuth } from '../context/AuthContext';
|
|||
|
|
import aiService from '../services/aiService';
|
|||
|
|
import { toast } from 'react-toastify';
|
|||
|
|
import { Marked } from 'marked';
|
|||
|
|
|
|||
|
|
const marked = new Marked({ breaks: true, gfm: true });
|
|||
|
|
const renderMd = (text) => ({ __html: marked.parse(text) });
|
|||
|
|
|
|||
|
|
const WELCOME_MSG = {
|
|||
|
|
role: 'assistant',
|
|||
|
|
content: 'Hallo! Ich bin dein KI-Assistent für IT-Support. Du kannst mich nach Lösungen für bekannte Probleme, Troubleshooting-Schritten oder Ticket-Einschätzungen fragen.',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const CATEGORY_LIST = ['Software', 'Hardware', 'Allgemein', 'SelectLine'];
|
|||
|
|
|
|||
|
|
export default function AiPage() {
|
|||
|
|
const { isAdmin } = useAuth();
|
|||
|
|
const [activeTab, setActiveTab] = useState('chat');
|
|||
|
|
|
|||
|
|
// Chat State
|
|||
|
|
const [messages, setMessages] = useState([WELCOME_MSG]);
|
|||
|
|
const [input, setInput] = useState('');
|
|||
|
|
const [chatLoading, setChatLoading] = useState(false);
|
|||
|
|
const [configured, setConfigured] = useState(true);
|
|||
|
|
const bottomRef = useRef(null);
|
|||
|
|
const inputRef = useRef(null);
|
|||
|
|
|
|||
|
|
// Knowledge Base State
|
|||
|
|
const [kbEntries, setKbEntries] = useState([]);
|
|||
|
|
const [kbLoading, setKbLoading] = useState(false);
|
|||
|
|
const [showKbForm, setShowKbForm] = useState(false);
|
|||
|
|
const [kbForm, setKbForm] = useState({ problem: '', solution: '', category: 'Allgemein', tags: '' });
|
|||
|
|
const [kbSaving, setKbSaving] = useState(false);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
aiService.getStatus()
|
|||
|
|
.then(s => setConfigured(s.configured))
|
|||
|
|
.catch(() => setConfigured(false));
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (activeTab === 'kb') loadKb();
|
|||
|
|
}, [activeTab]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|||
|
|
}, [messages]);
|
|||
|
|
|
|||
|
|
async function loadKb() {
|
|||
|
|
setKbLoading(true);
|
|||
|
|
try {
|
|||
|
|
const data = await aiService.getKnowledgeBase();
|
|||
|
|
setKbEntries(data);
|
|||
|
|
} catch (err) {
|
|||
|
|
toast.error('Wissensdatenbank konnte nicht geladen werden');
|
|||
|
|
} finally {
|
|||
|
|
setKbLoading(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function sendMessage() {
|
|||
|
|
const text = input.trim();
|
|||
|
|
if (!text || chatLoading) return;
|
|||
|
|
|
|||
|
|
const userMsg = { role: 'user', content: text };
|
|||
|
|
const newMessages = [...messages, userMsg];
|
|||
|
|
setMessages(newMessages);
|
|||
|
|
setInput('');
|
|||
|
|
setChatLoading(true);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
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}`,
|
|||
|
|
isError: true,
|
|||
|
|
}]);
|
|||
|
|
} finally {
|
|||
|
|
setChatLoading(false);
|
|||
|
|
setTimeout(() => inputRef.current?.focus(), 50);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveKbEntry() {
|
|||
|
|
if (!kbForm.problem.trim() || !kbForm.solution.trim()) {
|
|||
|
|
toast.error('Problem und Lösung sind pflichtfelder');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setKbSaving(true);
|
|||
|
|
try {
|
|||
|
|
await aiService.addKnowledgeEntry(kbForm);
|
|||
|
|
toast.success('Eintrag gespeichert');
|
|||
|
|
setKbForm({ problem: '', solution: '', category: 'Allgemein', tags: '' });
|
|||
|
|
setShowKbForm(false);
|
|||
|
|
loadKb();
|
|||
|
|
} catch (err) {
|
|||
|
|
toast.error('Fehler beim Speichern');
|
|||
|
|
} finally {
|
|||
|
|
setKbSaving(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function deleteKbEntry(id) {
|
|||
|
|
if (!window.confirm('Eintrag wirklich löschen?')) return;
|
|||
|
|
try {
|
|||
|
|
await aiService.deleteKnowledgeEntry(id);
|
|||
|
|
toast.success('Eintrag gelöscht');
|
|||
|
|
setKbEntries(prev => prev.filter(e => e.id !== id));
|
|||
|
|
} catch {
|
|||
|
|
toast.error('Fehler beim Löschen');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const TAB = (active) => ({
|
|||
|
|
padding: '10px 16px',
|
|||
|
|
border: 'none',
|
|||
|
|
background: 'transparent',
|
|||
|
|
cursor: 'pointer',
|
|||
|
|
fontSize: '0.875rem',
|
|||
|
|
fontWeight: active ? 600 : 400,
|
|||
|
|
color: active ? 'var(--cereda-primary)' : 'var(--text-muted)',
|
|||
|
|
borderBottom: active ? '2px solid var(--cereda-primary)' : '2px solid transparent',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="main-content">
|
|||
|
|
<div style={{ maxWidth: 960, margin: '0 auto' }}>
|
|||
|
|
{/* Header */}
|
|||
|
|
<div style={{ marginBottom: 24 }}>
|
|||
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 }}>
|
|||
|
|
<span style={{ fontSize: 28 }}>🤖</span>
|
|||
|
|
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 700 }}>KI-Assistent</h1>
|
|||
|
|
{!configured && (
|
|||
|
|
<span style={{ fontSize: 12, padding: '2px 8px', borderRadius: 6, background: '#fef3c7', color: '#92400e', border: '1px solid #fde68a' }}>
|
|||
|
|
Nicht konfiguriert
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
|||
|
|
Claude claude-sonnet-4-6 · IT-Support-Assistent mit Wissensdatenbank
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{!configured && (
|
|||
|
|
<div className="card" style={{ padding: '16px 20px', marginBottom: 20, background: '#fef3c7', border: '1px solid #fde68a' }}>
|
|||
|
|
<strong>⚠️ ANTHROPIC_API_KEY fehlt</strong>
|
|||
|
|
<p style={{ margin: '4px 0 0', fontSize: '0.875rem', color: '#92400e' }}>
|
|||
|
|
Füge <code>ANTHROPIC_API_KEY=sk-ant-...</code> in der docker-compose.yml als Umgebungsvariable hinzu und deploye neu.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="card" style={{ overflow: 'hidden' }}>
|
|||
|
|
{/* Tabs */}
|
|||
|
|
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color)', padding: '0 8px' }}>
|
|||
|
|
<button style={TAB(activeTab === 'chat')} onClick={() => setActiveTab('chat')}>
|
|||
|
|
💬 Chat
|
|||
|
|
</button>
|
|||
|
|
<button style={TAB(activeTab === 'kb')} onClick={() => setActiveTab('kb')}>
|
|||
|
|
📚 Wissensdatenbank ({kbEntries.length})
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Chat Tab */}
|
|||
|
|
{activeTab === 'chat' && (
|
|||
|
|
<div style={{ display: 'flex', flexDirection: 'column', height: 560 }}>
|
|||
|
|
{/* Messages */}
|
|||
|
|
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|||
|
|
{messages.map((msg, i) => (
|
|||
|
|
<div key={i} style={{ display: 'flex', justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start' }}>
|
|||
|
|
<div style={{
|
|||
|
|
maxWidth: '75%',
|
|||
|
|
padding: '10px 14px',
|
|||
|
|
borderRadius: msg.role === 'user' ? '14px 14px 2px 14px' : '14px 14px 14px 2px',
|
|||
|
|
background: msg.role === 'user'
|
|||
|
|
? 'linear-gradient(135deg, var(--cereda-primary), #8b5cf6)'
|
|||
|
|
: msg.isError ? '#fee2e2' : 'var(--bg-tertiary)',
|
|||
|
|
color: msg.role === 'user' ? '#fff' : msg.isError ? '#991b1b' : 'var(--text-primary)',
|
|||
|
|
fontSize: '0.875rem',
|
|||
|
|
lineHeight: 1.6,
|
|||
|
|
whiteSpace: 'pre-wrap',
|
|||
|
|
wordBreak: 'break-word',
|
|||
|
|
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
|
|||
|
|
}}>
|
|||
|
|
{msg.role === 'assistant'
|
|||
|
|
? <div className="md-content" dangerouslySetInnerHTML={renderMd(msg.content)} />
|
|||
|
|
: msg.content}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
|
|||
|
|
{chatLoading && (
|
|||
|
|
<div style={{ display: 'flex' }}>
|
|||
|
|
<div style={{ padding: '10px 16px', borderRadius: '14px 14px 14px 2px', background: 'var(--bg-tertiary)', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
|||
|
|
KI denkt nach…
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
<div ref={bottomRef} />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Input */}
|
|||
|
|
<div style={{ padding: '12px 16px', borderTop: '1px solid var(--border-color)', display: 'flex', gap: 10, alignItems: 'flex-end' }}>
|
|||
|
|
<textarea
|
|||
|
|
ref={inputRef}
|
|||
|
|
value={input}
|
|||
|
|
onChange={e => setInput(e.target.value)}
|
|||
|
|
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }}
|
|||
|
|
placeholder="Nachricht eingeben… (Enter zum Senden, Shift+Enter für Zeilenumbruch)"
|
|||
|
|
disabled={chatLoading || !configured}
|
|||
|
|
rows={2}
|
|||
|
|
style={{
|
|||
|
|
flex: 1, resize: 'none', padding: '10px 12px', borderRadius: 8,
|
|||
|
|
border: '1px solid var(--border-color)', fontSize: '0.875rem',
|
|||
|
|
background: 'var(--bg-primary)', color: 'var(--text-primary)',
|
|||
|
|
outline: 'none', fontFamily: 'inherit',
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'stretch' }}>
|
|||
|
|
<button
|
|||
|
|
onClick={sendMessage}
|
|||
|
|
disabled={chatLoading || !input.trim() || !configured}
|
|||
|
|
className="btn btn-primary"
|
|||
|
|
style={{ height: 38, padding: '0 20px', whiteSpace: 'nowrap' }}
|
|||
|
|
>
|
|||
|
|
Senden ↑
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => setMessages([WELCOME_MSG])}
|
|||
|
|
className="btn btn-secondary"
|
|||
|
|
style={{ height: 38, padding: '0 20px', fontSize: '0.75rem', whiteSpace: 'nowrap' }}
|
|||
|
|
>
|
|||
|
|
Leeren
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Knowledge Base Tab */}
|
|||
|
|
{activeTab === 'kb' && (
|
|||
|
|
<div style={{ padding: '20px 24px' }}>
|
|||
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
|||
|
|
<div>
|
|||
|
|
<h3 style={{ margin: 0, fontSize: '1rem' }}>Bekannte Probleme & Lösungen</h3>
|
|||
|
|
<p style={{ margin: '2px 0 0', fontSize: '0.8125rem', color: 'var(--text-muted)' }}>
|
|||
|
|
Diese Einträge werden als Kontext an den KI-Assistenten weitergegeben.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
{isAdmin() && (
|
|||
|
|
<button className="btn btn-primary" onClick={() => setShowKbForm(f => !f)}>
|
|||
|
|
+ Eintrag hinzufügen
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Add Form */}
|
|||
|
|
{showKbForm && isAdmin() && (
|
|||
|
|
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 10, padding: 20, marginBottom: 20 }}>
|
|||
|
|
<h4 style={{ margin: '0 0 16px', fontSize: '0.9375rem' }}>Neuer Eintrag</h4>
|
|||
|
|
<div style={{ display: 'grid', gap: 12 }}>
|
|||
|
|
<div>
|
|||
|
|
<label style={{ fontSize: '0.75rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Problem *</label>
|
|||
|
|
<textarea
|
|||
|
|
value={kbForm.problem}
|
|||
|
|
onChange={e => setKbForm(f => ({ ...f, problem: e.target.value }))}
|
|||
|
|
placeholder="Beschreibe das Problem"
|
|||
|
|
rows={2}
|
|||
|
|
style={{ width: '100%', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--border-color)', fontSize: '0.875rem', background: 'var(--bg-primary)', color: 'var(--text-primary)', resize: 'vertical', boxSizing: 'border-box' }}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label style={{ fontSize: '0.75rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Lösung *</label>
|
|||
|
|
<textarea
|
|||
|
|
value={kbForm.solution}
|
|||
|
|
onChange={e => setKbForm(f => ({ ...f, solution: e.target.value }))}
|
|||
|
|
placeholder="Beschreibe die Lösung Schritt für Schritt"
|
|||
|
|
rows={4}
|
|||
|
|
style={{ width: '100%', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--border-color)', fontSize: '0.875rem', background: 'var(--bg-primary)', color: 'var(--text-primary)', resize: 'vertical', boxSizing: 'border-box' }}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|||
|
|
<div>
|
|||
|
|
<label style={{ fontSize: '0.75rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Kategorie</label>
|
|||
|
|
<select
|
|||
|
|
value={kbForm.category}
|
|||
|
|
onChange={e => setKbForm(f => ({ ...f, category: e.target.value }))}
|
|||
|
|
style={{ width: '100%', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--border-color)', fontSize: '0.875rem', background: 'var(--bg-primary)', color: 'var(--text-primary)' }}
|
|||
|
|
>
|
|||
|
|
{CATEGORY_LIST.map(c => <option key={c}>{c}</option>)}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label style={{ fontSize: '0.75rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Tags</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={kbForm.tags}
|
|||
|
|
onChange={e => setKbForm(f => ({ ...f, tags: e.target.value }))}
|
|||
|
|
placeholder="z.B. VPN, Outlook, Drucker"
|
|||
|
|
style={{ width: '100%', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--border-color)', fontSize: '0.875rem', background: 'var(--bg-primary)', color: 'var(--text-primary)', boxSizing: 'border-box' }}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|||
|
|
<button className="btn btn-secondary" onClick={() => setShowKbForm(false)}>Abbrechen</button>
|
|||
|
|
<button className="btn btn-primary" onClick={saveKbEntry} disabled={kbSaving}>
|
|||
|
|
{kbSaving ? 'Speichern…' : 'Speichern'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Entries */}
|
|||
|
|
{kbLoading ? (
|
|||
|
|
<div style={{ textAlign: 'center', padding: '3rem 0', color: 'var(--text-muted)' }}>Lade…</div>
|
|||
|
|
) : kbEntries.length === 0 ? (
|
|||
|
|
<div style={{ textAlign: 'center', padding: '3rem 0', color: 'var(--text-muted)' }}>
|
|||
|
|
<div style={{ fontSize: '2.5rem', marginBottom: 8 }}>📚</div>
|
|||
|
|
<p style={{ margin: 0 }}>Noch keine Einträge vorhanden.</p>
|
|||
|
|
{isAdmin() && <p style={{ margin: '4px 0 0', fontSize: '0.8125rem' }}>Füge bekannte Probleme & Lösungen hinzu, damit die KI besser helfen kann.</p>}
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|||
|
|
{kbEntries.map(entry => (
|
|||
|
|
<div key={entry.id} style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 10, padding: '14px 18px' }}>
|
|||
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
|
|||
|
|
<div style={{ flex: 1 }}>
|
|||
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
|||
|
|
<span style={{ fontSize: '0.6875rem', fontWeight: 700, padding: '2px 8px', borderRadius: 4, background: 'var(--bg-tertiary)', color: 'var(--text-muted)' }}>
|
|||
|
|
{entry.category}
|
|||
|
|
</span>
|
|||
|
|
{entry.tags && (
|
|||
|
|
<span style={{ fontSize: '0.6875rem', color: 'var(--text-muted)' }}>
|
|||
|
|
🏷️ {entry.tags}
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<div style={{ marginBottom: 8 }}>
|
|||
|
|
<span style={{ fontSize: '0.6875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>Problem: </span>
|
|||
|
|
<span style={{ fontSize: '0.875rem', color: 'var(--text-primary)', whiteSpace: 'pre-wrap' }}>{entry.problem}</span>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<span style={{ fontSize: '0.6875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--success)' }}>Lösung: </span>
|
|||
|
|
<span style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{entry.solution}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
{isAdmin() && (
|
|||
|
|
<button
|
|||
|
|
onClick={() => deleteKbEntry(entry.id)}
|
|||
|
|
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border-color)', background: 'transparent', color: 'var(--danger)', cursor: 'pointer', fontSize: '0.8125rem', flexShrink: 0 }}
|
|||
|
|
title="Löschen"
|
|||
|
|
>
|
|||
|
|
🗑️
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<div style={{ marginTop: 8, fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
|||
|
|
Erstellt von {entry.created_by_username || 'System'} · {new Date(entry.created_at).toLocaleDateString('de-DE')}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|