OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

ai-chat.js

75 KB
/**
 * 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&auml;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, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;');
    }

    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);
    }

})();