Die monolithische ui.js (1534 Zeilen) zusammen mit chat.js (612) und history.js (626) in fokussierte Module aufgeteilt: Neue Dateien aus ui.js: - ui-helpers.js (126) - DOM-Utilities, $, esc, truncate, scrollDown, Toast - markdown.js (143) - renderMarkdown, wrapKreaGalleries, LaTeX/Code-Schutz - cards.js (90) - buildSessionCard, buildAgentCard - msg-actions.js (208) - Delete, Mark, Share, Reply-Button, Timestamp - tool-ui.js (332) - Tool-Boxes, Diffs, Todo-Liste, Visibility - modals.js (190) - System-Prompt, Command-Output, Krea-Image Modals - stream-ui.js (124) - Stream-Bubble, Review-Streaming - bubbles.js (312) - User/Assistant/Error/Warning/Choice/Review Bubbles Neue Dateien aus chat.js: - reply.js (54) - Reply/Quote-Handling mit pendingReply State - files.js (110) - File-Upload, Workspace-Refs mit pendingFiles/Refs State Neue Datei aus history.js: - archive.js (173) - Archiv-Loading mit loadedArchiveCount State Verbleibend: - chat.js (448) - Stream-Events, sendMessage, handleChoiceClick, Plan-Mode - history.js (464) - History laden, Polling, Message-Rendering Kein Code verändert, nur verschoben. Alle 94 Funktionen verifiziert. Globale Variablen in die Module verschoben, wo sie primär genutzt werden.
465 lines
18 KiB
JavaScript
465 lines
18 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, 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;
|
||
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;
|
||
}
|
||
|
||
// ── Fragment Rendering (for loadOlderMessages) ──
|
||
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 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 (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;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
}
|