Files
gott/wwwroot/js/history.js
T
Meleki a82d8cfb05 Add missing message actions to archive messages
- Archive messages (loadLatestArchive) now render reply, delete, mark
  and share buttons, matching the behavior of _renderMessagesIntoFragment
- Add review-bubble and preparer-bubble detection for archive messages
- Add data-role, data-timestamp attributes for proper reply quoting
- Add isMarked/deletedOn CSS class handling for archive messages
- Also add [Preparer:] to isReviewMsg check in _renderMessagesIntoFragment
  for consistency
2026-03-07 18:53:45 +01:00

627 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── History Loading ──
async function _autoLoadImageAttachments(messageId) {
// Finde die User-Bubble mit dieser messageId
const bubble = document.querySelector('.msg.user[data-message-id="' + messageId + '"]');
if (!bubble) return;
const placeholders = bubble.querySelectorAll('.inline-image-placeholder');
if (placeholders.length === 0) return;
try {
const resp = await fetch('/api/attachments/' + messageId);
const attachments = await resp.json();
const imageAtts = attachments.filter(function(a) { return a.type === 'image'; });
placeholders.forEach(function(ph) {
const filename = ph.dataset.filename;
// Bild anhand Dateiname zuordnen, oder der Reihe nach
const att = imageAtts.find(function(a) { return a.fileName === filename; }) || imageAtts.shift();
if (att) {
const img = document.createElement('img');
img.className = 'inline-user-image';
img.src = 'data:' + att.mediaType + ';base64,' + att.data;
img.alt = filename;
img.title = filename;
img.addEventListener('click', function() { showKreaImageModal(filename, img.src); });
ph.replaceWith(img);
}
});
} catch (e) {
// Platzhalter bleiben stehen bei Fehler
}
}
let lastKnownMessageId = 0;
let _oldestLoadedMessageId = 0;
let _hasMoreOlderMessages = false;
let historyPollInterval = null;
let loadedArchiveCount = 0;
async function loadHistory() {
try {
const sid = getCurrentSessionId();
const data = await apiHistory(sid, null, isDeletedVisible());
const entries = data.entries || [];
// Älteste geladene Message-ID merken für Nachladen
_hasMoreOlderMessages = data.hasMore;
_oldestLoadedMessageId = _findOldestMessageId(entries);
if (data.hasMore) {
showOlderMessagesButton(data.remainingCount);
} else {
// Keine älteren Nachrichten mehr → Archiv-Button anzeigen falls vorhanden
const archives = await apiListArchives(sid);
if (archives.length > 0) {
showArchiveLoadButton();
}
}
for (const entry of entries) {
renderMessages(entry.messages, true);
if (entry.chain) addChainMetaBubble(entry.chain, false);
}
scrollDown();
setMeta('Bereit');
startHistoryPolling();
} catch (e) {
console.error('History laden fehlgeschlagen:', e);
}
}
async function loadOlderMessages() {
if (!_hasMoreOlderMessages || !_oldestLoadedMessageId) return;
const btn = chatEl().querySelector('.older-messages-btn');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-hourglass-half"></i> Wird geladen...';
}
try {
const sid = getCurrentSessionId();
const data = await apiHistory(sid, _oldestLoadedMessageId, isDeletedVisible());
const entries = data.entries || [];
_hasMoreOlderMessages = data.hasMore;
const newOldest = _findOldestMessageId(entries);
if (newOldest > 0) _oldestLoadedMessageId = newOldest;
// Button entfernen
removeOlderMessagesButton();
// Scroll-Position merken
const chat = chatEl();
const oldScrollHeight = chat.scrollHeight;
// Nachrichten oben einfügen (vor dem ersten .msg Element)
const fragment = document.createDocumentFragment();
const imageMessageIds = [];
for (const entry of entries) {
_renderMessagesIntoFragment(fragment, entry.messages, imageMessageIds);
if (entry.chain && isChainMetaVisible(entry.chain)) {
const badges = buildMetaBadges(entry.chain);
const metaDiv = document.createElement('div');
metaDiv.className = 'chain-meta';
metaDiv.innerHTML = badges.map(b => '<span class="chain-meta-badge">' + esc(b) + '</span>').join('');
fragment.appendChild(metaDiv);
}
}
// Einfügen: vor dem ersten .msg oder .chain-meta Element
const firstMsg = chat.querySelector('.msg, .chain-meta, .tool-box');
if (firstMsg) {
chat.insertBefore(fragment, firstMsg);
} else {
chat.appendChild(fragment);
}
// Bild-Attachments automatisch laden
for (const mid of imageMessageIds) _autoLoadImageAttachments(mid);
// Neuen Button oder Archiv-Button anzeigen
if (data.hasMore) {
showOlderMessagesButton(data.remainingCount);
} else {
const archives = await apiListArchives(sid);
if (archives.length > 0) {
showArchiveLoadButton();
}
}
// Scroll-Position beibehalten
const newScrollHeight = chat.scrollHeight;
chat.scrollTop += (newScrollHeight - oldScrollHeight);
} catch (e) {
console.error('Ältere Nachrichten laden fehlgeschlagen:', e);
if (_hasMoreOlderMessages) {
showOlderMessagesButton(0);
}
}
}
function _findOldestMessageId(entries) {
for (const entry of entries) {
if (entry.messages && entry.messages.length > 0) {
return entry.messages[0].id;
}
}
return 0;
}
function _mergeConsecutiveAssistantTexts(msgs) {
const result = [];
for (const msg of msgs) {
if (msg.type === 'text' && msg.role === 'assistant') {
const prev = result.length > 0 ? result[result.length - 1] : null;
const isReview = msg.content && (msg.content.startsWith('[Review:') || msg.content.startsWith('[Finalizer:'));
const prevIsReview = prev && prev.content && (prev.content.startsWith('[Review:') || prev.content.startsWith('[Finalizer:'));
if (prev && prev.type === 'text' && prev.role === 'assistant'
&& prev.iteration === msg.iteration && !isReview && !prevIsReview) {
prev.content = (prev.content || '') + ' ' + (msg.content || '');
if (msg.id) prev.id = msg.id;
if (msg.createdAt) prev.createdAt = msg.createdAt;
if (msg.isMarked) prev.isMarked = true;
continue;
}
}
result.push(Object.assign({}, msg));
}
return result;
}
function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) {
msgs = _mergeConsecutiveAssistantTexts(msgs);
for (var _msgIdx = 0; _msgIdx < msgs.length; _msgIdx++) {
const msg = msgs[_msgIdx];
const div = document.createElement('div');
switch (msg.type) {
case 'text': {
const isReviewMsg = msg.content && (msg.content.startsWith('[Review:') || msg.content.startsWith('[Review-Feedback') || msg.content.startsWith('[Finalizer:') || msg.content.startsWith('[Preparer:'));
if (msg.role === 'user' && !isReviewMsg) {
div.className = 'msg user markdown' + (msg.isMarked ? ' marked' : '');
if (msg.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden');
div.dataset.role = 'user';
div.dataset.timestamp = msg.createdAt || '';
if (msg.id) div.dataset.messageId = msg.id;
renderUserContent(div, msg.content);
_addTimestamp(div, msg.createdAt);
_addReplyButton(div);
_addDeleteButton(div, msg.id, msg.deletedOn);
_addMarkButton(div, msg.id, msg.isMarked);
_addShareButton(div, msg.id);
fragment.appendChild(div);
if (msg.attachmentCount > 0) {
// Prüfe ob Bilder dabei sind (Content enthält [Bild: ...])
if (/\[Bild: [^\]]+\]/.test(msg.content) && imageMessageIds) {
imageMessageIds.push(msg.id);
}
let names = [];
try { if (msg.attachmentNames) names = JSON.parse(msg.attachmentNames); } catch {}
const nameList = names.filter(n => n).join(', ');
const ind = document.createElement('div');
ind.className = 'attachment-indicator';
ind.innerHTML = `<i class="fa fa-paperclip"></i> ${msg.attachmentCount} Datei${msg.attachmentCount > 1 ? 'en' : ''}` + (nameList ? `: ${esc(nameList)}` : '');
ind.style.cursor = 'pointer';
ind.title = 'Klicken zum Anzeigen';
ind.addEventListener('click', () => loadAndShowAttachments(msg.id, ind));
div.appendChild(ind);
}
} else if (isReviewMsg) {
div.className = 'msg review-bubble markdown' + (msg.isMarked ? ' marked' : '');
if (msg.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden');
div.dataset.role = msg.role;
div.dataset.timestamp = msg.createdAt || '';
if (msg.id) div.dataset.messageId = msg.id;
div.innerHTML = renderMarkdown(msg.content);
parseFluentEmojis(div);
_addTimestamp(div, msg.createdAt);
_addReplyButton(div);
_addDeleteButton(div, msg.id, msg.deletedOn);
_addMarkButton(div, msg.id, msg.isMarked);
_addShareButton(div, msg.id);
fragment.appendChild(div);
} else if (msg.role === 'assistant') {
div.className = 'msg assistant markdown' + (msg.isMarked ? ' marked' : '');
if (msg.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden');
div.dataset.role = 'assistant';
div.dataset.timestamp = msg.createdAt || '';
if (msg.id) div.dataset.messageId = msg.id;
div.innerHTML = renderMarkdown(msg.content);
parseFluentEmojis(div);
_addTimestamp(div, msg.createdAt);
_addReplyButton(div);
_addDeleteButton(div, msg.id, msg.deletedOn);
_addMarkButton(div, msg.id, msg.isMarked);
_addShareButton(div, msg.id);
fragment.appendChild(div);
}
break;
}
case 'tool_use':
if (msg.toolName === 'ask_user_choice') {
// ask_user_choice: als Choice-Bubble rendern
try {
var choiceInput = typeof msg.content === 'string' ? JSON.parse(msg.content) : msg.content;
var choiceQuestion = choiceInput.question || '';
var choiceOptions = Array.isArray(choiceInput.choices) ? choiceInput.choices : [String(choiceInput.choices || '')];
// Voraus-Suche nach zugehörigem tool_result
var selectedChoice = null;
for (var ci = _msgIdx + 1; ci < msgs.length; ci++) {
if (msgs[ci].type === 'tool_result' && msgs[ci].toolCallId === msg.toolCallId) {
selectedChoice = msgs[ci].content;
msgs[ci]._choiceHandled = true; // Markieren damit es nicht nochmal gerendert wird
break;
}
}
var choiceDiv = document.createElement('div');
choiceDiv.className = 'msg choice-bubble' + (selectedChoice ? ' choice-answered' : '');
choiceDiv.dataset.toolCallId = msg.toolCallId || '';
if (msg.id) choiceDiv.dataset.messageId = msg.id;
var qDiv = document.createElement('div');
qDiv.className = 'choice-question';
qDiv.innerHTML = renderMarkdown(choiceQuestion);
parseFluentEmojis(qDiv);
choiceDiv.appendChild(qDiv);
var btnsDiv = document.createElement('div');
btnsDiv.className = 'choice-buttons';
for (var co of choiceOptions) {
var cbtn = document.createElement('button');
cbtn.className = 'choice-btn';
cbtn.textContent = co;
if (selectedChoice) {
cbtn.disabled = true;
if (co === selectedChoice) {
cbtn.classList.add('choice-selected');
} else {
cbtn.classList.add('choice-unselected');
}
} else {
// Noch nicht beantwortet – klickbar machen
(function(toolCallId, choice, bubble) {
cbtn.addEventListener('click', function() { handleChoiceClick(toolCallId, choice, bubble); });
})(msg.toolCallId, co, choiceDiv);
}
btnsDiv.appendChild(cbtn);
}
choiceDiv.appendChild(btnsDiv);
if (msg.createdAt) _addTimestamp(choiceDiv, msg.createdAt);
fragment.appendChild(choiceDiv);
} catch(e) {
console.error('Choice-Bubble Rendering fehlgeschlagen:', e);
}
continue;
}
if (!isToolVisible(msg.toolName)) continue;
div.className = 'tool-box';
renderToolBoxContent(div, msg.toolName, msg.content, null, false);
fragment.appendChild(div);
break;
case 'web_search_result': {
if (!isToolVisible('web_search')) continue;
var wsSources = [];
try { wsSources = JSON.parse(msg.content || '[]'); } catch(e) {}
if (wsSources.length === 0) continue; // Leere Suchergebnisse überspringen
div.className = 'tool-box';
div.dataset.toolName = 'web_search';
renderToolBoxContent(div, 'web_search', null, null, false, wsSources);
fragment.appendChild(div);
break;
}
case 'tool_result': {
// Überspringe tool_results die bereits von einer Choice-Bubble verarbeitet wurden
if (msg._choiceHandled) continue;
const prevBoxes = fragment.querySelectorAll('.tool-box');
const lastToolBox = prevBoxes[prevBoxes.length - 1];
if (lastToolBox && !lastToolBox.querySelector('.tool-result')) {
const rd = document.createElement('div');
rd.className = 'tool-result' + (msg.isError ? ' tool-error' : '');
rd.textContent = truncate(msg.content || '', 300);
rd.hidden = true;
lastToolBox.appendChild(rd);
}
continue;
}
case 'error':
div.className = 'msg system-info';
div.style.color = 'var(--dangerous)';
div.textContent = (msg.content || '').trim();
fragment.appendChild(div);
break;
default:
continue;
}
}
}
function renderMessages(msgs, addToConversation) {
msgs = _mergeConsecutiveAssistantTexts(msgs);
for (var _rmIdx = 0; _rmIdx < msgs.length; _rmIdx++) {
const msg = msgs[_rmIdx];
if (msg.id > lastKnownMessageId) lastKnownMessageId = msg.id;
switch (msg.type) {
case 'text':
if (msg.role === 'user') {
if (msg.content && msg.content.startsWith('[Review-Feedback')) {
addReviewBubbleStatic(msg.content, msg.createdAt, msg.id, msg.isMarked, msg.deletedOn);
} else {
addUserBubble(msg.content, false, msg.createdAt, msg.id, msg.isMarked, msg.deletedOn);
if (msg.attachmentCount > 0) {
_autoLoadImageAttachments(msg.id);
addAttachmentIndicator(msg.id, msg.attachmentCount, msg.attachmentNames);
}
}
} else if (msg.role === 'assistant') {
if (msg.content && (msg.content.startsWith('[Review:') || msg.content.startsWith('[Finalizer:'))) {
addReviewBubbleStatic(msg.content, msg.createdAt, msg.id, msg.isMarked, msg.deletedOn);
} else {
addAssistantBubble(msg.content, msg.createdAt, msg.id, msg.isMarked, msg.deletedOn);
}
}
break;
case 'tool_use':
if (msg.toolName === 'ask_user_choice') {
try {
var choiceInput2 = typeof msg.content === 'string' ? JSON.parse(msg.content) : msg.content;
var choiceQ2 = choiceInput2.question || '';
var choiceOpts2 = Array.isArray(choiceInput2.choices) ? choiceInput2.choices : [String(choiceInput2.choices || '')];
// Voraus-Suche nach zugehörigem tool_result
var selectedChoice2 = null;
for (var ci2 = _rmIdx + 1; ci2 < msgs.length; ci2++) {
if (msgs[ci2].type === 'tool_result' && msgs[ci2].toolCallId === msg.toolCallId) {
selectedChoice2 = msgs[ci2].content;
msgs[ci2]._choiceHandled = true;
break;
}
}
if (selectedChoice2) {
addChoiceBubbleStatic(msg.toolCallId, choiceQ2, choiceOpts2, selectedChoice2, msg.createdAt, msg.id);
} else {
addChoiceBubble(msg.toolCallId, choiceQ2, choiceOpts2, false);
}
} catch(e) {
console.error('Choice-Bubble Rendering fehlgeschlagen:', e);
}
break;
}
if (isToolVisible(msg.toolName)) {
addToolBoxStatic(msg.toolName, msg.content, null, false);
}
break;
case 'web_search_result': {
if (isToolVisible('web_search')) {
var wsSrc = [];
try { wsSrc = JSON.parse(msg.content || '[]'); } catch(e) {}
if (wsSrc.length > 0) addToolBoxStatic('web_search', null, null, false, wsSrc);
}
break;
}
case 'tool_result':
if (msg._choiceHandled) break;
// tool_result wird dem vorherigen tool_use-Box angehängt wenn möglich
appendToolResult(msg.content, msg.isError);
break;
case 'error':
addErrorBubble(msg.content);
break;
}
}
}
// ── Archive Loading ──
function getOrCreateArchiveContainer() {
let container = chatEl().querySelector('.archive-container');
if (!container) {
container = document.createElement('div');
container.className = 'archive-container';
chatEl().prepend(container);
}
return container;
}
function getOrCreateArchiveDivider() {
let divider = chatEl().querySelector('.archive-divider');
if (!divider) {
divider = document.createElement('div');
divider.className = 'msg system-info archive-divider';
divider.textContent = '── Aktuelle Konversation ──';
// Nach dem Container, vor den aktiven Messages
const container = getOrCreateArchiveContainer();
container.after(divider);
}
return divider;
}
async function loadLatestArchive() {
try {
const archives = await apiListArchives(getCurrentSessionId());
if (archives.length <= loadedArchiveCount) return;
const latest = archives[loadedArchiveCount];
loadedArchiveCount++;
removeArchiveLoadButton();
const data = await apiGetArchive(latest.archiveId);
const container = getOrCreateArchiveContainer();
getOrCreateArchiveDivider();
// Archiv-Block zusammenbauen
const fragment = document.createDocumentFragment();
const sep = document.createElement('div');
sep.className = 'msg system-info';
sep.innerHTML = `<i class="fa fa-archive"></i> Archiv vom ${new Date(latest.createdAt).toLocaleString('de-AT')} (${latest.chainCount} Chains, ${latest.messageCount} Messages)`;
fragment.appendChild(sep);
const archiveImageIds = [];
for (const entry of data) {
const mergedMsgs = _mergeConsecutiveAssistantTexts(entry.messages);
for (const msg of mergedMsgs) {
const div = document.createElement('div');
switch (msg.type) {
case 'text': {
const isReviewMsg = msg.content && (msg.content.startsWith('[Review:') || msg.content.startsWith('[Review-Feedback') || msg.content.startsWith('[Finalizer:') || msg.content.startsWith('[Preparer:'));
if (msg.role === 'user' && !isReviewMsg) {
div.className = 'msg user markdown archived' + (msg.isMarked ? ' marked' : '');
if (msg.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden');
div.dataset.role = 'user';
div.dataset.timestamp = msg.createdAt || '';
if (msg.id) div.dataset.messageId = msg.id;
renderUserContent(div, msg.content);
_addTimestamp(div, msg.createdAt);
_addReplyButton(div);
_addDeleteButton(div, msg.id, msg.deletedOn);
_addMarkButton(div, msg.id, msg.isMarked);
_addShareButton(div, msg.id);
} else if (isReviewMsg) {
div.className = 'msg review-bubble markdown archived' + (msg.isMarked ? ' marked' : '');
if (msg.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden');
div.dataset.role = msg.role;
div.dataset.timestamp = msg.createdAt || '';
if (msg.id) div.dataset.messageId = msg.id;
div.innerHTML = renderMarkdown(msg.content);
parseFluentEmojis(div);
_addTimestamp(div, msg.createdAt);
_addReplyButton(div);
_addDeleteButton(div, msg.id, msg.deletedOn);
_addMarkButton(div, msg.id, msg.isMarked);
_addShareButton(div, msg.id);
} else if (msg.role === 'assistant') {
div.className = 'msg assistant markdown archived' + (msg.isMarked ? ' marked' : '');
if (msg.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden');
div.dataset.role = 'assistant';
div.dataset.timestamp = msg.createdAt || '';
if (msg.id) div.dataset.messageId = msg.id;
div.innerHTML = renderMarkdown(msg.content);
parseFluentEmojis(div);
_addTimestamp(div, msg.createdAt);
_addReplyButton(div);
_addDeleteButton(div, msg.id, msg.deletedOn);
_addMarkButton(div, msg.id, msg.isMarked);
_addShareButton(div, msg.id);
}
if (msg.role === 'user' && msg.attachmentCount > 0) {
if (/\[Bild: [^\]]+\]/.test(msg.content)) archiveImageIds.push(msg.id);
let names = [];
try { if (msg.attachmentNames) names = JSON.parse(msg.attachmentNames); } catch {}
const nameList = names.filter(n => n).join(', ');
const ind = document.createElement('div');
ind.className = 'attachment-indicator';
ind.innerHTML = `<i class="fa fa-paperclip"></i> ${msg.attachmentCount} Datei${msg.attachmentCount > 1 ? 'en' : ''}` + (nameList ? `: ${esc(nameList)}` : '');
ind.style.cursor = 'pointer';
ind.title = 'Klicken zum Anzeigen';
ind.addEventListener('click', () => loadAndShowAttachments(msg.id, ind));
div.appendChild(ind);
}
break;
}
case 'tool_use':
if (!isToolVisible(msg.toolName)) continue;
div.className = 'tool-box archived';
renderToolBoxContent(div, msg.toolName, msg.content, null, false);
break;
case 'web_search_result': {
if (!isToolVisible('web_search')) continue;
var archSrc = [];
try { archSrc = JSON.parse(msg.content || '[]'); } catch(e) {}
if (archSrc.length === 0) continue; // Leere Suchergebnisse überspringen
div.className = 'tool-box archived';
div.dataset.toolName = 'web_search';
renderToolBoxContent(div, 'web_search', null, null, false, archSrc);
break;
}
case 'tool_result': {
// An vorherigen tool-box anhängen
const prevBoxes = fragment.querySelectorAll ? fragment.querySelectorAll('.tool-box') : [];
const lastToolBox = prevBoxes[prevBoxes.length - 1];
if (lastToolBox && !lastToolBox.querySelector('.tool-result')) {
const rd = document.createElement('div');
rd.className = 'tool-result' + (msg.isError ? ' tool-error' : '');
rd.textContent = truncate(msg.content || '', 300);
lastToolBox.appendChild(rd);
}
continue; // kein separates div
}
case 'error':
div.className = 'msg system-info archived';
div.style.color = 'var(--dangerous)';
div.textContent = (msg.content || '').trim();
break;
default:
continue;
}
fragment.appendChild(div);
}
// Chain-Meta-Badges nach jeder Chain einfügen
if (entry.chain && isChainMetaVisible(entry.chain)) {
const badges = buildMetaBadges(entry.chain);
const metaDiv = document.createElement('div');
metaDiv.className = 'chain-meta archived';
metaDiv.innerHTML = badges.map(b => '<span class="chain-meta-badge">' + esc(b) + '</span>').join('');
fragment.appendChild(metaDiv);
}
}
// Älteres Archiv vor dem bisherigen Inhalt einfügen
container.prepend(fragment);
// Bild-Attachments automatisch laden
for (const mid of archiveImageIds) _autoLoadImageAttachments(mid);
// Button ganz oben, falls es noch ältere Archive gibt
if (archives.length > loadedArchiveCount) {
showArchiveLoadButton();
}
} catch (e) {
console.error('Archiv laden fehlgeschlagen:', e);
}
}
// ── Polling ──
let _pollLock = false;
function startHistoryPolling(intervalMs = 3000) {
if (historyPollInterval) return;
historyPollInterval = setInterval(pollHistory, intervalMs);
}
function stopHistoryPolling() {
if (historyPollInterval) {
clearInterval(historyPollInterval);
historyPollInterval = null;
}
}
async function pollHistory(force = false) {
if (isStreaming && !force) return;
if (_pollLock) return;
_pollLock = true;
try {
const newMessages = await apiHistoryPoll(getCurrentSessionId(), lastKnownMessageId, isDeletedVisible());
if (newMessages.length > 0) {
renderMessages(newMessages, true);
scrollDown();
}
} catch (e) {
console.error('History poll fehlgeschlagen:', e);
} finally {
_pollLock = false;
}
}