- WebSocket Shell/RDP: Rollenprüfung statt nur JWT-Gültigkeit (war: jeder eingeloggte User konnte fremde Agents per Shell/RDP übernehmen) - JWT_SECRET: Server bricht ab statt mit unsicherem Default weiterzulaufen - Auth: Token läuft jetzt über httpOnly-Cookie statt localStorage (XSS-Schutz gegen Session-Diebstahl) - WS-Auth: Token nicht mehr als URL-Query-Param (landete in nginx-Logs), sondern als erste Message bzw. automatisch via Cookie - Frontend: toter Rollen-Check (isSuperAdmin/isAdmin ohne Funktionsaufruf) in AgentDetailPage gefixt - XSS: DOMPurify-Sanitizing für alle marked.parse()-Renderstellen (KI-Antworten, Kommentare, Knowledge Base) - E-Mail: HTML-Escaping für alle ticket-gesteuerten Felder (auch über öffentliche Ticket-Route erreichbar) - SSRF-Schutz beim Knowledge-Base-URL-Import (blockt private/Loopback-Adressen) - TV-Dashboard: Shared-Key statt komplett offenem Endpoint - Striktes Rate-Limit auf /login, must_change_password serverseitig erzwungen - Agent (C#) v2.7.0: RDP-Consent/Disconnect/Capture verlangen jetzt ein Pro-Session-Secret (war: jeder lokale Prozess konnte Consent vortäuschen), DataDir-ACL für agent.log/status.json - FIDO-PINs AES-256-GCM-verschlüsselt statt Klartext, Retention-Job für alte patch_commands/audit_log Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
373 lines
22 KiB
JavaScript
373 lines
22 KiB
JavaScript
import React, { useState, useEffect, useRef } from 'react';
|
||
import { useAuth } from '../context/AuthContext';
|
||
import aiService from '../services/aiService';
|
||
import { toast } from 'react-toastify';
|
||
import { renderMd } from '../utils/sanitizeMarkdown';
|
||
|
||
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>
|
||
);
|
||
}
|