document.addEventListener('DOMContentLoaded', () => { const chatEl = document.getElementById('chat'); const promptEl = document.getElementById('prompt'); const sendBtn = document.getElementById('send'); const abortBtn = document.getElementById('abort'); const clearBtn = document.getElementById('clear'); const aiCloudEl = document.getElementById('ai-cloud'); const retriexCardsToggleEl = document.getElementById('toggle-retriex-cards'); const LAST_TURN_STORAGE_KEY = 'retriex:lastCompletedTurn'; const DETAIL_CARDS_STORAGE_KEY = 'retriex:showDetailCards'; const state = { abortRequested: false, isStreaming: false, renderTimer: null, abortController: null, reader: null, eventSource: null, completeStream: null, failStream: null, lastCompletedUserPrompt: '', lastCompletedAssistantText: '', }; marked.setOptions({breaks: true}); function setRetriexDetailCardsVisible(isVisible, persist = true) { document.body.classList.toggle('retriex-show-detail-cards', isVisible); if (retriexCardsToggleEl) { retriexCardsToggleEl.checked = isVisible; } if (!persist) { return; } try { window.localStorage?.setItem(DETAIL_CARDS_STORAGE_KEY, isVisible ? '1' : '0'); } catch (err) { console.debug('Could not persist detail card visibility:', err); } } function initRetriexDetailCardsToggle() { let isVisible = false; try { isVisible = window.localStorage?.getItem(DETAIL_CARDS_STORAGE_KEY) === '1'; } catch (err) { console.debug('Could not read detail card visibility:', err); } setRetriexDetailCardsVisible(isVisible, false); retriexCardsToggleEl?.addEventListener('change', () => { setRetriexDetailCardsVisible(retriexCardsToggleEl.checked, true); }); } initRetriexDetailCardsToggle(); function renderMarkdown(text) { return DOMPurify.sanitize(marked.parse(text)); } function normalizeContextHintText(value) { return String(value || '') .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .replace(/[\t ]+/g, ' ') .replace(/\n{3,}/g, '\n\n') .trim(); } function tokenizeClientMetaGuardText(value) { return normalizeContextHintText(value) .toLowerCase() .replace(/[-/_]/g, ' ') .replace(/[^\p{L}\p{N}]+/gu, ' ') .trim() .split(/\s+/u) .filter(Boolean); } function extractAssistantContextText(bubble) { if (!bubble) { return ''; } const clone = bubble.cloneNode(true); clone.querySelectorAll('.think, .retriex-meta-card, .retriex-alert').forEach((element) => { element.remove(); }); return normalizeContextHintText(clone.innerText || clone.textContent || ''); } function isClientMetaOnlyShopPrompt(value) { const tokens = tokenizeClientMetaGuardText(value); if (!tokens.length) { return true; } const metaTerms = new Set([ 'shop', 'shopsuche', 'suche', 'suchen', 'such', 'finde', 'find', 'zeige', 'zeig', 'bitte', 'mal', 'im', 'in', 'nach', 'den', 'die', 'das', 'der', 'dem', ]); return tokens.every((token) => metaTerms.has(token)); } function isNoConcreteShopResponse(value) { const normalized = normalizeContextHintText(value).toLowerCase(); return normalized.includes('keine konkrete shop-suchanfrage erkannt') || normalized.includes('shop-suche noch nicht belastbar auflösen') || normalized.includes('shop-suche noch nicht belastbar aufloesen'); } function rememberCompletedTurn(userPrompt, assistantText) { const normalizedPrompt = normalizeContextHintText(userPrompt); const normalizedAssistantText = normalizeContextHintText(assistantText); if (!normalizedPrompt) { return; } if (isClientMetaOnlyShopPrompt(normalizedPrompt) && isNoConcreteShopResponse(normalizedAssistantText)) { return; } state.lastCompletedUserPrompt = normalizedPrompt.slice(0, 800); state.lastCompletedAssistantText = normalizedAssistantText.slice(0, 3000); try { window.sessionStorage?.setItem(LAST_TURN_STORAGE_KEY, JSON.stringify({ userPrompt: state.lastCompletedUserPrompt, assistantText: state.lastCompletedAssistantText, rememberedAt: Date.now(), })); } catch (err) { console.debug('Could not persist last completed turn:', err); } } function loadStoredCompletedTurn() { try { const raw = window.sessionStorage?.getItem(LAST_TURN_STORAGE_KEY) || ''; if (!raw) { return null; } const data = JSON.parse(raw); const userPrompt = normalizeContextHintText(data?.userPrompt || ''); const assistantText = normalizeContextHintText(data?.assistantText || ''); if (!userPrompt) { return null; } return { userPrompt: userPrompt.slice(0, 800), assistantText: assistantText.slice(0, 3000), }; } catch (err) { console.debug('Could not read last completed turn:', err); return null; } } function extractLatestVisibleCompletedTurn() { if (!chatEl) { return null; } const messages = Array.from(chatEl.querySelectorAll('.message')); let pendingUserPrompt = ''; let latestTurn = null; messages.forEach((message) => { const bubble = message.querySelector('.bubble'); const text = message.classList.contains('assistant') ? extractAssistantContextText(bubble) : normalizeContextHintText(bubble?.innerText || bubble?.textContent || ''); if (!text) { return; } if (message.classList.contains('user')) { pendingUserPrompt = text; return; } if (!message.classList.contains('assistant') || !pendingUserPrompt) { return; } if (bubble?.classList.contains('loader')) { return; } if (isClientMetaOnlyShopPrompt(pendingUserPrompt) && isNoConcreteShopResponse(text)) { pendingUserPrompt = ''; return; } latestTurn = { userPrompt: pendingUserPrompt, assistantText: text, }; pendingUserPrompt = ''; }); return latestTurn; } function buildClientContextHint() { const visibleTurn = extractLatestVisibleCompletedTurn(); const storedTurn = loadStoredCompletedTurn(); const userPrompt = visibleTurn?.userPrompt || state.lastCompletedUserPrompt || storedTurn?.userPrompt || ''; const assistantText = visibleTurn?.assistantText || state.lastCompletedAssistantText || storedTurn?.assistantText || ''; if (!userPrompt) { return ''; } const lines = [`Question: ${userPrompt.slice(0, 800)}`]; if (assistantText) { lines.push(assistantText.slice(0, 3000)); } return normalizeContextHintText(lines.join('\n')).slice(0, 4000); } function scrollChatToBottom() { if (!chatEl) { return; } chatEl.scrollTop = chatEl.scrollHeight; } function enhanceChatLinks(container = chatEl) { if (!container) { return; } container.querySelectorAll('a[href]').forEach((link) => { link.setAttribute('target', '_blank'); link.setAttribute('rel', 'noopener noreferrer'); }); } function addMessage(role, html = '', extra = '') { const msg = document.createElement('div'); msg.className = 'message ' + role; const bubble = document.createElement('div'); bubble.className = 'bubble ' + extra; bubble.innerHTML = html; msg.appendChild(bubble); chatEl.appendChild(msg); enhanceChatLinks(bubble); scrollChatToBottom(); return bubble; } function addLoader() { return addMessage('assistant', 'Antwort wird vorbereitet…', 'loader'); } function hasMeaningfulChildContent(element) { return element.querySelector( 'img, table, pre, code, ul, ol, h1, h2, h3, h4, h5, h6, a, hr, .badge, .retriex-meta-card, .retriex-alert, .retriex-action-chip' ) !== null; } function isWhitespaceTextNode(node) { return node.nodeType === Node.TEXT_NODE && (node.textContent || '').trim() === ''; } function isBreakNode(node) { return node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR'; } function trimEdgeBreaks(element) { while (element.firstChild && (isWhitespaceTextNode(element.firstChild) || isBreakNode(element.firstChild))) { element.firstChild.remove(); } while (element.lastChild && (isWhitespaceTextNode(element.lastChild) || isBreakNode(element.lastChild))) { element.lastChild.remove(); } } function cleanupEmptyBlocks(container) { container.querySelectorAll('p, div, li, blockquote').forEach((el) => { trimEdgeBreaks(el); const html = el.innerHTML .replace(//gi, '') .replace(/ /gi, '') .trim(); const text = (el.textContent || '').trim(); if (html === '' && text === '') { el.remove(); return; } if (text === '' && !hasMeaningfulChildContent(el)) { el.remove(); } }); trimEdgeBreaks(container); } function removeThinkSpansOnly(container) { container.querySelectorAll('.think').forEach((span) => { span.remove(); }); cleanupEmptyBlocks(container); } function cloneWithoutThinkContent(container) { const clone = container.cloneNode(true); clone.querySelectorAll('.think').forEach((span) => span.remove()); cleanupEmptyBlocks(clone); return clone; } function hasNonThinkContent(container) { const clone = cloneWithoutThinkContent(container); if ((clone.textContent || '').trim() !== '') { return true; } return hasMeaningfulChildContent(clone); } function keepOnlyLastThink(container) { const thinkSpans = Array.from(container.querySelectorAll('.think')); if (thinkSpans.length <= 1) { cleanupEmptyBlocks(container); return; } const lastThink = thinkSpans[thinkSpans.length - 1]; thinkSpans.slice(0, -1).forEach((span) => { span.remove(); }); const blockSelector = 'p, div, li, blockquote'; const lastBlock = lastThink.closest(blockSelector) || lastThink.parentElement; if (lastBlock && lastThink.parentElement === lastBlock) { Array.from(lastBlock.childNodes).forEach((node) => { if (node === lastThink) { return; } if ( node.nodeType === Node.TEXT_NODE && node.textContent.trim() === '' ) { node.remove(); return; } if ( node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR' ) { node.remove(); } }); } cleanupEmptyBlocks(container); } function hasVisibleContentAfterNode(container, markerNode) { const walker = document.createTreeWalker( container, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, null ); let afterMarker = false; while (walker.nextNode()) { const node = walker.currentNode; if (node === markerNode) { afterMarker = true; continue; } if (!afterMarker) { continue; } if (node.nodeType === Node.TEXT_NODE) { if (node.parentElement?.closest('.think')) { continue; } if ((node.textContent || '').trim() !== '') { return true; } continue; } if (node.nodeType !== Node.ELEMENT_NODE) { continue; } if (node.classList?.contains('think') || node.closest?.('.think')) { continue; } if (node.tagName === 'BR') { continue; } if ((node.textContent || '').trim() !== '' || hasMeaningfulChildContent(node)) { return true; } } return false; } function cleanupThinkSpans(container) { if (!container) { return; } const thinkSpans = Array.from(container.querySelectorAll('.think')); if (thinkSpans.length === 0) { cleanupEmptyBlocks(container); return; } const lastThink = thinkSpans[thinkSpans.length - 1]; if (!hasVisibleContentAfterNode(container, lastThink)) { keepOnlyLastThink(container); return; } removeThinkSpansOnly(container); } function copyElementAttributes(target, source) { Array.from(target.attributes).forEach((attribute) => { target.removeAttribute(attribute.name); }); Array.from(source.attributes).forEach((attribute) => { target.setAttribute(attribute.name, attribute.value); }); } function deduplicateRetriexMetaCards(container) { if (!container) { return; } const cards = Array.from(container.querySelectorAll('.retriex-meta-card[data-retriex-meta-id]')); const cardsById = new Map(); cards.forEach((card) => { const cardId = card.getAttribute('data-retriex-meta-id'); if (!cardId) { return; } if (!cardsById.has(cardId)) { cardsById.set(cardId, []); } cardsById.get(cardId).push(card); }); cardsById.forEach((group) => { if (group.length <= 1) { return; } const firstCard = group[0]; const latestCard = group[group.length - 1]; firstCard.innerHTML = latestCard.innerHTML; copyElementAttributes(firstCard, latestCard); group.slice(1).forEach((card) => card.remove()); }); cleanupEmptyBlocks(container); } function renderBubbleContent(bubble, raw) { bubble.innerHTML = renderMarkdown(raw); cleanupThinkSpans(bubble); deduplicateRetriexMetaCards(bubble); enhanceChatLinks(bubble); scrollChatToBottom(); } function setBusyUi(isBusy) { state.isStreaming = isBusy; sendBtn.disabled = isBusy; abortBtn.disabled = !isBusy; clearBtn.disabled = isBusy; if (isBusy) { aiCloudEl?.classList.remove('d-none'); return; } aiCloudEl?.classList.add('d-none'); } function clearScheduledRender() { if (!state.renderTimer) { return; } clearTimeout(state.renderTimer); state.renderTimer = null; } function scheduleRender(bubble, getRaw) { if (state.renderTimer) { return; } state.renderTimer = setTimeout(() => { try { renderBubbleContent(bubble, getRaw()); } finally { state.renderTimer = null; } }, 100); } function parseSseEvents(buffer) { const normalizedBuffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const chunks = normalizedBuffer.split('\n\n'); return { events: chunks.slice(0, -1), rest: chunks[chunks.length - 1] || '', }; } function readSseEvent(rawEvent) { const lines = rawEvent.split('\n'); let eventName = 'message'; const dataLines = []; lines.forEach((line) => { if (line.startsWith('event:')) { eventName = line.slice(6).trim() || 'message'; return; } if (line.startsWith('data:')) { dataLines.push(line.slice(5).trimStart()); } }); return { eventName, data: dataLines.join('\n'), }; } function finalizeRetriexRunMetaCards(container, options = {}) { if (!container) { return; } const isError = options.state === 'error'; const titleText = isError ? 'Antwort wurde unterbrochen' : 'Abgeschlossen'; const statusText = isError ? 'Status: unterbrochen' : 'Status: abgeschlossen'; const emptySourceText = isError ? 'nicht vollständig geprüft' : 'keine belastbare Datenbasis'; container.querySelectorAll('.retriex-run-meta[data-retriex-meta-id="run-status"]').forEach((card) => { card.setAttribute('data-retriex-meta-state', isError ? 'error' : 'completed'); const title = card.querySelector('.retriex-meta-card__title'); if (title) { title.textContent = titleText; } const statusPill = card.querySelector('.retriex-meta-pill--status'); if (statusPill) { statusPill.textContent = statusText; } const emptySource = card.querySelector('.retriex-source-overview__empty'); if (emptySource && emptySource.textContent.trim() === 'wird geprüft') { emptySource.textContent = emptySourceText; } }); } function finalizeStream(bubble, raw, options = {}) { clearScheduledRender(); bubble.innerHTML = renderMarkdown(raw); removeThinkSpansOnly(bubble); deduplicateRetriexMetaCards(bubble); finalizeRetriexRunMetaCards(bubble, options); bubble.classList.remove('loader'); enhanceChatLinks(bubble); scrollChatToBottom(); } async function releaseStreamResources() { if (state.eventSource) { state.eventSource.close(); state.eventSource = null; } try { await state.reader?.cancel(); } catch (err) { console.debug('Reader cancel failed:', err); } state.reader = null; state.abortController = null; state.completeStream = null; state.failStream = null; } async function loadHistory() { try { const res = await fetch('/history'); if (!res.ok) { console.error('History request failed with status:', res.status); return; } const messages = await res.json(); let latestLoadedUserPrompt = ''; messages.forEach((message) => { const bubble = addMessage(message.role); renderBubbleContent(bubble, message.text); if (message.role === 'user') { latestLoadedUserPrompt = normalizeContextHintText(message.text); return; } if (message.role === 'assistant' && latestLoadedUserPrompt) { rememberCompletedTurn(latestLoadedUserPrompt, message.text); } }); enhanceChatLinks(chatEl); } catch (err) { console.error('History load failed:', err); } } chatEl?.addEventListener('click', (event) => { const actionButton = event.target?.closest?.('.retriex-action-chip[data-retriex-action-prompt]'); if (!actionButton || state.isStreaming) { return; } const actionPrompt = normalizeContextHintText(actionButton.getAttribute('data-retriex-action-prompt') || ''); if (!actionPrompt) { return; } promptEl.value = actionPrompt; promptEl.focus(); sendBtn.click(); }); loadHistory(); promptEl.addEventListener('keydown', (event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendBtn.click(); } }); sendBtn.addEventListener('click', async () => { const prompt = promptEl.value.trim(); if (!prompt || state.isStreaming) { return; } const contextHint = buildClientContextHint(); addMessage('user', renderMarkdown(prompt)); promptEl.value = ''; const bubble = addLoader(); let raw = ''; let firstChunk = true; state.abortRequested = false; state.abortController = new AbortController(); setBusyUi(true); const appendChunk = (chunk) => { if (firstChunk) { bubble.classList.remove('loader'); bubble.innerHTML = ''; firstChunk = false; } raw += chunk; scheduleRender(bubble, () => raw); }; const appendError = (message) => { const safeMessage = String(message || '').trim(); if (!safeMessage) { return; } if (firstChunk) { bubble.classList.remove('loader'); bubble.innerHTML = ''; firstChunk = false; } const formattedMessage = `${safeMessage}`; raw += raw.trim() === '' ? formattedMessage : `\n\n${formattedMessage}`; finalizeStream(bubble, raw, {state: 'error'}); }; const finishEventStream = () => { state.eventSource?.close(); state.eventSource = null; }; try { const jobRes = await fetch('/ask-jobs', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ prompt, fullContext: false, contextHint, }), signal: state.abortController.signal, }); if (!jobRes.ok) { throw new Error(`HTTP ${jobRes.status} ${jobRes.statusText}`); } const jobPayload = await jobRes.json(); const jobId = String(jobPayload.jobId || ''); if (!/^[a-f0-9]{48}$/.test(jobId)) { throw new Error('Invalid stream job response'); } await new Promise((resolve, reject) => { let finished = false; let lastSseEventId = 0; const source = new EventSource(`/ask-sse/${encodeURIComponent(jobId)}`); state.eventSource = source; let networkErrorTimer = null; const clearNetworkErrorTimer = () => { if (!networkErrorTimer) { return; } clearTimeout(networkErrorTimer); networkErrorTimer = null; }; const complete = () => { if (finished) { return; } finished = true; clearNetworkErrorTimer(); finishEventStream(); resolve(); }; const fail = (err) => { if (finished) { return; } finished = true; clearNetworkErrorTimer(); finishEventStream(); reject(err); }; state.completeStream = complete; state.failStream = fail; source.onopen = () => { clearNetworkErrorTimer(); }; source.onmessage = (event) => { if (state.abortRequested || finished) { complete(); return; } clearNetworkErrorTimer(); if (event.data === undefined || event.data === null || event.data === '') { return; } const numericEventId = Number.parseInt(event.lastEventId || '', 10); if (Number.isFinite(numericEventId) && numericEventId > 0) { if (numericEventId <= lastSseEventId) { return; } lastSseEventId = numericEventId; } appendChunk(event.data); }; source.addEventListener('done', () => { if (!state.abortRequested) { finalizeStream(bubble, raw); rememberCompletedTurn(prompt, raw); } complete(); }); source.addEventListener('error', (event) => { if (state.abortRequested || finished) { complete(); return; } if (event instanceof MessageEvent && typeof event.data === 'string') { appendError(event.data); complete(); return; } if (source.readyState === EventSource.CLOSED) { fail(new Error('EventSource connection closed')); return; } if (!networkErrorTimer) { networkErrorTimer = setTimeout(() => { if (!finished && !state.abortRequested) { fail(new Error('EventSource connection error')); } }, 20000); } }); }); } catch (err) { if (err?.name === 'AbortError' || state.abortRequested) { console.info('SSE request aborted by user'); } else { console.error('SSE stream failed:', err); bubble.classList.remove('loader'); const userMessage = 'Die Verbindung zum Antwort-Stream wurde unterbrochen. Bitte sende die Anfrage erneut, falls die Antwort unvollständig ist.'; if (raw.trim() !== '') { const formattedMessage = `${userMessage}`; raw += raw.trim() === '' ? formattedMessage : `\n\n${formattedMessage}`; finalizeStream(bubble, raw, {state: 'error'}); } else { bubble.innerHTML = `${userMessage}`; enhanceChatLinks(bubble); scrollChatToBottom(); } } } finally { clearScheduledRender(); await releaseStreamResources(); setBusyUi(false); } }); abortBtn.addEventListener('click', async () => { if (!state.isStreaming) { return; } state.abortRequested = true; state.abortController?.abort(); state.completeStream?.(); await releaseStreamResources(); setBusyUi(false); addMessage('assistant', '[aborted]'); }); clearBtn.addEventListener('click', async () => { if (state.isStreaming) { return; } try { await fetch('/history/delete', {method: 'POST'}); } catch (err) { console.error('History delete failed:', err); } state.lastCompletedUserPrompt = ''; state.lastCompletedAssistantText = ''; try { window.sessionStorage?.removeItem(LAST_TURN_STORAGE_KEY); } catch (err) { console.debug('Could not clear last completed turn:', err); } chatEl.innerHTML = ''; addMessage('assistant', 'History cleared.'); }); });