1021 lines
31 KiB
JavaScript
1021 lines
31 KiB
JavaScript
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 LAST_TURN_STORAGE_KEY = 'retriex:lastCompletedTurn';
|
|
|
|
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 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(/<br\s*\/?>/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 = `<em>${safeMessage}</em>`;
|
|
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;
|
|
let completionWatchdogTimer = null;
|
|
let completionWatchdogInFlight = false;
|
|
|
|
const clearNetworkErrorTimer = () => {
|
|
if (!networkErrorTimer) {
|
|
return;
|
|
}
|
|
|
|
clearTimeout(networkErrorTimer);
|
|
networkErrorTimer = null;
|
|
};
|
|
|
|
const clearCompletionWatchdog = () => {
|
|
if (!completionWatchdogTimer) {
|
|
return;
|
|
}
|
|
|
|
clearInterval(completionWatchdogTimer);
|
|
completionWatchdogTimer = null;
|
|
};
|
|
|
|
const complete = () => {
|
|
if (finished) {
|
|
return;
|
|
}
|
|
|
|
finished = true;
|
|
clearNetworkErrorTimer();
|
|
clearCompletionWatchdog();
|
|
finishEventStream();
|
|
resolve();
|
|
};
|
|
|
|
const fail = (err) => {
|
|
if (finished) {
|
|
return;
|
|
}
|
|
|
|
finished = true;
|
|
clearNetworkErrorTimer();
|
|
clearCompletionWatchdog();
|
|
finishEventStream();
|
|
reject(err);
|
|
};
|
|
|
|
const finishFromStatus = (payload) => {
|
|
if (finished || state.abortRequested || !payload || typeof payload !== 'object') {
|
|
return false;
|
|
}
|
|
|
|
const status = String(payload.status || '');
|
|
const serverLastEventId = Number.parseInt(String(payload.lastEventId || '0'), 10) || 0;
|
|
|
|
// Only finalize from the side-channel when all numbered content chunks are already visible.
|
|
// This prevents cutting off the final answer if the browser missed more than just the terminal done event.
|
|
if (serverLastEventId > lastSseEventId) {
|
|
return false;
|
|
}
|
|
|
|
if (status === 'completed') {
|
|
finalizeStream(bubble, raw);
|
|
rememberCompletedTurn(prompt, raw);
|
|
complete();
|
|
return true;
|
|
}
|
|
|
|
if (status === 'failed' || status === 'interrupted') {
|
|
appendError(payload.message || 'Der Antwort-Stream wurde beendet, ohne ein Abschluss-Signal an den Browser zu senden.');
|
|
complete();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const checkCompletionStatus = async () => {
|
|
if (finished || state.abortRequested || completionWatchdogInFlight) {
|
|
return false;
|
|
}
|
|
|
|
completionWatchdogInFlight = true;
|
|
|
|
try {
|
|
const statusRes = await fetch(`/ask-jobs/${encodeURIComponent(jobId)}`, {
|
|
method: 'GET',
|
|
cache: 'no-store',
|
|
signal: state.abortController?.signal,
|
|
});
|
|
|
|
if (!statusRes.ok) {
|
|
return false;
|
|
}
|
|
|
|
return finishFromStatus(await statusRes.json());
|
|
} catch (err) {
|
|
if (!state.abortRequested) {
|
|
console.debug('Stream completion watchdog failed:', err);
|
|
}
|
|
|
|
return false;
|
|
} finally {
|
|
completionWatchdogInFlight = false;
|
|
}
|
|
};
|
|
|
|
completionWatchdogTimer = setInterval(checkCompletionStatus, 2500);
|
|
|
|
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;
|
|
}
|
|
|
|
if (event.data === '[DONE]') {
|
|
finalizeStream(bubble, raw);
|
|
rememberCompletedTurn(prompt, raw);
|
|
complete();
|
|
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;
|
|
}
|
|
|
|
void checkCompletionStatus();
|
|
|
|
if (source.readyState === EventSource.CLOSED) {
|
|
window.setTimeout(async () => {
|
|
if (finished || state.abortRequested) {
|
|
return;
|
|
}
|
|
|
|
const statusCompleted = await checkCompletionStatus();
|
|
|
|
if (!statusCompleted && !finished) {
|
|
fail(new Error('EventSource connection closed'));
|
|
}
|
|
}, 250);
|
|
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 = `<em>${userMessage}</em>`;
|
|
raw += raw.trim() === '' ? formattedMessage : `\n\n${formattedMessage}`;
|
|
finalizeStream(bubble, raw, {state: 'error'});
|
|
} else {
|
|
bubble.innerHTML = `<em>${userMessage}</em>`;
|
|
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', '<em>[aborted]</em>');
|
|
});
|
|
|
|
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', '<em>History cleared.</em>');
|
|
});
|
|
}); |