Initial commit: IT Nexus Web-App

This commit is contained in:
2026-06-01 20:49:07 +02:00
commit 8023765e6c
387 changed files with 106900 additions and 0 deletions

View File

@@ -0,0 +1,375 @@
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>
);
}