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.
313 lines
12 KiB
JavaScript
313 lines
12 KiB
JavaScript
// ── User Content Rendering ──
|
|
|
|
function renderUserContent(container, text, inlineImages) {
|
|
const raw = (text || '').trim();
|
|
|
|
// Reply-Zitat erkennen und als visuellen Block rendern
|
|
// Format: > [Zitat: HH:MM, Role]:\n> text...\n\nrest
|
|
const quoteMatch = raw.match(/^> \[Zitat: ([^\]]+)\]:\n((?:> .*\n?)*)\n*([\s\S]*)$/);
|
|
if (quoteMatch) {
|
|
const quoteMeta = quoteMatch[1]; // z.B. "14:32, Assistant"
|
|
const quoteBody = quoteMatch[2].replace(/^> /gm, '').trim();
|
|
const restText = quoteMatch[3].trim();
|
|
|
|
const quoteEl = document.createElement('div');
|
|
quoteEl.className = 'reply-quote';
|
|
const metaEl = document.createElement('div');
|
|
metaEl.className = 'reply-quote-meta';
|
|
metaEl.innerHTML = '<i class="fa fa-reply"></i> ' + esc(quoteMeta);
|
|
quoteEl.appendChild(metaEl);
|
|
const bodyEl = document.createElement('div');
|
|
bodyEl.className = 'reply-quote-text';
|
|
bodyEl.textContent = truncate(quoteBody, 200);
|
|
quoteEl.appendChild(bodyEl);
|
|
container.appendChild(quoteEl);
|
|
|
|
// Rest als normalen Text weiterverarbeiten
|
|
if (restText) {
|
|
_renderUserTextContent(container, restText, inlineImages);
|
|
}
|
|
return;
|
|
}
|
|
|
|
_renderUserTextContent(container, raw, inlineImages);
|
|
}
|
|
|
|
function _renderUserTextContent(container, raw, inlineImages) {
|
|
// [Bild: name] durch Platzhalter ersetzen
|
|
const imagePattern = /\[Bild: ([^\]]+)\]/g;
|
|
const imageRefs = [];
|
|
let mdText = raw.replace(imagePattern, function(m, name) {
|
|
imageRefs.push(name);
|
|
return '%%IMGREF' + (imageRefs.length - 1) + '%%';
|
|
});
|
|
|
|
// [Datei: path] oder [Datei: path:1-10] durch Platzhalter ersetzen (vor Markdown-Parsing)
|
|
const pattern = /\[Datei: ([^\]]+)\]/g;
|
|
const fileRefs = [];
|
|
mdText = mdText.replace(pattern, function(m, ref) {
|
|
fileRefs.push(ref);
|
|
return '%%FILEREF' + (fileRefs.length - 1) + '%%';
|
|
});
|
|
|
|
// Markdown rendern
|
|
let html = renderMarkdown(mdText);
|
|
|
|
// Bild-Platzhalter durch Inline-Bilder ersetzen
|
|
html = html.replace(/%%IMGREF(\d+)%%/g, function(m, idx) {
|
|
const name = imageRefs[parseInt(idx)];
|
|
// Wenn Inline-Bild-Daten vorhanden (beim Senden), direkt anzeigen
|
|
const img = inlineImages && inlineImages.find(function(i) { return i.name === name; });
|
|
if (img) {
|
|
return '<img class="inline-user-image" src="' + esc(img.dataUrl) + '" alt="' + esc(name) + '" title="' + esc(name) + '">';
|
|
}
|
|
// Sonst: Platzhalter mit data-Attribut für späteres Laden
|
|
return '<span class="inline-image-placeholder" data-filename="' + esc(name) + '">'
|
|
+ '<i class="fa fa-image"></i> ' + esc(name) + '</span>';
|
|
});
|
|
|
|
// Datei-Platzhalter durch File-Ref-Chips ersetzen (als HTML)
|
|
html = html.replace(/%%FILEREF(\d+)%%/g, function(m, idx) {
|
|
const ref = fileRefs[parseInt(idx)];
|
|
const filePath = ref.replace(/:\d+(-\d+)?$/, '');
|
|
const fileName = filePath.includes('/') ? filePath.substring(filePath.lastIndexOf('/') + 1) : filePath;
|
|
const lineInfo = ref.replace(filePath, '').trim();
|
|
return '<span class="file-ref-chip" data-filepath="' + esc(filePath) + '" title="' + esc(ref) + '" style="cursor:pointer">'
|
|
+ '<i class="fa fa-file-text-o"></i> ' + esc(fileName + lineInfo) + '</span>';
|
|
});
|
|
|
|
// In ein Wrapper-Div rendern (damit vorherige Elemente wie Reply-Quotes erhalten bleiben)
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'user-text-content';
|
|
wrap.innerHTML = html;
|
|
container.appendChild(wrap);
|
|
|
|
// Click-Handler für File-Ref-Chips nachträglich binden
|
|
wrap.querySelectorAll('.file-ref-chip[data-filepath]').forEach(function(chip) {
|
|
chip.addEventListener('click', function() {
|
|
openFileExplorer({ sessionId: getCurrentSessionId(), initialFile: chip.dataset.filepath });
|
|
});
|
|
});
|
|
|
|
// Click-Handler für Inline-Bilder (Vollbild)
|
|
wrap.querySelectorAll('.inline-user-image').forEach(function(img) {
|
|
img.addEventListener('click', function() {
|
|
showKreaImageModal(img.alt || 'Bild', img.src);
|
|
});
|
|
});
|
|
|
|
// Fluent Emoji DOM-Parsing
|
|
parseFluentEmojis(wrap);
|
|
}
|
|
|
|
// ── Message Bubbles ──
|
|
|
|
function addUserBubble(text, streamed = false, timestamp = '', messageId = null, isMarked = false, deletedOn = null, inlineImages = null) {
|
|
const div = document.createElement('div');
|
|
div.className = 'msg user markdown' + (isMarked ? ' marked' : '');
|
|
if (deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden');
|
|
div.dataset.role = 'user';
|
|
div.dataset.timestamp = timestamp || new Date().toISOString();
|
|
if (messageId) div.dataset.messageId = messageId;
|
|
if (streamed) div.classList.add('streamed');
|
|
renderUserContent(div, text, inlineImages);
|
|
_addTimestamp(div, timestamp);
|
|
_addReplyButton(div);
|
|
_addDeleteButton(div, messageId, deletedOn);
|
|
_addMarkButton(div, messageId, isMarked);
|
|
_addShareButton(div, messageId);
|
|
chatEl().appendChild(div);
|
|
scrollDown();
|
|
}
|
|
|
|
function addAssistantBubble(text, timestamp = '', messageId = null, isMarked = false, deletedOn = null) {
|
|
const div = document.createElement('div');
|
|
div.className = 'msg assistant markdown' + (isMarked ? ' marked' : '');
|
|
if (deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden');
|
|
div.dataset.role = 'assistant';
|
|
div.dataset.timestamp = timestamp || new Date().toISOString();
|
|
if (messageId) div.dataset.messageId = messageId;
|
|
div.innerHTML = renderMarkdown(text);
|
|
parseFluentEmojis(div);
|
|
wrapKreaGalleries(div);
|
|
_addTimestamp(div, timestamp);
|
|
_addReplyButton(div);
|
|
_addDeleteButton(div, messageId, deletedOn);
|
|
_addMarkButton(div, messageId, isMarked);
|
|
_addShareButton(div, messageId);
|
|
chatEl().appendChild(div);
|
|
scrollDown();
|
|
return div;
|
|
}
|
|
|
|
function addErrorBubble(text, streamed = false) {
|
|
const div = document.createElement('div');
|
|
div.className = 'msg system-info';
|
|
if (streamed) div.classList.add('streamed');
|
|
div.style.color = 'var(--dangerous)';
|
|
div.innerHTML = '<i class="fa fa-times-circle"></i> ' + esc((text || '').trim());
|
|
chatEl().appendChild(div);
|
|
scrollDown();
|
|
}
|
|
|
|
function addWarningBubble(text, streamed = false) {
|
|
const div = document.createElement('div');
|
|
div.className = 'msg system-info';
|
|
if (streamed) div.classList.add('streamed');
|
|
div.style.color = 'var(--warning)';
|
|
div.innerHTML = '<i class="fa fa-exclamation-triangle"></i> ' + esc((text || '').trim());
|
|
chatEl().appendChild(div);
|
|
scrollDown();
|
|
}
|
|
|
|
// ── Choice Bubbles (ask_user_choice) ──
|
|
|
|
// Wird live aus dem SSE-Stream aufgerufen
|
|
function addChoiceBubble(toolCallId, question, choices, streamed = true) {
|
|
// Guard: choices muss ein Array sein
|
|
if (!Array.isArray(choices)) choices = [String(choices)];
|
|
|
|
const div = document.createElement('div');
|
|
div.className = 'msg choice-bubble';
|
|
if (streamed) div.classList.add('streamed');
|
|
div.dataset.toolCallId = toolCallId;
|
|
|
|
const questionDiv = document.createElement('div');
|
|
questionDiv.className = 'choice-question';
|
|
questionDiv.innerHTML = renderMarkdown(question);
|
|
parseFluentEmojis(questionDiv);
|
|
div.appendChild(questionDiv);
|
|
|
|
const buttonsDiv = document.createElement('div');
|
|
buttonsDiv.className = 'choice-buttons';
|
|
for (const choice of choices) {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'choice-btn';
|
|
btn.textContent = choice;
|
|
btn.addEventListener('click', () => handleChoiceClick(toolCallId, choice, div));
|
|
buttonsDiv.appendChild(btn);
|
|
}
|
|
div.appendChild(buttonsDiv);
|
|
|
|
chatEl().appendChild(div);
|
|
scrollDown();
|
|
return div;
|
|
}
|
|
|
|
// Wird für History-Rendering aufgerufen (bereits beantwortete Choices)
|
|
function addChoiceBubbleStatic(toolCallId, question, choices, selectedChoice, timestamp, messageId) {
|
|
// Guard: choices muss ein Array sein
|
|
if (!Array.isArray(choices)) choices = [String(choices)];
|
|
|
|
const div = document.createElement('div');
|
|
div.className = 'msg choice-bubble choice-answered';
|
|
div.dataset.toolCallId = toolCallId;
|
|
if (messageId) div.dataset.messageId = messageId;
|
|
|
|
const questionDiv = document.createElement('div');
|
|
questionDiv.className = 'choice-question';
|
|
questionDiv.innerHTML = renderMarkdown(question);
|
|
parseFluentEmojis(questionDiv);
|
|
div.appendChild(questionDiv);
|
|
|
|
const buttonsDiv = document.createElement('div');
|
|
buttonsDiv.className = 'choice-buttons';
|
|
for (const choice of choices) {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'choice-btn';
|
|
btn.disabled = true;
|
|
btn.textContent = choice;
|
|
if (choice === selectedChoice) {
|
|
btn.classList.add('choice-selected');
|
|
} else {
|
|
btn.classList.add('choice-unselected');
|
|
}
|
|
buttonsDiv.appendChild(btn);
|
|
}
|
|
div.appendChild(buttonsDiv);
|
|
|
|
if (timestamp) _addTimestamp(div, timestamp);
|
|
chatEl().appendChild(div);
|
|
scrollDown();
|
|
return div;
|
|
}
|
|
|
|
// ── Review Bubbles (Static / History) ──
|
|
|
|
function addReviewBubbleStatic(text, timestamp, messageId, isMarked, deletedOn) {
|
|
const div = document.createElement('div');
|
|
div.className = 'msg review-bubble markdown' + (isMarked ? ' marked' : '');
|
|
if (deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden');
|
|
div.dataset.role = 'assistant';
|
|
div.dataset.timestamp = timestamp || '';
|
|
if (messageId) div.dataset.messageId = messageId;
|
|
div.innerHTML = renderMarkdown(text);
|
|
parseFluentEmojis(div);
|
|
_addTimestamp(div, timestamp);
|
|
_addReplyButton(div);
|
|
_addDeleteButton(div, messageId, deletedOn);
|
|
_addMarkButton(div, messageId, isMarked);
|
|
_addShareButton(div, messageId);
|
|
chatEl().appendChild(div);
|
|
scrollDown();
|
|
return div;
|
|
}
|
|
|
|
// ── Attachment Indicator ──
|
|
|
|
function addAttachmentIndicator(messageId, count, namesJson) {
|
|
const chat = chatEl();
|
|
const lastMsg = chat.lastElementChild;
|
|
if (!lastMsg) return;
|
|
|
|
let names = [];
|
|
try { if (namesJson) names = JSON.parse(namesJson); } catch {}
|
|
|
|
const indicator = document.createElement('div');
|
|
indicator.className = 'attachment-indicator';
|
|
|
|
const nameList = names.filter(n => n).join(', ');
|
|
indicator.innerHTML = `<i class="fa fa-paperclip"></i> ${count} Datei${count > 1 ? 'en' : ''}` + (nameList ? `: ${esc(nameList)}` : '');
|
|
indicator.style.cursor = 'pointer';
|
|
indicator.title = 'Klicken zum Anzeigen';
|
|
indicator.addEventListener('click', () => loadAndShowAttachments(messageId, indicator));
|
|
lastMsg.appendChild(indicator);
|
|
}
|
|
|
|
async function loadAndShowAttachments(messageId, indicator) {
|
|
if (indicator.dataset.loaded) return;
|
|
indicator.dataset.loaded = '1';
|
|
indicator.innerHTML = '<i class="fa fa-paperclip"></i> Laden...';
|
|
|
|
try {
|
|
const resp = await fetch(`/api/attachments/${messageId}`);
|
|
const attachments = await resp.json();
|
|
|
|
const container = document.createElement('div');
|
|
container.className = 'attachment-container';
|
|
|
|
for (const att of attachments) {
|
|
if (att.type === 'image') {
|
|
const img = document.createElement('img');
|
|
img.src = `data:${att.mediaType};base64,${att.data}`;
|
|
img.className = 'attachment-image';
|
|
img.title = att.fileName || 'Bild';
|
|
container.appendChild(img);
|
|
} else {
|
|
const link = document.createElement('a');
|
|
link.href = `data:${att.mediaType};base64,${att.data}`;
|
|
link.download = att.fileName || 'datei';
|
|
link.className = 'attachment-file';
|
|
link.innerHTML = `<i class="fa fa-file-text-o"></i> ${esc(att.fileName || 'Dokument')}`;
|
|
container.appendChild(link);
|
|
}
|
|
}
|
|
|
|
indicator.innerHTML = `<i class="fa fa-paperclip"></i> ${attachments.length} Datei${attachments.length > 1 ? 'en' : ''}`;
|
|
indicator.parentElement.appendChild(container);
|
|
} catch (e) {
|
|
indicator.innerHTML = '<i class="fa fa-paperclip"></i> Fehler beim Laden';
|
|
indicator.dataset.loaded = '';
|
|
}
|
|
}
|