Quelltext des aktuellen Standes
/**
* KI-Assistent (Bubble + Vollbild /assistant)
*
* Dieselbe Logik treibt die schwebende Bubble und die Vollbild-Seite
* (erkannt über #aiAssistantPage). Je nach Datenschutz-Modus werden Daten vor
* der Übertragung an externe KI-APIs anonymisiert; Passwörter und Vault-
* Einträge werden in keinem Modus übertragen.
*/
(function () {
'use strict';
/** Oberflaechentext uebersetzen; `werte` fuellt die {name}-Stellen. */
function T(text, werte) {
return window.onitText ? window.onitText(text, werte) : text;
}
// =========================================================================
// Initialisierung
// =========================================================================
var panel = document.getElementById('aiChatPanel');
var bubble = document.getElementById('aiChatBubbleBtn');
var messages = document.getElementById('aiChatMessages');
var textarea = document.getElementById('aiChatTextarea');
var sendBtn = document.getElementById('aiChatSendBtn');
var clearBtn = document.getElementById('aiChatClearBtn');
var errorBar = document.getElementById('aiChatErrorBar');
var errorText = document.getElementById('aiChatErrorText');
var errorClose = document.getElementById('aiChatErrorClose');
var newSessionBtn = document.getElementById('aiChatNewSessionBtn');
var sessionDrawer = document.getElementById('aiChatSessionDrawer');
var sessionListEl = document.getElementById('aiChatSessionList');
// Vollbild-Modus: dieselbe Logik treibt sowohl die Bubble als auch die
// Seite /assistant. Im Vollbild gibt es keinen Bubble-Button; das Panel
// ist dauerhaft sichtbar.
var pageMode = !!document.getElementById('aiAssistantPage');
if (!panel) return; // KI-Chat nicht aktiv
var isOpen = false;
var isBusy = false;
var csrfToken = document.querySelector('meta[name="csrf-token"]');
csrfToken = csrfToken ? csrfToken.getAttribute('content') : '';
var useStream = true; // SSE-Streaming aktiviert
var pendingPreviewAction = null; // wird von handleAction() vor appendActionCard() gesetzt
var currentAbort = null; // AbortController der laufenden Generierung (Stopp-Button)
// =========================================================================
// Panel öffnen / schließen
// =========================================================================
if (bubble) {
bubble.addEventListener('click', function () {
if (isOpen) {
closePanel();
} else {
openPanel();
}
});
}
function openPanel() {
isOpen = true;
panel.classList.remove('hidden');
if (bubble) {
bubble.classList.add('chat-open');
bubble.setAttribute('aria-expanded', 'true');
var ico = bubble.querySelector('.ai-bubble-icon');
if (ico) ico.textContent = '✕';
}
if (textarea) textarea.focus();
loadHistory();
}
function closePanel() {
if (pageMode) return; // Vollbild lässt sich nicht schließen
isOpen = false;
panel.classList.add('hidden');
if (bubble) {
bubble.classList.remove('chat-open');
bubble.setAttribute('aria-expanded', 'false');
var ico = bubble.querySelector('.ai-bubble-icon');
if (ico) ico.textContent = '🤖';
}
}
// ESC schließt Panel (nur Bubble-Modus)
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && isOpen && !pageMode) closePanel();
});
// Vollbild: Panel direkt initialisieren (kein Bubble-Klick nötig)
if (pageMode) {
openPanel();
}
// =========================================================================
// Chat-History laden
// =========================================================================
function loadHistory() {
fetch('/api/ai-chat/history', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.json(); })
.then(function (data) {
if (!Array.isArray(data.messages) || data.messages.length === 0) return;
// Willkommens-Nachricht ausblenden
var welcome = document.getElementById('aiChatWelcome');
if (welcome) welcome.style.display = 'none';
// Nachrichten in umgekehrter Reihenfolge (DESC aus DB)
var sorted = data.messages.slice().reverse();
sorted.forEach(function (msg) {
var el = appendMessage(msg.role, msg.content, false, msg.created_at);
// Quellen & Feedback aus den gespeicherten Metadaten rehydrieren
if (msg.role === 'assistant') {
var meta = msg.metadata || {};
if (msg.id) appendFeedbackBar(el, msg.id);
if (meta.source_pages && meta.source_pages.length) {
appendSourcePages(el, meta.source_pages);
}
}
});
scrollToBottom();
})
.catch(function () { /* Fehler beim Laden ignorieren */ });
}
// =========================================================================
// Nachricht senden
// =========================================================================
function sendMessage() {
if (isBusy) return;
var text = textarea ? textarea.value.trim() : '';
if (!text) return;
hideError();
setBusy(true);
currentAbort = (typeof AbortController !== 'undefined') ? new AbortController() : null;
// Willkommen ausblenden
var welcome = document.getElementById('aiChatWelcome');
if (welcome) welcome.style.display = 'none';
// Benutzer-Nachricht anzeigen
appendMessage('user', text, true, new Date());
// Textarea zurücksetzen
if (textarea) {
textarea.value = '';
textarea.style.height = 'auto';
}
// Tipp-Indikator
var typingId = appendTyping();
var formData = new FormData();
formData.append('message', text);
formData.append('_csrf', csrfToken);
if (useStream && typeof ReadableStream !== 'undefined') {
// ── Streaming via Fetch + SSE ──────────────────────────────────────
var assistantEl = null;
var streamBuffer = '';
fetch('/api/ai-chat/stream', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: formData,
signal: currentAbort ? currentAbort.signal : undefined
})
.then(function (r) {
if (!r.ok || !r.body) throw new Error('stream_unavailable');
removeTyping(typingId);
assistantEl = appendStreamingMessage();
var reader = r.body.getReader();
var decoder = new TextDecoder();
function readChunk() {
return reader.read().then(function (result) {
if (result.done) return;
streamBuffer += decoder.decode(result.value, { stream: true });
var lines = streamBuffer.split('\n');
streamBuffer = lines.pop(); // letzte (unvollständige) Zeile aufheben
lines.forEach(function (line) {
if (!line.startsWith('data: ')) return;
var raw = line.slice(6).trim();
if (!raw) return;
try {
var evt = JSON.parse(raw);
if (evt.type === 'chunk') {
appendStreamChunk(assistantEl, evt.text);
} else if (evt.type === 'tool') {
showToolStatus(assistantEl, evt.label);
} else if (evt.type === 'done') {
finalizeStreamingMessage(assistantEl, evt.full_text || null);
appendFeedbackBar(assistantEl, evt.message_id);
if (evt.source_pages && evt.source_pages.length) {
appendSourcePages(assistantEl, evt.source_pages);
}
if (evt.suggestions && evt.suggestions.length) {
appendSuggestions(evt.suggestions);
}
var actionList = evt.actions && evt.actions.length ? evt.actions : (evt.action ? [evt.action] : []);
actionList.forEach(function (a) { handleAction(a); });
} else if (evt.type === 'error') {
showError(evt.message || 'KI-Fehler');
}
} catch (e) { /* JSON parse error – ignore */ }
});
return readChunk();
});
}
return readChunk();
})
.catch(function (err) {
// Vom Nutzer gestoppt → kein Fallback, vorhandenen Text behalten
if (err && err.name === 'AbortError') {
removeTyping(typingId);
if (assistantEl) finalizeStreamingMessage(assistantEl, null);
return;
}
// Fallback auf normales JSON-Send bei Streaming-Fehler
if (assistantEl) assistantEl.remove();
if (!typingId || !document.getElementById(typingId)) {
typingId = appendTyping();
}
return fetch('/api/ai-chat/send', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: formData,
signal: currentAbort ? currentAbort.signal : undefined
})
.then(function (r) { return r.json(); })
.then(function (data) {
removeTyping(typingId);
if (data.error) { showError(data.error); return; }
var msgEl = appendMessage('assistant', data.message, true, new Date());
appendFeedbackBar(msgEl, data.message_id);
if (data.source_pages && data.source_pages.length) appendSourcePages(msgEl, data.source_pages);
if (data.suggestions && data.suggestions.length) appendSuggestions(data.suggestions);
var al = data.actions && data.actions.length ? data.actions : (data.action ? [data.action] : []);
al.forEach(function (a) { handleAction(a); });
})
.catch(function () { removeTyping(typingId); showError('Netzwerkfehler. Bitte erneut versuchen.'); });
})
.finally(function () {
setBusy(false);
currentAbort = null;
if (textarea) textarea.focus();
});
} else {
// ── Fallback: normales JSON-Request ────────────────────────────────
fetch('/api/ai-chat/send', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: formData,
signal: currentAbort ? currentAbort.signal : undefined
})
.then(function (r) { return r.json(); })
.then(function (data) {
removeTyping(typingId);
if (data.error) { showError(data.error); return; }
var msgEl = appendMessage('assistant', data.message, true, new Date());
appendFeedbackBar(msgEl, data.message_id);
if (data.source_pages && data.source_pages.length) appendSourcePages(msgEl, data.source_pages);
if (data.suggestions && data.suggestions.length) appendSuggestions(data.suggestions);
var actionList = data.actions && data.actions.length ? data.actions : (data.action ? [data.action] : []);
actionList.forEach(function (a) { handleAction(a); });
})
.catch(function () {
removeTyping(typingId);
showError('Netzwerkfehler. Bitte erneut versuchen.');
})
.finally(function () {
setBusy(false);
currentAbort = null;
if (textarea) textarea.focus();
});
}
}
// Senden per Button (während der Generierung = Stopp-Button)
if (sendBtn) {
sendBtn.addEventListener('click', function () {
if (isBusy) { stopGeneration(); } else { sendMessage(); }
});
}
/** Schaltet den Sende-Button zwischen Senden- und Stopp-Modus um. */
function setBusy(busy) {
isBusy = busy;
if (!sendBtn) return;
if (busy) {
sendBtn.classList.add('ai-sending');
sendBtn.dataset.prev = sendBtn.innerHTML;
sendBtn.innerHTML = '⏹';
sendBtn.title = 'Antwort stoppen';
} else {
sendBtn.classList.remove('ai-sending');
if (sendBtn.dataset.prev) { sendBtn.innerHTML = sendBtn.dataset.prev; delete sendBtn.dataset.prev; }
sendBtn.title = 'Senden';
}
}
/** Bricht die laufende Generierung ab. */
function stopGeneration() {
if (currentAbort) {
try { currentAbort.abort(); } catch (e) { /* ignore */ }
}
}
// Senden per Enter (Shift+Enter = Zeilenumbruch)
if (textarea) {
textarea.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// Textarea auto-resize
textarea.addEventListener('input', function () {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 96) + 'px';
});
}
// =========================================================================
// Chat löschen
// =========================================================================
if (clearBtn) {
clearBtn.addEventListener('click', function () {
if (!confirm(T('Chat-Verlauf löschen?'))) return;
var fd = new FormData();
fd.append('_csrf', csrfToken);
fetch('/api/ai-chat/clear', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: fd
})
.then(function () {
messages.innerHTML = '';
appendWelcome();
})
.catch(function () {});
});
}
// =========================================================================
// Hint-Buttons (Vorschläge)
// =========================================================================
document.querySelectorAll('.ai-chat-hint-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var text = this.getAttribute('data-hint');
if (textarea && text) {
textarea.value = text;
textarea.focus();
}
});
});
// =========================================================================
// Nachrichten rendern
// =========================================================================
function appendMessage(role, content, scroll, ts) {
scroll = scroll !== false;
var el = document.createElement('div');
el.className = 'ai-msg ai-msg-' + role;
var bubble = document.createElement('div');
bubble.className = 'ai-msg-bubble';
bubble.innerHTML = formatMessage(content);
var meta = document.createElement('div');
meta.className = 'ai-msg-meta';
var label = role === 'user' ? 'Du' : role === 'system' ? 'System' : 'KI-Assistent';
var time = formatTime(ts);
meta.textContent = time ? (label + ' · ' + time) : label;
// Kopier-Button für KI-Antworten
if (role === 'assistant') {
var copyBtn = document.createElement('button');
copyBtn.className = 'ai-msg-copy';
copyBtn.type = 'button';
copyBtn.title = 'Antwort kopieren';
copyBtn.setAttribute('aria-label', 'Antwort kopieren');
copyBtn.textContent = '⧉';
copyBtn.addEventListener('click', function () {
copyText(bubble.innerText, copyBtn);
});
meta.appendChild(copyBtn);
}
// Bearbeiten/erneut senden für eigene Nachrichten
if (role === 'user') {
var editBtn = document.createElement('button');
editBtn.className = 'ai-msg-copy';
editBtn.type = 'button';
editBtn.title = 'Bearbeiten & erneut senden';
editBtn.setAttribute('aria-label', 'Bearbeiten');
editBtn.textContent = '✎';
editBtn.addEventListener('click', function () {
if (isBusy || !textarea) return;
textarea.value = content;
textarea.focus();
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 96) + 'px';
});
meta.appendChild(editBtn);
}
el.appendChild(bubble);
el.appendChild(meta);
messages.appendChild(el);
if (scroll) scrollToBottom();
return el;
}
function appendSuggestions(options) {
var el = document.createElement('div');
el.className = 'ai-suggestions';
options.forEach(function (opt) {
var btn = document.createElement('button');
btn.className = 'ai-suggestion-btn';
btn.textContent = opt;
// Inline fallback: garantiert lesbare Farben unabhängig vom CSS-Cache
btn.style.cssText = 'background:#eaf4fb;color:#1a5276;border:1.5px solid #3c8dbc;border-radius:0.5rem;padding:0.4rem 0.85rem;font-size:0.82rem;cursor:pointer;display:block;width:100%;text-align:left;font-family:inherit;line-height:1.4;';
btn.addEventListener('click', function () {
// Vorschläge entfernen, sobald einer angeklickt wird
el.remove();
if (textarea) {
textarea.value = opt;
}
sendMessage();
});
el.appendChild(btn);
});
messages.appendChild(el);
scrollToBottom();
}
/**
* Hängt eine 👍/👎-Leiste an ein Assistenten-Nachricht-Element.
* msgId = 0 oder undefined → keine Buttons (z.B. bei History-Nachrichten).
*/
function appendFeedbackBar(msgEl, msgId) {
if (!msgId || !msgEl) return;
var bar = document.createElement('div');
bar.className = 'ai-feedback-bar';
[{ label: '👍', val: 1 }, { label: '👎', val: -1 }].forEach(function (item) {
var btn = document.createElement('button');
btn.className = 'ai-feedback-btn';
btn.textContent = item.label;
btn.title = item.val === 1 ? 'Hilfreich' : 'Nicht hilfreich';
btn.addEventListener('click', function () {
if (bar.dataset.voted) return; // nur einmal abstimmen
bar.dataset.voted = '1';
bar.querySelectorAll('.ai-feedback-btn').forEach(function (b) {
b.disabled = true;
b.style.opacity = b === btn ? '1' : '0.35';
});
btn.classList.add('ai-feedback-btn--active');
var fd = new FormData();
fd.append('message_id', msgId);
fd.append('rating', item.val);
fd.append('_csrf', csrfToken);
fetch('/api/ai-chat/feedback', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: fd
}).catch(function () {});
});
bar.appendChild(btn);
});
msgEl.appendChild(bar);
}
/**
* Hängt eine Quellenangaben-Leiste mit klickbaren Seiten-Links an eine Nachricht.
*/
function appendSourcePages(msgEl, pages) {
if (!pages || !pages.length || !msgEl) return;
var details = document.createElement('details');
details.className = 'ai-source-details';
var summary = document.createElement('summary');
summary.className = 'ai-source-summary';
summary.textContent = 'Quellen (' + pages.length + ')';
details.appendChild(summary);
var bar = document.createElement('div');
bar.className = 'ai-source-bar';
pages.forEach(function (p) {
var a = document.createElement('a');
a.className = 'ai-source-link';
// Doku-Treffer verlinken auf die Seite (/pages/{id}), Modul-Quellen
// (VMs, Inventar …) bringen eine fertige Ziel-URL mit.
a.href = p.url ? p.url : ('/pages/' + p.id);
a.textContent = p.title;
a.title = p.topic || '';
a.target = '_self';
bar.appendChild(a);
});
details.appendChild(bar);
msgEl.appendChild(details);
}
function appendTyping() {
var id = 'typing-' + Date.now();
var el = document.createElement('div');
el.className = 'ai-msg ai-msg-assistant ai-msg-typing';
el.id = id;
el.innerHTML = '<div class="ai-msg-bubble"><div class="ai-typing-dot"></div><div class="ai-typing-dot"></div><div class="ai-typing-dot"></div></div>';
messages.appendChild(el);
scrollToBottom();
return id;
}
function removeTyping(id) {
var el = document.getElementById(id);
if (el) el.remove();
}
// =========================================================================
// Streaming Nachrichten
// =========================================================================
/** Erstellt ein leeres KI-Nachrichten-Element für Streaming */
function appendStreamingMessage() {
var el = document.createElement('div');
el.className = 'ai-msg ai-msg-assistant ai-msg-streaming';
var bubble = document.createElement('div');
bubble.className = 'ai-msg-bubble';
bubble.setAttribute('data-raw', ''); // Rohtext für spätere Formatierung
var cursor = document.createElement('span');
cursor.className = 'ai-stream-cursor';
cursor.textContent = '▋';
bubble.appendChild(cursor);
var meta = document.createElement('div');
meta.className = 'ai-msg-meta';
meta.textContent = 'KI-Assistent';
el.appendChild(bubble);
el.appendChild(meta);
messages.appendChild(el);
scrollToBottom();
return el;
}
/** Zeigt während der Tool-Ausführung eine Fortschritts-Zeile im Stream-Element. */
function showToolStatus(el, label) {
if (!el) return;
var bubble = el.querySelector('.ai-msg-bubble');
if (!bubble) return;
var status = bubble.querySelector('.ai-tool-status');
if (!status) {
status = document.createElement('div');
status.className = 'ai-tool-status';
var cursor = bubble.querySelector('.ai-stream-cursor');
bubble.insertBefore(status, cursor || null);
}
status.textContent = '🔍 ' + (label || T('Suche Daten')) + ' …';
scrollToBottom();
}
/** Fügt einen Text-Chunk zum streaming Element hinzu */
function appendStreamChunk(el, chunk) {
var bubble = el.querySelector('.ai-msg-bubble');
if (!bubble) return;
// Sobald echter Text kommt, Tool-Status entfernen
var ts = bubble.querySelector('.ai-tool-status');
if (ts) ts.remove();
var raw = (bubble.getAttribute('data-raw') || '') + chunk;
bubble.setAttribute('data-raw', raw);
// Live-Darstellung: einfaches Escaping + Cursor
var cursor = bubble.querySelector('.ai-stream-cursor');
var textNode = bubble.querySelector('.ai-stream-text');
if (!textNode) {
textNode = document.createElement('span');
textNode.className = 'ai-stream-text';
bubble.insertBefore(textNode, cursor);
}
textNode.textContent = raw;
scrollToBottom();
}
/** Konvertiert nach Streaming-Ende zur formatierten Darstellung */
function finalizeStreamingMessage(el, deanonymizedText) {
var bubble = el.querySelector('.ai-msg-bubble');
if (!bubble) return;
// Bevorzuge den vom Server de-anonymisierten Text, sonst Fallback auf Roh-Chunks
var raw = deanonymizedText || bubble.getAttribute('data-raw') || '';
el.classList.remove('ai-msg-streaming');
bubble.removeAttribute('data-raw');
bubble.innerHTML = formatMessage(raw);
scrollToBottom();
}
// =========================================================================
// Session Management
// =========================================================================
/** Lädt und zeigt das Session-Drawer-Menü */
function loadSessions() {
if (!sessionListEl) return;
fetch('/api/ai-chat/sessions', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.json(); })
.then(function (data) {
renderSessionList(data.sessions || [], data.active_id);
})
.catch(function () {});
}
function renderSessionList(sessions, activeId) {
if (!sessionListEl) return;
sessionListEl.innerHTML = '';
if (sessions.length === 0) {
sessionListEl.innerHTML = '<div style="padding:0.5rem;color:#9ca3af;font-size:0.8rem">' + T('Keine früheren Sitzungen') + '</div>';
return;
}
sessions.forEach(function (s) {
var item = document.createElement('div');
item.className = 'ai-session-item' + (s.id == activeId ? ' active' : '');
var label = s.title || (s.last_user_message
? s.last_user_message.substring(0, 40) + (s.last_user_message.length > 40 ? '…' : '')
: T('Neue Sitzung'));
var dateStr = s.updated_at ? new Date(s.updated_at).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit'}) : '';
item.innerHTML =
'<div class="ai-session-label" title="' + escHtml(label) + '">' + escHtml(label) + '</div>' +
'<div class="ai-session-meta">' + escHtml(dateStr) + '</div>' +
'<button type="button" class="ai-session-del" title="' + T('Löschen') + '" data-id="' + s.id + '">×</button>';
item.querySelector('.ai-session-label').addEventListener('click', function () {
if (s.id == activeId) { closeSessionDrawer(); return; }
switchToSession(s.id);
});
item.querySelector('.ai-session-del').addEventListener('click', function (e) {
e.stopPropagation();
if (!confirm(T('Diese Sitzung löschen?'))) return;
deleteSession(s.id, sessions, activeId);
});
sessionListEl.appendChild(item);
});
}
function switchToSession(sessionId) {
var fd = new FormData();
fd.append('session_id', sessionId);
fd.append('_csrf', csrfToken);
fetch('/api/ai-chat/sessions/switch', {
method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.success) {
messages.innerHTML = '';
loadHistory();
closeSessionDrawer();
}
})
.catch(function () {});
}
function deleteSession(sessionId, sessions, activeId) {
var fd = new FormData();
fd.append('session_id', sessionId);
fd.append('_csrf', csrfToken);
fetch('/api/ai-chat/sessions/delete', {
method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.success) {
loadSessions();
// Falls die aktive Session gelöscht wurde → History neu laden
if (sessionId == activeId) {
messages.innerHTML = '';
appendWelcome();
loadHistory();
}
}
})
.catch(function () {});
}
function openSessionDrawer() {
if (!sessionDrawer) return;
loadSessions();
sessionDrawer.classList.remove('hidden');
sessionDrawer.classList.add('visible');
}
function closeSessionDrawer() {
if (!sessionDrawer) return;
sessionDrawer.classList.remove('visible');
sessionDrawer.classList.add('hidden');
}
// Neue Sitzung anlegen
if (newSessionBtn) {
newSessionBtn.addEventListener('click', function () {
var fd = new FormData();
fd.append('_csrf', csrfToken);
fetch('/api/ai-chat/sessions/new', {
method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.success) {
messages.innerHTML = '';
appendWelcome();
closeSessionDrawer();
}
})
.catch(function () {});
});
}
// Sitzungs-Drawer per Klick auf Header-Bereich öffnen/schließen
var sessionToggleBtn = document.getElementById('aiChatSessionToggleBtn');
if (sessionToggleBtn) {
sessionToggleBtn.addEventListener('click', function () {
if (sessionDrawer && sessionDrawer.classList.contains('visible')) {
closeSessionDrawer();
} else {
openSessionDrawer();
}
});
}
// Außerhalb des Drawers klicken schließt ihn
document.addEventListener('click', function (e) {
if (sessionDrawer && sessionDrawer.classList.contains('visible')) {
if (!sessionDrawer.contains(e.target) && e.target !== sessionToggleBtn) {
closeSessionDrawer();
}
}
});
function appendWelcome() {
var welcome = document.createElement('div');
welcome.id = 'aiChatWelcome';
welcome.className = 'ai-chat-welcome';
welcome.innerHTML = '<span class="ai-chat-welcome-icon">🤖</span>' +
'<h3>' + T('Hallo! Wie kann ich helfen?') + '</h3>' +
'<p>' + T('Ich kenne alle Inhalte dieses IT-Tools. Frag mich zu Assets, Tickets, Verträgen, VMs und mehr.') + '</p>';
messages.appendChild(welcome);
}
// =========================================================================
// Aktionen (Bestellung anlegen, Vertrag anlegen, etc.)
// =========================================================================
function handleAction(action) {
var actionType = action.action;
// Für die Dry-Run-Vorschau: nächste appendActionCard()-Karte gehört zu dieser Aktion
pendingPreviewAction = action;
if (actionType === 'create_order') {
appendActionCard({
icon: '🛒',
title: T('Bestellung anlegen'),
details: buildOrderDetails(action),
confirmLabel: T('Jetzt anlegen'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('create_order', action, cardEl);
}
});
} else if (actionType === 'create_contract') {
appendActionCard({
icon: '📄',
title: T('Vertrag anlegen'),
details: buildContractDetails(action),
confirmLabel: T('Jetzt anlegen'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('create_contract', action, cardEl);
}
});
} else if (actionType === 'create_company') {
appendActionCard({
icon: '🏢',
title: T('Firma anlegen'),
details: T('Firma „{name}" im Adressbuch anlegen.', { name: escHtml(action.name) }),
confirmLabel: T('Firma anlegen'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('create_company', action, cardEl);
}
});
} else if (actionType === 'update_order') {
// Status-Bezeichnungen wie im Bestellmodul (delivered = eingetroffen,
// abholbereit ist ein eigener Status).
var statusLabels = { open: T('Offen'), delivered: T('Eingetroffen'), abholbereit: T('Abholbereit'), closed: T('Abgeschlossen') };
var orderDetails = T('Bestellung #{nummer} auf „{status}" setzen.', { nummer: escHtml(String(action.order_id)), status: escHtml(statusLabels[action.status] || action.status) });
if (action.delivery_method) orderDetails += '<br>' + T('<strong>Lieferart:</strong> {wert}', { wert: escHtml(action.delivery_method) });
if (action.delivery_note) orderDetails += '<br>' + T('<strong>Notiz:</strong> {wert}', { wert: escHtml(action.delivery_note) });
appendActionCard({
icon: '✏️',
title: T('Bestellstatus ändern'),
details: orderDetails,
confirmLabel: T('Status ändern'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('update_order', action, cardEl);
}
});
} else if (actionType === 'check_order_component') {
var compDetails = action.component_id
? T('Position [ID {id}] wird als erhalten abgehakt.', { id: escHtml(String(action.component_id)) })
: T('Position „{name}" der Bestellung #{nummer} wird als erhalten abgehakt.', { name: escHtml(action.component_name || '?'), nummer: escHtml(String(action.order_id || '?')) });
appendActionCard({
icon: '✅',
title: T('Bestellposition abhaken'),
details: compDetails,
confirmLabel: T('Abhaken'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('check_order_component', action, cardEl);
}
});
} else if (actionType === 'add_asset_note') {
appendActionCard({
icon: '📝',
title: T('Asset-Notiz ergänzen'),
details: T('<strong>Asset:</strong> {asset}<br><strong>Notiz:</strong> {notiz}', { asset: escHtml(action.inventory_number || '?'), notiz: escHtml(action.note || '') }),
confirmLabel: T('Notiz speichern'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('add_asset_note', action, cardEl);
}
});
} else if (actionType === 'create_ticket') {
appendActionCard({
icon: '🎫',
title: T('Ticket anlegen'),
details: '„' + escHtml(action.title) + '"' + (action.description ? '<br><small style="color:#6b7280;">' + escHtml(action.description) + '</small>' : ''),
confirmLabel: T('Ticket anlegen'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('create_ticket', action, cardEl);
}
});
} else if (actionType === 'create_todo') {
appendActionCard({
icon: '✅',
title: T('To-Do anlegen'),
details: T('„{titel}" zu deiner To-Do-Liste hinzufügen.', { titel: escHtml(action.title) }),
confirmLabel: T('Hinzufügen'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('create_todo', action, cardEl);
}
});
} else if (actionType === 'reserve_loan') {
var deviceLabel = action.device_name
? escHtml(action.device_name) + ' (ID: ' + escHtml(String(action.device_id)) + ')'
: T('Gerät-ID: {id}', { id: escHtml(String(action.device_id)) });
var loanDetails = T('Person: {person}', { person: escHtml(action.person_name) })
+ '<br>' + T('Gerät: {geraet}', { geraet: deviceLabel })
+ '<br>' + T('Zeitraum: {von} – {bis}', { von: escHtml(action.date_from), bis: escHtml(action.date_to) })
+ (action.purpose ? '<br>' + T('Zweck: {zweck}', { zweck: escHtml(action.purpose) }) : '');
appendActionCard({
icon: '💻',
title: T('Leihgerät reservieren'),
details: loanDetails,
confirmLabel: T('Reservieren'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('reserve_loan', action, cardEl);
}
});
} else if (actionType === 'return_loan') {
appendActionCard({
icon: '↩️',
title: T('Leihgerät zurückgeben'),
details: T('Leihvorgang #{nummer} als zurückgegeben markieren.', { nummer: escHtml(String(action.lending_id)) }),
confirmLabel: T('Zurückgeben'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('return_loan', action, cardEl);
}
});
} else if (actionType === 'update_ticket') {
var statusLabelsT = { new: T('Neu'), open: T('Offen'), waiting: T('Wartend'), solved: T('Gelöst'), closed: T('Geschlossen') };
var ticketDetails = T('Ticket #{nummer}', { nummer: escHtml(String(action.ticket_id)) });
if (action.status) ticketDetails += T(' → Status: „{status}"', { status: escHtml(statusLabelsT[action.status] || action.status) });
if (action.reply) ticketDetails += '<br><small style="color:#6b7280;">' + T('Notiz: {notiz}', { notiz: escHtml(action.reply) }) + '</small>';
appendActionCard({
icon: '🎫',
title: T('Ticket aktualisieren'),
details: ticketDetails,
confirmLabel: T('Aktualisieren'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('update_ticket', action, cardEl);
}
});
} else if (actionType === 'create_kanban_task') {
var prioLabels = { low: T('Niedrig'), normal: T('Normal'), high: T('Hoch'), urgent: T('Dringend') };
var kanbanDetails = '„' + escHtml(action.title) + '"'
+ (action.priority ? T(' [Priorität: {prio}]', { prio: escHtml(prioLabels[action.priority] || action.priority) }) : '')
+ (action.due_date ? '<br>' + T('Fällig: {datum}', { datum: escHtml(action.due_date) }) : '');
appendActionCard({
icon: '📋',
title: T('Kanban-Aufgabe anlegen'),
details: kanbanDetails,
confirmLabel: T('Aufgabe anlegen'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('create_kanban_task', action, cardEl);
}
});
} else if (actionType === 'create_maintenance') {
var typeLabels = { maintenance: T('Wartung'), review: T('Review'), renewal: T('Verlängerung'), other: T('Sonstiges') };
var maintDetails = '„' + escHtml(action.title) + '"'
+ '<br>' + T('Datum: {datum}', { datum: escHtml(action.event_date) })
+ (action.end_date ? ' – ' + escHtml(action.end_date) : '')
+ (action.type ? ' [' + escHtml(typeLabels[action.type] || action.type) + ']' : '');
appendActionCard({
icon: '🔧',
title: T('Wartungstermin anlegen'),
details: maintDetails,
confirmLabel: T('Termin anlegen'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('create_maintenance', action, cardEl);
}
});
} else if (actionType === 'create_contact') {
appendActionCard({
icon: '👤',
title: T('Kontakt anlegen'),
details: escHtml((action.first_name || '') + ' ' + (action.last_name || '')).trim()
+ (action.company_name ? T(' bei „{firma}"', { firma: escHtml(action.company_name) }) : '')
+ (action.position ? '<br>' + escHtml(action.position) : ''),
confirmLabel: T('Kontakt anlegen'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('create_contact', action, cardEl);
}
});
} else if (actionType === 'update_asset') {
var assetStatusLabels = { active: T('Aktiv'), storage: T('Lager'), decommissioned: T('Ausgemustert') };
appendActionCard({
icon: '🖥️',
title: T('Asset-Status ändern'),
details: T('Asset „{asset}" → „{status}"', { asset: escHtml(action.inventory_number), status: escHtml(assetStatusLabels[action.status] || action.status) }),
confirmLabel: T('Status ändern'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('update_asset', action, cardEl);
}
});
} else if (actionType === 'create_license') {
var licDetails = '„' + escHtml(action.name) + '"'
+ (action.vendor ? T(' von {hersteller}', { hersteller: escHtml(action.vendor) }) : '')
+ (action.seats ? T(' – {anzahl} Seats', { anzahl: escHtml(String(action.seats)) }) : '')
+ (action.valid_until ? '<br>' + T('Gültig bis: {datum}', { datum: escHtml(action.valid_until) }) : '');
appendActionCard({
icon: '🔑',
title: T('Lizenz anlegen'),
details: licDetails,
confirmLabel: T('Lizenz anlegen'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('create_license', action, cardEl);
}
});
} else if (actionType === 'update_stock') {
var stockDir = action.quantity_delta > 0 ? '+' + action.quantity_delta : String(action.quantity_delta);
appendActionCard({
icon: '📦',
title: T('Lagerbestand ändern'),
details: 'Artikel #' + escHtml(String(action.item_id)) + ' um ' + escHtml(stockDir) + ' anpassen'
+ (action.note ? '<br>Grund: ' + escHtml(action.note) : ''),
confirmLabel: T('Bestand ändern'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('update_stock', action, cardEl);
}
});
} else if (actionType === 'add_ip') {
appendActionCard({
icon: '🌐',
title: T('IP-Adresse eintragen'),
details: escHtml(action.ip_address)
+ (action.hostname ? ' (' + escHtml(action.hostname) + ')' : '')
+ '<br>Subnetz-ID: ' + escHtml(String(action.subnet_id))
+ (action.type ? ' [Typ: ' + escHtml(action.type) + ']' : ''),
confirmLabel: T('IP eintragen'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('add_ip', action, cardEl);
}
});
} else if (actionType === 'create_page') {
var sectionCount = Array.isArray(action.sections) ? action.sections.length : 0;
var pageDetails = '„' + escHtml(action.title) + '"'
+ (action.topic_id ? '<br>Topic-ID: ' + escHtml(String(action.topic_id)) : '')
+ (sectionCount ? '<br>' + sectionCount + ' Abschnitte' : '');
if (Array.isArray(action.sections) && action.sections.length > 0) {
pageDetails += '<ul style="margin:0.25rem 0 0 1rem;font-size:0.78rem;color:#6b7280">';
action.sections.forEach(function (s) {
if (s.h) pageDetails += '<li>' + escHtml(s.h) + '</li>';
});
pageDetails += '</ul>';
}
appendActionCard({
icon: '📝',
title: T('Dokumentationsseite anlegen'),
details: pageDetails,
confirmLabel: T('Seite anlegen'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('create_page', action, cardEl);
}
});
} else if (actionType === 'update_page') {
var modeLabel = action.mode === 'replace' ? T('ersetzen') : T('erweitern');
var upPageDetails = T('Seite #{nummer} – Inhalt {modus}', { nummer: escHtml(String(action.page_id)), modus: modeLabel });
if (action.mode === 'replace') {
upPageDetails += '<div style="margin-top:0.3rem;padding:0.3rem 0.5rem;background:#fef3c7;border-left:3px solid #f59e0b;border-radius:0.25rem;font-size:0.78rem;color:#92400e">' +
T('⚠️ <strong>Achtung:</strong> Der gesamte bisherige Seiteninhalt wird unwiderruflich überschrieben!') +
'</div>';
}
if (Array.isArray(action.sections) && action.sections.length > 0) {
upPageDetails += '<ul style="margin:0.25rem 0 0 1rem;font-size:0.78rem;color:#6b7280">';
action.sections.forEach(function (s) {
if (s.h) upPageDetails += '<li>' + escHtml(s.h) + '</li>';
});
upPageDetails += '</ul>';
}
appendActionCard({
icon: '✏️',
title: T('Dokumentationsseite aktualisieren'),
details: upPageDetails,
confirmLabel: action.mode === 'replace' ? T('Inhalt ersetzen') : T('Abschnitt hinzufügen'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('update_page', action, cardEl);
}
});
} else if (actionType === 'complete_todo') {
appendActionCard({
icon: '✅',
title: T('To-Do erledigen'),
details: '„' + escHtml(action.title) + '" als erledigt markieren.',
confirmLabel: T('Erledigen'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('complete_todo', action, cardEl);
}
});
} else if (actionType === 'update_kanban_task') {
var prioLabelsU = { low: T('Niedrig'), normal: T('Normal'), high: T('Hoch'), urgent: T('Dringend') };
var ktDetails = T('Aufgabe #{nummer}', { nummer: escHtml(String(action.task_id)) });
if (action.priority) ktDetails += T(' → Priorität: „{prio}"', { prio: escHtml(prioLabelsU[action.priority] || action.priority) });
if (action.status) ktDetails += ' → Status: „' + escHtml(action.status) + '"';
appendActionCard({
icon: '📋',
title: T('Kanban-Aufgabe ändern'),
details: ktDetails,
confirmLabel: T('Ändern'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('update_kanban_task', action, cardEl);
}
});
} else if (actionType === 'assign_asset') {
appendActionCard({
icon: '🖥️',
title: T('Asset zuweisen'),
details: T('Asset „{asset}" → {person}', { asset: escHtml(action.inventory_number), person: escHtml(action.person_name) }),
confirmLabel: T('Zuweisen'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('assign_asset', action, cardEl);
}
});
} else if (actionType === 'start_loan') {
appendActionCard({
icon: '📤',
title: T('Gerät ausgeben'),
details: T('Reservierung #{nummer} aktivieren und Unterschrift einholen.', { nummer: escHtml(String(action.lending_id)) }),
confirmLabel: T('Ausgeben & Unterschrift anfordern'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('start_loan', action, cardEl);
}
});
} else if (actionType === 'update_ip') {
var ipDetails = escHtml(action.ip_address || ('IP #' + action.ip_id));
if (action.status) ipDetails += ' → Status: „' + escHtml(action.status) + '"';
if (action.hostname) ipDetails += '<br>Hostname: ' + escHtml(action.hostname);
appendActionCard({
icon: '🌐',
title: T('IP-Eintrag aktualisieren'),
details: ipDetails,
confirmLabel: T('Aktualisieren'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('update_ip', action, cardEl);
}
});
} else if (actionType === 'update_vm_status') {
var vmLabel = action.status === 'running' ? T('▶ Starten') : T('⏹ Stoppen');
var vmIcon = action.status === 'running' ? '🟢' : '🔴';
appendActionCard({
icon: '🖥️',
title: 'VM ' + (action.status === 'running' ? 'starten' : 'stoppen'),
details: T('VM #{nummer} → Status: „{status}"', { nummer: escHtml(String(action.vm_id)), status: escHtml(action.status) }),
confirmLabel: vmLabel,
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('update_vm_status', action, cardEl);
}
});
} else if (actionType === 'assign_ticket') {
appendActionCard({
icon: '🎫',
title: T('Ticket zuweisen'),
details: T('Ticket #{nummer} → {person}', { nummer: escHtml(String(action.ticket_id)), person: escHtml(action.person_name) }),
confirmLabel: T('Zuweisen'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('assign_ticket', action, cardEl);
}
});
} else if (actionType === 'start_asset_handover') {
appendActionCard({
icon: '📦',
title: T('Asset-Übergabe starten'),
details: T('Asset „{asset}" wird ausgegeben an: {person}', { asset: escHtml(action.inventory_number), person: escHtml(action.person_name) }),
confirmLabel: T('Übergabe bestätigen'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction('start_asset_handover', action, cardEl);
}
});
} else {
// Generische Karte für Aktionstypen ohne eigene Darstellung: zeigt
// ALLE Felder des Vorschlags, damit keine Aktion unsichtbar bleibt
// und der Nutzer immer gegen die vollständigen Daten bestätigt.
var genericRows = [];
Object.keys(action).forEach(function (k) {
if (k === 'action' || k === '_confirmed') return;
var v = action[k];
if (v === null || v === undefined || v === '') return;
if (typeof v === 'object') v = JSON.stringify(v);
genericRows.push('<strong>' + escHtml(k) + ':</strong> ' + escHtml(String(v)));
});
appendActionCard({
icon: '⚙️',
title: T('Aktion: {typ}', { typ: escHtml(actionType) }),
details: genericRows.join('<br>') || T('Keine Zusatzdaten.'),
confirmLabel: T('Ausführen'),
cancelLabel: T('Abbrechen'),
onConfirm: function (cardEl) {
executeAction(actionType, action, cardEl);
}
});
}
}
function executeAction(actionType, actionData, cardEl) {
var fd = new FormData();
fd.append('action_type', actionType);
fd.append('action_data', JSON.stringify(actionData));
fd.append('_csrf', csrfToken);
// Karte deaktivieren während Ausführung
cardEl.querySelectorAll('button').forEach(function (b) { b.disabled = true; });
fetch('/api/ai-chat/action', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: fd
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.needs_confirmation && data.type === 'company_not_found') {
// Firma nicht gefunden – Rückfrage anzeigen
cardEl.remove();
appendConfirmCard({
text: data.message,
yesLabel: 'Firma anlegen & Vertrag erstellen',
noLabel: 'Abbrechen',
onYes: function (confirmEl) {
var mergedData = Object.assign({}, data.contract_data, {
action: 'create_company',
name: data.company_name,
contract_data: data.contract_data
});
confirmEl.remove();
executeAction('create_company', mergedData, document.createElement('div'));
}
});
return;
}
if (data.needs_confirmation && data.type === 'destructive_action') {
// Destruktive Aktion – zweite Bestätigung mit Warnung
cardEl.remove();
appendConfirmCard({
text: data.message,
warning: true,
yesLabel: 'Ja, fortfahren',
noLabel: 'Abbrechen',
onYes: function (confirmEl) {
confirmEl.remove();
var confirmedData = Object.assign({}, data.action_data, { _confirmed: true });
executeAction(data.action_type, confirmedData, document.createElement('div'));
}
});
return;
}
if (data.error) {
showError(data.error);
cardEl.querySelectorAll('button').forEach(function (b) { b.disabled = false; });
return;
}
if (data.success && data.needs_signature) {
// Gerät ausgegeben – auf Unterschrift warten
cardEl.remove();
showSignaturePending(data.lending_id, data.message, data.redirect);
return;
}
if (data.success) {
cardEl.remove();
// Spezielle Preview-Karte für Seiten-Aktionen
if ((actionType === 'create_page' || actionType === 'update_page') && data.page_id) {
appendPagePreviewCard(data);
} else {
// Standard-Erfolgsmeldung für alle anderen Aktionen
var msg = data.message || T('Erfolgreich ausgeführt.');
var sysEl = appendMessage('system', msg, false);
if (data.redirect) {
var link = document.createElement('a');
link.href = data.redirect;
link.textContent = ' ' + T('Öffnen →');
link.style.cssText = 'color:#3c8dbc;margin-left:0.5rem';
sysEl.querySelector('.ai-msg-bubble').appendChild(link);
}
}
scrollToBottom();
}
})
.catch(function () {
showError('Aktion fehlgeschlagen. Bitte erneut versuchen.');
cardEl.querySelectorAll('button').forEach(function (b) { b.disabled = false; });
});
}
/**
* Zeigt eine "Warte auf Unterschrift"-Karte im Chat und pollt den Status.
*/
function showSignaturePending(lendingId, successMsg, redirectUrl) {
var parentMsg = messages.lastElementChild;
var card = document.createElement('div');
card.className = 'ai-signature-pending-card';
card.innerHTML =
'<div class="ai-sig-header">' +
' <span class="ai-sig-dot"></span>' +
' <span class="ai-sig-label">' + T('Warte auf Unterschrift…') + '</span>' +
'</div>' +
'<div class="ai-sig-info">' + escHtml(successMsg || '') + '</div>' +
(redirectUrl
? '<a href="' + escHtml(redirectUrl) + '" class="ai-sig-link">' + T('Leihvorgang öffnen →') + '</a>'
: '') +
'<div class="ai-sig-hint">' + T('Bitte auf dem Unterschriften-Tablet unterschreiben.') + '</div>';
if (parentMsg) parentMsg.appendChild(card);
else messages.appendChild(card);
scrollToBottom();
// Poll every 3 s until signature obtained or request gone
var pollTimer = setInterval(function () {
if (!document.body.contains(card)) {
clearInterval(pollTimer);
return;
}
fetch('/api/loans/lendings/' + encodeURIComponent(lendingId) + '/signature-status', {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.status === 'obtained') {
clearInterval(pollTimer);
card.className = 'ai-signature-done-card';
card.innerHTML =
'<div class="ai-sig-header">' +
' <span class="ai-sig-check">✓</span>' +
' <span class="ai-sig-label-done">' + T('Unterschrift erhalten') + '</span>' +
'</div>' +
'<div class="ai-sig-info">' + escHtml(successMsg || '') + '</div>' +
(redirectUrl
? '<a href="' + escHtml(redirectUrl) + '" class="ai-sig-link">' + T('Leihvorgang öffnen →') + '</a>'
: '');
scrollToBottom();
} else if (d.status === 'none') {
// Anforderung wurde abgebrochen
clearInterval(pollTimer);
card.className = 'ai-signature-cancelled-card';
card.innerHTML =
'<div class="ai-sig-header">' +
' <span style="color:#6b7280">–</span>' +
' <span style="color:#6b7280;font-size:0.84rem;font-weight:600">Unterschrift abgebrochen</span>' +
'</div>';
scrollToBottom();
}
})
.catch(function () { /* ignore polling errors */ });
}, 3000);
}
/**
* Zeigt eine ansprechende Preview-Karte nach Seiten-Erstellung/-Aktualisierung.
*/
function appendPagePreviewCard(data) {
var card = document.createElement('div');
card.className = 'ai-page-preview-card';
var actionLabel = data.message && data.message.indexOf('angelegt') !== -1 ? T('Seite angelegt') : T('Seite aktualisiert');
card.innerHTML =
'<div class="ai-page-preview-icon">📄</div>' +
'<div class="ai-page-preview-body">' +
' <div class="ai-page-preview-label">' + escHtml(actionLabel) + '</div>' +
' <div class="ai-page-preview-msg">' + escHtml(data.message || '') + '</div>' +
'</div>' +
(data.redirect
? '<a href="' + escHtml(data.redirect) + '" class="ai-page-preview-btn" target="_self">' + T('Seite öffnen →') + '</a>'
: '');
var parentMsg = messages.lastElementChild;
if (parentMsg) parentMsg.appendChild(card);
else messages.appendChild(card);
scrollToBottom();
}
function appendActionCard(opts) {
var parentMsg = messages.lastElementChild;
var card = document.createElement('div');
card.className = 'ai-action-card';
card.innerHTML =
'<div class="ai-action-card-title">' + opts.icon + ' ' + escHtml(opts.title) + '</div>' +
'<div class="ai-action-card-details">' + opts.details + '</div>' +
'<div class="ai-action-card-actions">' +
'<button type="button" class="ai-action-btn ai-action-btn-primary confirm-btn">' + escHtml(opts.confirmLabel) + '</button>' +
'<button type="button" class="ai-action-btn ai-action-btn-secondary cancel-btn">' + escHtml(opts.cancelLabel) + '</button>' +
'</div>';
card.querySelector('.confirm-btn').addEventListener('click', function () {
opts.onConfirm(card);
});
card.querySelector('.cancel-btn').addEventListener('click', function () {
card.remove();
});
if (parentMsg) parentMsg.appendChild(card);
else messages.appendChild(card);
scrollToBottom();
// Dry-Run-Vorschau laden (Berechtigung + aufgelöste Referenzen),
// bevor der Nutzer bestätigt.
var previewAct = pendingPreviewAction;
pendingPreviewAction = null;
if (previewAct) loadActionPreview(card, previewAct);
}
/** Holt die Server-Vorschau und blendet Hinweise/Berechtigung in die Karte ein. */
function loadActionPreview(card, action) {
var fd = new FormData();
fd.append('action_type', action.action);
fd.append('action_data', JSON.stringify(action));
fd.append('_csrf', csrfToken);
fetch('/api/ai-chat/action/preview', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: fd
})
.then(function (r) { return r.json(); })
.then(function (p) {
if (!p || typeof p.allowed === 'undefined') return;
var box = document.createElement('div');
box.className = 'ai-action-preview';
var html = '';
if (p.resolved) {
Object.keys(p.resolved).forEach(function (k) {
html += '<div class="ai-prev-line">↳ ' + escHtml(k) + ': <strong>' + escHtml(p.resolved[k]) + '</strong></div>';
});
}
(p.issues || []).forEach(function (msg) {
html += '<div class="ai-prev-issue">⚠ ' + escHtml(msg) + '</div>';
});
box.innerHTML = html;
if (html) {
var details = card.querySelector('.ai-action-card-details');
if (details) details.appendChild(box);
}
if (!p.allowed) {
var btn = card.querySelector('.confirm-btn');
if (btn) { btn.disabled = true; btn.title = 'Nicht erlaubt'; btn.style.opacity = '0.5'; }
}
})
.catch(function () { /* Vorschau ist optional */ });
}
function appendConfirmCard(opts) {
var parentMsg = messages.lastElementChild;
var card = document.createElement('div');
card.className = 'ai-confirm-card' + (opts.warning ? ' ai-confirm-card-warning' : '');
var warningIcon = opts.warning ? '<span style="font-size:1.1em">⚠️</span> ' : '';
card.innerHTML =
'<div class="ai-confirm-card-text">' + warningIcon + escHtml(opts.text) + '</div>' +
'<div class="ai-confirm-card-actions">' +
'<button type="button" class="ai-action-btn ' + (opts.warning ? 'ai-action-btn-danger' : 'ai-action-btn-primary') + ' yes-btn">' + escHtml(opts.yesLabel) + '</button>' +
'<button type="button" class="ai-action-btn ai-action-btn-secondary no-btn">' + escHtml(opts.noLabel) + '</button>' +
'</div>';
card.querySelector('.yes-btn').addEventListener('click', function () { opts.onYes(card); });
card.querySelector('.no-btn').addEventListener('click', function () { card.remove(); });
if (parentMsg) parentMsg.appendChild(card);
else messages.appendChild(card);
scrollToBottom();
}
// =========================================================================
// Details-Text für Aktionskarten
// =========================================================================
function buildOrderDetails(action) {
var html = T('<strong>Titel:</strong> {titel}', { titel: escHtml(action.title || '') });
if (Array.isArray(action.components) && action.components.length > 0) {
html += '<ul style="margin:0.375rem 0 0 1.25rem">';
action.components.forEach(function (c) {
html += '<li>' + escHtml(c.name) + (c.quantity > 1 ? ' × ' + c.quantity : '') + '</li>';
});
html += '</ul>';
}
if (action.recipient_name) html += '<br>' + T('<strong>Empfänger:</strong> {name}', { name: escHtml(action.recipient_name) });
if (action.info) html += '<br><em>' + escHtml(action.info) + '</em>';
return html;
}
function buildContractDetails(action) {
var parts = [];
if (action.title) parts.push('<strong>Titel:</strong> ' + escHtml(action.title));
if (action.company_name) parts.push('<strong>Firma:</strong> ' + escHtml(action.company_name));
if (action.cost_amount) parts.push('<strong>Kosten:</strong> ' + escHtml(window.OpenNIT.money.format(action.cost_amount)) + (action.cost_interval === 'yearly' ? ' / Jahr' : action.cost_interval === 'monthly' ? ' / Monat' : ''));
if (action.start_date) parts.push('<strong>Von:</strong> ' + action.start_date);
if (action.end_date) parts.push('<strong>Bis:</strong> ' + action.end_date);
return parts.join('<br>');
}
// =========================================================================
// Text-Formatierung (einfaches Markdown)
// =========================================================================
function formatMessage(text) {
// Spezialblöcke entfernen (werden separat als Buttons/Actions gezeigt, nicht als Text).
// Die Kennzeichnung `suggestions` steht hinter der Zaunlinie oder – bei
// kleineren Modellen – in der ersten Zeile des Blocks; beides zählt,
// sonst stünde der Block als Quelltext in der Unterhaltung.
text = text.replace(/```([^\n`]*)\r?\n([\s\S]*?)```/g, function (ganz, kopf, rumpf) {
var ersteZeile = (rumpf.split(/\r?\n/)[0] || '').trim().toLowerCase();
if (kopf.trim().toLowerCase() === 'suggestions' || ersteZeile === 'suggestions') {
return '';
}
return ganz;
}).trim();
text = text.replace(/```json\s*\{[\s\S]*?"action"[\s\S]*?\}[\s\S]*?```/g, '').trim();
// Code-Blöcke vor dem Escaping extrahieren, danach wieder einsetzen
var codeBlocks = [];
text = text.replace(/```([\w+-]*)\n?([\s\S]*?)```/g, function (_, lang, code) {
var idx = codeBlocks.length;
var label = lang ? escHtml(lang) : 'Code';
codeBlocks.push(
'<div class="ai-code-block">' +
'<div class="ai-code-head">' +
'<span class="ai-code-lang">' + label + '</span>' +
'<button type="button" class="ai-code-copy" title="Code kopieren" aria-label="Code kopieren">⧉ Kopieren</button>' +
'</div>' +
'<pre><code>' + escHtml(code.replace(/\n$/, '')) + '</code></pre>' +
'</div>'
);
return '\x00CODE' + idx + '\x00';
});
text = escHtml(text)
// Inline Code
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Fett
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Kursiv (*text* oder _text_)
.replace(/\*([^*\n]+)\*/g, '<em>$1</em>')
.replace(/_([^_\n]+)_/g, '<em>$1</em>')
// Links
.replace(/\[([^\]]+)\]\((\/[^\)]+)\)/g, '<a href="$2">$1</a>');
// Markdown-Tabellen (GitHub-Stil) in <table> umwandeln
text = renderTables(text);
// Überschriften (nach escHtml, da # kein HTML-Sonderzeichen)
text = text
.replace(/^### (.+)$/gm, '<strong>$1</strong>')
.replace(/^## (.+)$/gm, '<strong style="font-size:1.05em">$1</strong>')
.replace(/^# (.+)$/gm, '<strong style="font-size:1.1em;display:block;margin-bottom:0.2em">$1</strong>');
// Listen: aufeinanderfolgende - / 1. Zeilen in <ul>/<ol> einwickeln
text = text
.replace(/((?:^- .+$\n?)+)/gm, function (block) {
var items = block.trim().replace(/^- (.+)$/gm, '<li>$1</li>');
return '<ul style="margin:0.3em 0;padding-left:1.2em">' + items + '</ul>';
})
.replace(/((?:^\d+\. .+$\n?)+)/gm, function (block) {
var items = block.trim().replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
return '<ol style="margin:0.3em 0;padding-left:1.2em">' + items + '</ol>';
});
// Zeilenumbrüche (nicht innerhalb von Block-Tags)
text = text.replace(/\n/g, '<br>');
// Code-Blöcke wieder einsetzen
text = text.replace(/\x00CODE(\d+)\x00/g, function (_, i) { return codeBlocks[+i]; });
return text;
}
// =========================================================================
// Hilfsfunktionen
// =========================================================================
/**
* Wandelt GitHub-Markdown-Tabellen in <table> um. Erwartet bereits
* escapten Text. Eine Tabelle = Kopfzeile mit |, Trennzeile aus -/:,
* dann ≥1 Datenzeilen.
*/
function renderTables(text) {
var lines = text.split('\n');
var out = [];
var i = 0;
var isRow = function (l) { return /^\s*\|.*\|\s*$/.test(l); };
var isSep = function (l) { return /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(l) && l.indexOf('-') !== -1; };
var cells = function (l) {
return l.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map(function (c) { return c.trim(); });
};
while (i < lines.length) {
if (isRow(lines[i]) && i + 1 < lines.length && isSep(lines[i + 1])) {
var head = cells(lines[i]);
var body = [];
i += 2;
while (i < lines.length && isRow(lines[i])) { body.push(cells(lines[i])); i++; }
var html = '<table class="ai-md-table"><thead><tr>';
head.forEach(function (c) { html += '<th>' + c + '</th>'; });
html += '</tr></thead><tbody>';
body.forEach(function (row) {
html += '<tr>';
head.forEach(function (_, idx) { html += '<td>' + (row[idx] || '') + '</td>'; });
html += '</tr>';
});
html += '</tbody></table>';
out.push(html);
} else {
out.push(lines[i]);
i++;
}
}
return out.join('\n');
}
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function scrollToBottom() {
if (messages) messages.scrollTop = messages.scrollHeight;
}
/** Formatiert einen Zeitstempel (ISO/MySQL/Date) zu "HH:MM"; leer bei Fehler. */
function formatTime(ts) {
if (!ts) return '';
var d = (ts instanceof Date) ? ts : new Date(String(ts).replace(' ', 'T'));
if (isNaN(d.getTime())) return '';
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
/** Kopiert Text in die Zwischenablage und gibt kurzes visuelles Feedback. */
function copyText(text, btn) {
var done = function () {
if (!btn) return;
var prev = btn.textContent;
btn.textContent = T('✓ Kopiert');
btn.classList.add('ai-copied');
setTimeout(function () { btn.textContent = prev; btn.classList.remove('ai-copied'); }, 1500);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(function () {});
} else {
try {
var ta = document.createElement('textarea');
ta.value = text; document.body.appendChild(ta); ta.select();
document.execCommand('copy'); document.body.removeChild(ta); done();
} catch (e) { /* ignore */ }
}
}
// Delegierter Klick-Handler für "Kopieren" an Code-Blöcken
if (messages) {
messages.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('.ai-code-copy') : null;
if (!btn) return;
var block = btn.closest('.ai-code-block');
var pre = block ? block.querySelector('pre') : null;
if (pre) copyText(pre.innerText, btn);
});
}
function showError(msg) {
if (errorText) errorText.textContent = msg;
if (errorBar) errorBar.style.display = 'flex';
}
function hideError() {
if (errorBar) errorBar.style.display = 'none';
}
if (errorClose) {
errorClose.addEventListener('click', hideError);
}
})();