// ── DOM Helpers ── const $ = (sel) => document.querySelector(sel); const chatEl = () => $('#chat'); const metaEl = () => $('#meta'); function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML.replace(/"/g, '"'); } function truncate(s, max) { return s && s.length > max ? s.substring(0, max) + '...' : (s || ''); } // Auto-Scroll: wird deaktiviert wenn User nach oben scrollt, reaktiviert wenn er ganz unten ist var _autoScroll = true; function initAutoScroll() { const chat = chatEl(); if (!chat) return; _autoScroll = true; chat.addEventListener('scroll', function() { const threshold = 60; const atBottom = chat.scrollHeight - chat.scrollTop - chat.clientHeight <= threshold; _autoScroll = atBottom; }); } function scrollDown() { const chat = chatEl(); if (!chat) return; if (!_autoScroll) return; chat.scrollTop = chat.scrollHeight; // Nach Font-Swap kann sich die Höhe ändern → nochmal scrollen document.fonts.ready.then(() => { if (_autoScroll) chat.scrollTop = chat.scrollHeight; }); } function scrollDownForce() { const chat = chatEl(); if (!chat) return; _autoScroll = true; chat.scrollTop = chat.scrollHeight; document.fonts.ready.then(() => { chat.scrollTop = chat.scrollHeight; }); } function prettyModelName(model) { if (!model) return '?'; model = model.split('claude-').join(''); if (model == 'sonnet') return 'Sonnet'; if (model == 'sonnet-4-5') return 'Sonnet 4.5'; if (model == 'sonnet-4-6') return 'Sonnet 4.6'; if (model == 'opus') return 'Opus'; if (model == 'opus-4-5') return 'Opus 4.5'; if (model == 'opus-4-6') return 'Opus 4.6'; if (model == 'haiku') return 'Haiku'; if (model == 'haiku-4-5') return 'Haiku 4.5'; return 'Unbekannt (' + model + ')'; } // ── Toast Notifications ── function showToast(message, type) { type = type || 'info'; var container = document.getElementById('toastContainer'); if (!container) { container = document.createElement('div'); container.id = 'toastContainer'; document.body.appendChild(container); } var toast = document.createElement('div'); toast.className = 'toast toast-' + type; var icon = type === 'success' ? 'fa-check-circle' : type === 'error' ? 'fa-times-circle' : 'fa-info-circle'; toast.innerHTML = ' ' + (message || ''); container.appendChild(toast); // Einblenden requestAnimationFrame(function() { toast.classList.add('toast-visible'); }); // Automatisch ausblenden var timeout = setTimeout(function() { removeToast(toast); }, 4000); toast.addEventListener('click', function() { clearTimeout(timeout); removeToast(toast); }); } function removeToast(toast) { toast.classList.remove('toast-visible'); toast.addEventListener('transitionend', function() { toast.remove(); }); } // ── EasyMDE Helper ── function createMDE(element, options) { options = options || {}; const mde = new EasyMDE({ element: element, initialValue: options.initialValue || '', toolbar: options.toolbar || ['bold', 'italic', 'heading', '|', 'unordered-list', 'ordered-list', '|', 'link', 'code', '|', 'preview'], status: false, spellChecker: false, autoDownloadFontAwesome: false, autofocus: options.autofocus || false, minHeight: options.minHeight || '120px', maxHeight: options.maxHeight || undefined, placeholder: options.placeholder || '', previewClass: ['editor-preview', 'markdown'] }); mde.codemirror.setOption('theme', 'default'); mde.codemirror.getWrapperElement().closest('.EasyMDEContainer').classList.add('fe-mde-wrap'); return mde; } // ── Card Preview (for Picker-Select-Cards) ── /** * Renders a card preview into a select-card element. * Used by agent picker (session-page) and session picker (cronjob-page). * @param {HTMLElement} cardEl - The container element * @param {HTMLElement|null} innerCard - Result of buildAgentCard/buildSessionCard, or null for empty state * @param {string} emptyIcon - FA icon class for empty state * @param {string} emptyText - Text for empty state * @param {string} changeClass - CSS class for the change indicator (e.g. 'agent-select-change', 'session-select-change') */ function _renderCardPreview(cardEl, innerCard, emptyIcon, emptyText, changeClass) { cardEl.innerHTML = ''; if (!innerCard) { cardEl.innerHTML = '
' + emptyText + '
'; return; } while (innerCard.firstChild) cardEl.appendChild(innerCard.firstChild); cardEl.insertAdjacentHTML('beforeend', ''); } // ── More/Expand Button ── function initMoreButton(moreEl) { if (!moreEl) return; moreEl.addEventListener('click', () => { const content = moreEl.querySelector('[hidden]'); while (content.firstChild) moreEl.parentNode.insertBefore(content.firstChild, moreEl); moreEl.remove(); }); } // ── Button Loading Helper ── /** * Disables a button, sets loading text, runs async function, then restores. * @param {HTMLElement} btn - The button element * @param {string} loadingText - Text while loading (e.g. 'Speichern...') * @param {Function} asyncFn - Async function to run * @returns {Promise<*>} Result of asyncFn */ async function withButtonLoading(btn, loadingText, asyncFn) { const originalText = btn.textContent; const originalHtml = btn.innerHTML; btn.disabled = true; btn.textContent = loadingText; try { return await asyncFn(); } finally { btn.disabled = false; btn.innerHTML = originalHtml; } } // ── Date Formatting ── function formatDate(iso) { if (!iso) return ''; return new Date(iso).toLocaleString('de-AT', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }); } function formatTimestamp(isoString) { if (!isoString) return ''; var d = new Date(isoString); if (isNaN(d.getTime())) return ''; var dd = String(d.getDate()).padStart(2, '0'); var mm = String(d.getMonth() + 1).padStart(2, '0'); var yyyy = d.getFullYear(); var hh = String(d.getHours()).padStart(2, '0'); var min = String(d.getMinutes()).padStart(2, '0'); var ss = String(d.getSeconds()).padStart(2, '0'); return dd + '.' + mm + '.' + yyyy + ' ' + hh + ':' + min + ':' + ss; } // ── Meta Bar ── function setMeta(text) { metaEl().textContent = text; } function setMetaHtml(html) { metaEl().innerHTML = html; } // ── Older Messages Button ── function showOlderMessagesButton(remainingCount) { removeOlderMessagesButton(); const btn = document.createElement('button'); btn.className = 'archive-load-btn older-messages-btn'; btn.innerHTML = ' ' + remainingCount + ' ältere Nachrichten laden'; btn.addEventListener('click', () => loadOlderMessages()); chatEl().prepend(btn); } function removeOlderMessagesButton() { const btn = chatEl().querySelector('.older-messages-btn'); if (btn) btn.remove(); } // ── Archive Button ── function showArchiveLoadButton() { const existing = chatEl().querySelector('.archive-load-btn:not(.older-messages-btn)'); if (existing) existing.remove(); const btn = document.createElement('button'); btn.className = 'archive-load-btn'; btn.innerHTML = ' Archivierte Nachrichten laden'; btn.addEventListener('click', loadLatestArchive); // Immer ganz oben im Chat (aber nach dem older-messages-btn falls vorhanden) const olderBtn = chatEl().querySelector('.older-messages-btn'); if (olderBtn) { olderBtn.after(btn); } else { chatEl().prepend(btn); } } function removeArchiveLoadButton() { const btns = chatEl().querySelectorAll('.archive-load-btn:not(.older-messages-btn)'); btns.forEach(b => b.remove()); } // ── Review/Sub-Agent Helpers ── function _isReviewMessage(content) { return content && (content.startsWith('[Review:') || content.startsWith('[Review-Feedback') || content.startsWith('[Finalizer:') || content.startsWith('[Preparer:')); } /** * Returns { icon, defaultName } for a sub-agent type. * @param {string} agentType - 'preparer', 'finalizer', or 'review' */ function _getSubAgentMeta(agentType) { if (agentType === 'preparer') return { icon: 'fa-filter', defaultName: 'Vorbereiter-Agent' }; if (agentType === 'finalizer') return { icon: 'fa-flag-checkered', defaultName: 'Finalizer-Agent' }; return { icon: 'fa-check-circle-o', defaultName: 'Review-Agent' }; } /** * Creates a review/sub-agent header element. * @param {string} agentType - 'preparer', 'finalizer', or 'review' * @param {string} agentName - Display name of the agent * @param {object} [extra] - Optional: { round, maxRounds } for review */ function _createSubAgentHeader(agentType, agentName, extra) { var meta = _getSubAgentMeta(agentType); var header = document.createElement('div'); header.className = 'review-header'; var html = ' ' + esc(agentName || meta.defaultName) + ''; if (agentType === 'review' && extra && extra.round) { html += ' Runde ' + extra.round + '/' + extra.maxRounds + ''; } header.innerHTML = html; return header; } // ── Streamed Elements ── function removeStreamedElements() { chatEl().querySelectorAll('.streamed').forEach(el => el.remove()); }