Neue Helper-Funktionen extrahiert: - _runStream(): SSE-Stream-Loop aus sendMessage() + handleChoiceClick() (chat.js) - _createMessageBubble() + _addBubbleActions(): Bubble-Erstellung aus 6+ Stellen (bubbles.js) - createAttachmentIndicator(): Attachment-Anzeige aus bubbles/history/archive (bubbles.js) - _renderMessagesIntoFragment(): Shared zwischen loadOlderMessages + loadLatestArchive (history.js) - _isReviewMessage(): Review/Finalizer/Preparer-Check aus 8+ Stellen (history.js) - _renderChoiceBubbleInFragment(): Choice-Bubble-Rendering extrahiert (history.js) - _buildDiffLines(): Diff-Zeilen aus buildEditDiff + buildWritePreview (tool-ui.js) - _buildAvatarTransformStyle(): Avatar-Transform aus buildSessionCard + buildAgentCard (cards.js)
435 lines
16 KiB
JavaScript
435 lines
16 KiB
JavaScript
// ── Image Attachments Auto-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
|
|
}
|
|
}
|
|
|
|
// ── History State ──
|
|
|
|
let lastKnownMessageId = 0;
|
|
let _oldestLoadedMessageId = 0;
|
|
let _hasMoreOlderMessages = false;
|
|
let historyPollInterval = null;
|
|
|
|
|
|
// ── History Loading ──
|
|
|
|
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, _mergeConsecutiveAssistantTexts(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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Helper Functions ──
|
|
|
|
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;
|
|
if (prev && prev.type === 'text' && prev.role === 'assistant'
|
|
&& prev.iteration === msg.iteration
|
|
&& !_isReviewMessage(msg.content) && !_isReviewMessage(prev.content)) {
|
|
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;
|
|
}
|
|
|
|
// ── Fragment Rendering (shared between loadOlderMessages + loadLatestArchive) ──
|
|
|
|
/**
|
|
* Renders a merged message list into a DocumentFragment.
|
|
* @param {DocumentFragment} fragment
|
|
* @param {Array} msgs - Already merged messages
|
|
* @param {Array} imageMessageIds - Collects message IDs with image attachments
|
|
* @param {object} opts - { archived: bool }
|
|
*/
|
|
function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) {
|
|
const archived = opts && opts.archived;
|
|
for (var _msgIdx = 0; _msgIdx < msgs.length; _msgIdx++) {
|
|
const msg = msgs[_msgIdx];
|
|
switch (msg.type) {
|
|
case 'text': {
|
|
const isReviewMsg = _isReviewMessage(msg.content);
|
|
if (msg.role === 'user' && !isReviewMsg) {
|
|
const div = _createMessageBubble({ role: 'user', timestamp: msg.createdAt, messageId: msg.id, isMarked: msg.isMarked, deletedOn: msg.deletedOn, archived });
|
|
renderUserContent(div, msg.content);
|
|
_addBubbleActions(div, { timestamp: msg.createdAt, messageId: msg.id, deletedOn: msg.deletedOn, isMarked: msg.isMarked });
|
|
fragment.appendChild(div);
|
|
if (msg.attachmentCount > 0) {
|
|
if (/\[Bild: [^\]]+\]/.test(msg.content) && imageMessageIds) {
|
|
imageMessageIds.push(msg.id);
|
|
}
|
|
div.appendChild(createAttachmentIndicator(msg.id, msg.attachmentCount, msg.attachmentNames));
|
|
}
|
|
} else if (isReviewMsg) {
|
|
const div = _createMessageBubble({ role: msg.role, extraClass: 'review-bubble', timestamp: msg.createdAt, messageId: msg.id, isMarked: msg.isMarked, deletedOn: msg.deletedOn, archived });
|
|
div.innerHTML = renderMarkdown(msg.content);
|
|
parseFluentEmojis(div);
|
|
_addBubbleActions(div, { timestamp: msg.createdAt, messageId: msg.id, deletedOn: msg.deletedOn, isMarked: msg.isMarked });
|
|
fragment.appendChild(div);
|
|
} else if (msg.role === 'assistant') {
|
|
const div = _createMessageBubble({ role: 'assistant', timestamp: msg.createdAt, messageId: msg.id, isMarked: msg.isMarked, deletedOn: msg.deletedOn, archived });
|
|
div.innerHTML = renderMarkdown(msg.content);
|
|
parseFluentEmojis(div);
|
|
_addBubbleActions(div, { timestamp: msg.createdAt, messageId: msg.id, deletedOn: msg.deletedOn, isMarked: msg.isMarked });
|
|
fragment.appendChild(div);
|
|
}
|
|
break;
|
|
}
|
|
case 'tool_use':
|
|
if (msg.toolName === 'ask_user_choice') {
|
|
_renderChoiceBubbleInFragment(fragment, msgs, _msgIdx);
|
|
continue;
|
|
}
|
|
if (!isToolVisible(msg.toolName)) continue;
|
|
var toolDiv = document.createElement('div');
|
|
toolDiv.className = 'tool-box' + (archived ? ' archived' : '');
|
|
renderToolBoxContent(toolDiv, msg.toolName, msg.content, null, false);
|
|
fragment.appendChild(toolDiv);
|
|
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;
|
|
var wsDiv = document.createElement('div');
|
|
wsDiv.className = 'tool-box' + (archived ? ' archived' : '');
|
|
wsDiv.dataset.toolName = 'web_search';
|
|
renderToolBoxContent(wsDiv, 'web_search', null, null, false, wsSources);
|
|
fragment.appendChild(wsDiv);
|
|
break;
|
|
}
|
|
case 'tool_result': {
|
|
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': {
|
|
var errDiv = document.createElement('div');
|
|
errDiv.className = 'msg system-info' + (archived ? ' archived' : '');
|
|
errDiv.style.color = 'var(--dangerous)';
|
|
errDiv.textContent = (msg.content || '').trim();
|
|
fragment.appendChild(errDiv);
|
|
break;
|
|
}
|
|
default:
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
function _isReviewMessage(content) {
|
|
return content && (content.startsWith('[Review:') || content.startsWith('[Review-Feedback') || content.startsWith('[Finalizer:') || content.startsWith('[Preparer:'));
|
|
}
|
|
|
|
function _renderChoiceBubbleInFragment(fragment, msgs, msgIdx) {
|
|
var msg = msgs[msgIdx];
|
|
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 || '')];
|
|
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;
|
|
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;
|
|
cbtn.classList.add(co === selectedChoice ? 'choice-selected' : 'choice-unselected');
|
|
} else {
|
|
(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);
|
|
}
|
|
}
|
|
|
|
// ── Render Messages (for pollHistory + loadHistory) ──
|
|
|
|
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 (_isReviewMessage(msg.content)) {
|
|
addReviewBubbleStatic(msg.content, msg.createdAt, msg.id, msg.isMarked, msg.deletedOn);
|
|
} else if (msg.role === 'user') {
|
|
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') {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
}
|