Initial commit: IT Nexus Web-App
This commit is contained in:
375
frontend/src/pages/AiPage.jsx
Normal file
375
frontend/src/pages/AiPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user