Files
MtoRagSystem/public/assets/js/base.js
team 1 c327dc4102 p64
2026-05-09 11:24:08 +02:00

1221 lines
38 KiB
JavaScript

document.addEventListener('DOMContentLoaded', async () => {
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 JOB_STATUS_POLL_INTERVAL_MS = 2500;
const JOB_COMPLETION_CATCHUP_GRACE_MS = 10000;
const JOB_CLIENT_STALE_GRACE_MS = 150000;
let chatMessages = {};
function configuredMessage(path, parameters = {}) {
const segments = String(path || '').split('.').filter(Boolean);
let current = chatMessages;
for (const segment of segments) {
if (!current || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, segment)) {
return '';
}
current = current[segment];
}
if (typeof current !== 'string') {
return '';
}
return current.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => {
if (!Object.prototype.hasOwnProperty.call(parameters, key)) {
return match;
}
return String(parameters[key] ?? '').replace(/\s+/g, ' ').trim();
});
}
function configuredMessageList(path) {
const segments = String(path || '').split('.').filter(Boolean);
let current = chatMessages;
for (const segment of segments) {
if (!current || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, segment)) {
return [];
}
current = current[segment];
}
return Array.isArray(current) ? current.filter((item) => typeof item === 'string' && item.trim() !== '') : [];
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function configuredEmphasis(path) {
const text = configuredMessage(path);
return text === '' ? '' : `<em>${escapeHtml(text)}</em>`;
}
async function loadConfiguredChatMessages() {
try {
const response = await fetch('/chat-messages/frontend', {
method: 'GET',
cache: 'no-store',
headers: {'Accept': 'application/json'},
});
if (!response.ok) {
console.error('Chat messages request failed with status:', response.status);
return;
}
const payload = await response.json();
chatMessages = payload && typeof payload === 'object' ? payload : {};
} catch (err) {
console.error('Chat messages load failed:', err);
}
}
function applyConfiguredUiMessages(root = document) {
const documentTitle = configuredMessage('document.title');
if (documentTitle !== '') {
document.title = documentTitle;
}
root.querySelectorAll('[data-chat-message-text]').forEach((element) => {
element.textContent = configuredMessage(element.getAttribute('data-chat-message-text') || '');
});
root.querySelectorAll('[data-chat-message-placeholder]').forEach((element) => {
element.setAttribute('placeholder', configuredMessage(element.getAttribute('data-chat-message-placeholder') || ''));
});
root.querySelectorAll('[data-chat-message-aria-label]').forEach((element) => {
element.setAttribute('aria-label', configuredMessage(element.getAttribute('data-chat-message-aria-label') || ''));
});
}
await loadConfiguredChatMessages();
applyConfiguredUiMessages();
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 configuredMessageList('guards.no_concrete_shop_response_markers')
.some((marker) => normalized.includes(marker.toLowerCase()));
}
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', escapeHtml(configuredMessage('assistant.loader')), '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(/&nbsp;/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 canonicalRetriexSourceChipKey(value) {
return String(value || '')
.replace(/\u00a0/g, ' ')
.replace(/[‐‑‒–—]/g, '-')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '');
}
function deduplicateRetriexSourceChips(container) {
if (!container) {
return;
}
container.querySelectorAll('.retriex-source-overview__badges').forEach((badgeGroup) => {
const seen = new Set();
badgeGroup.querySelectorAll('.retriex-source-chip').forEach((chip) => {
let key = canonicalRetriexSourceChipKey(chip.textContent);
if (key === 'shopsystem' || key === 'liveshopdaten') {
chip.textContent = configuredMessage('source_chips.live_shop_data');
key = 'liveshopdaten';
}
if (seen.has(key)) {
chip.remove();
return;
}
seen.add(key);
});
});
}
function deduplicateRetriexMetaCards(container) {
if (!container) {
return;
}
deduplicateRetriexSourceChips(container);
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 ? configuredMessage('run_meta.interrupted_title') : configuredMessage('run_meta.completed_title');
const statusText = isError ? configuredMessage('run_meta.interrupted_status') : configuredMessage('run_meta.completed_status');
const emptySourceText = isError ? configuredMessage('run_meta.interrupted_empty_source') : configuredMessage('run_meta.completed_empty_source');
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() === configuredMessage('run_meta.pending_source_marker')) {
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;
let completedStatusSeenAt = null;
let lastClientProgressAt = Date.now();
const source = new EventSource(`/ask-sse/${encodeURIComponent(jobId)}`);
state.eventSource = source;
let networkErrorTimer = null;
let jobStatusTimer = null;
const clearNetworkErrorTimer = () => {
if (!networkErrorTimer) {
return;
}
clearTimeout(networkErrorTimer);
networkErrorTimer = null;
};
const clearJobStatusTimer = () => {
if (!jobStatusTimer) {
return;
}
clearTimeout(jobStatusTimer);
jobStatusTimer = null;
};
const clearStreamTimers = () => {
clearNetworkErrorTimer();
clearJobStatusTimer();
};
const parsePositiveInteger = (value) => {
const parsed = Number.parseInt(String(value || ''), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
};
const complete = () => {
if (finished) {
return;
}
finished = true;
clearStreamTimers();
finishEventStream();
resolve();
};
const fail = (err) => {
if (finished) {
return;
}
finished = true;
clearStreamTimers();
finishEventStream();
reject(err);
};
const completeFromStatus = (statusPayload) => {
if (finished || state.abortRequested) {
complete();
return;
}
const statusLastEventId = parsePositiveInteger(statusPayload?.lastEventId);
if (statusLastEventId > lastSseEventId) {
completedStatusSeenAt ??= Date.now();
if (Date.now() - completedStatusSeenAt < JOB_COMPLETION_CATCHUP_GRACE_MS) {
return;
}
}
finalizeStream(bubble, raw);
rememberCompletedTurn(prompt, raw);
complete();
};
const completeWithJobError = (message) => {
appendError(message || configuredMessage('stream.incomplete'));
complete();
};
const pollJobStatus = async () => {
if (finished || state.abortRequested) {
return;
}
try {
const res = await fetch(`/ask-jobs/${encodeURIComponent(jobId)}`, {
method: 'GET',
cache: 'no-store',
headers: {'Accept': 'application/json'},
});
if (!res.ok) {
if (res.status === 404 && Date.now() - lastClientProgressAt > JOB_CLIENT_STALE_GRACE_MS) {
completeWithJobError(configuredMessage('stream.job_not_found_retry'));
}
return;
}
const statusPayload = await res.json();
const status = String(statusPayload?.status || '');
const message = String(statusPayload?.message || '').trim();
if (status === 'completed') {
completeFromStatus(statusPayload);
return;
}
if (status === 'failed') {
completeWithJobError(message || configuredMessage('stream.failed_retry'));
return;
}
if (status === 'interrupted') {
completeWithJobError(message || configuredMessage('stream.interrupted_retry'));
return;
}
if (status === 'missing') {
completeWithJobError(message || configuredMessage('stream.missing_retry'));
return;
}
if (status === 'running') {
const updatedAt = parsePositiveInteger(statusPayload?.updatedAt);
const serverTime = parsePositiveInteger(statusPayload?.serverTime);
const staleAfterSeconds = parsePositiveInteger(statusPayload?.runningStaleAfterSeconds);
if (updatedAt > 0 && serverTime > 0 && staleAfterSeconds > 0 && serverTime - updatedAt > staleAfterSeconds + 15) {
completeWithJobError(message || configuredMessage('stream.stale_retry'));
return;
}
}
} catch (err) {
console.debug('Stream job status polling failed:', err);
} finally {
if (!finished && !state.abortRequested) {
jobStatusTimer = setTimeout(pollJobStatus, JOB_STATUS_POLL_INTERVAL_MS);
}
}
};
state.completeStream = complete;
state.failStream = fail;
jobStatusTimer = setTimeout(pollJobStatus, JOB_STATUS_POLL_INTERVAL_MS);
source.onopen = () => {
clearNetworkErrorTimer();
lastClientProgressAt = Date.now();
};
source.onmessage = (event) => {
if (state.abortRequested || finished) {
complete();
return;
}
clearNetworkErrorTimer();
lastClientProgressAt = Date.now();
completedStatusSeenAt = null;
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 = configuredMessage('stream.connection_interrupted_retry');
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', configuredEmphasis('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', configuredEmphasis('assistant.history_cleared'));
});
});