From b56b83ad1e01748d13874ef20568d32c5f32678a Mon Sep 17 00:00:00 2001 From: Meleki Date: Sat, 7 Mar 2026 21:37:41 +0100 Subject: [PATCH] Refactor: JavaScript-Code-Duplikate eliminiert (-227 Zeilen) 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) --- wwwroot/js/archive.js | 99 +------------ wwwroot/js/bubbles.js | 101 +++++++------- wwwroot/js/cards.js | 19 +-- wwwroot/js/chat.js | 238 +++++++++++--------------------- wwwroot/js/history.js | 232 ++++++++++++++----------------- wwwroot/js/pages/shared-page.js | 10 +- wwwroot/js/tool-ui.js | 72 +++------- 7 files changed, 272 insertions(+), 499 deletions(-) diff --git a/wwwroot/js/archive.js b/wwwroot/js/archive.js index efcb09a..37d0fc5 100644 --- a/wwwroot/js/archive.js +++ b/wwwroot/js/archive.js @@ -49,104 +49,7 @@ async function loadLatestArchive() { 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 = ` ${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 + _renderMessagesIntoFragment(fragment, _mergeConsecutiveAssistantTexts(entry.messages), archiveImageIds, { archived: true }); if (entry.chain && isChainMetaVisible(entry.chain)) { const badges = buildMetaBadges(entry.chain); const metaDiv = document.createElement('div'); diff --git a/wwwroot/js/bubbles.js b/wwwroot/js/bubbles.js index c7ab002..850bd37 100644 --- a/wwwroot/js/bubbles.js +++ b/wwwroot/js/bubbles.js @@ -100,41 +100,69 @@ function _renderUserTextContent(container, raw, inlineImages) { parseFluentEmojis(wrap); } +// ── Message Bubble Helpers ── + +/** + * Creates a message bubble div with common setup (className, dataset, deletedOn, action buttons). + * @param {object} opts - { role, extraClass, timestamp, messageId, isMarked, deletedOn, streamed, archived } + */ +function _createMessageBubble(opts) { + const div = document.createElement('div'); + const classes = ['msg', opts.extraClass || opts.role, 'markdown']; + if (opts.isMarked) classes.push('marked'); + if (opts.archived) classes.push('archived'); + if (opts.streamed) classes.push('streamed'); + div.className = classes.join(' '); + if (opts.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden'); + div.dataset.role = opts.role || ''; + div.dataset.timestamp = opts.timestamp || new Date().toISOString(); + if (opts.messageId) div.dataset.messageId = opts.messageId; + return div; +} + +/** + * Adds standard action buttons (timestamp, reply, delete, mark, share) to a bubble. + */ +function _addBubbleActions(div, opts) { + _addTimestamp(div, opts.timestamp); + _addReplyButton(div); + _addDeleteButton(div, opts.messageId, opts.deletedOn); + _addMarkButton(div, opts.messageId, opts.isMarked); + _addShareButton(div, opts.messageId); +} + +/** + * Creates an attachment indicator element for a message. + */ +function createAttachmentIndicator(messageId, count, namesJson) { + let names = []; + try { if (namesJson) names = JSON.parse(namesJson); } catch {} + const nameList = names.filter(n => n).join(', '); + const ind = document.createElement('div'); + ind.className = 'attachment-indicator'; + ind.innerHTML = ` ${count} Datei${count > 1 ? 'en' : ''}` + (nameList ? `: ${esc(nameList)}` : ''); + ind.style.cursor = 'pointer'; + ind.title = 'Klicken zum Anzeigen'; + ind.addEventListener('click', () => loadAndShowAttachments(messageId, ind)); + return ind; +} + // ── 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'); + const div = _createMessageBubble({ role: 'user', timestamp, messageId, isMarked, deletedOn, streamed }); renderUserContent(div, text, inlineImages); - _addTimestamp(div, timestamp); - _addReplyButton(div); - _addDeleteButton(div, messageId, deletedOn); - _addMarkButton(div, messageId, isMarked); - _addShareButton(div, messageId); + _addBubbleActions(div, { timestamp, messageId, deletedOn, isMarked }); 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; + const div = _createMessageBubble({ role: 'assistant', timestamp, messageId, isMarked, deletedOn }); div.innerHTML = renderMarkdown(text); parseFluentEmojis(div); wrapKreaGalleries(div); - _addTimestamp(div, timestamp); - _addReplyButton(div); - _addDeleteButton(div, messageId, deletedOn); - _addMarkButton(div, messageId, isMarked); - _addShareButton(div, messageId); + _addBubbleActions(div, { timestamp, messageId, deletedOn, isMarked }); chatEl().appendChild(div); scrollDown(); return div; @@ -235,19 +263,10 @@ function addChoiceBubbleStatic(toolCallId, question, choices, selectedChoice, ti // ── 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; + const div = _createMessageBubble({ role: 'assistant', extraClass: 'review-bubble', timestamp, messageId, isMarked, deletedOn }); div.innerHTML = renderMarkdown(text); parseFluentEmojis(div); - _addTimestamp(div, timestamp); - _addReplyButton(div); - _addDeleteButton(div, messageId, deletedOn); - _addMarkButton(div, messageId, isMarked); - _addShareButton(div, messageId); + _addBubbleActions(div, { timestamp, messageId, deletedOn, isMarked }); chatEl().appendChild(div); scrollDown(); return div; @@ -259,19 +278,7 @@ 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 = ` ${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); + lastMsg.appendChild(createAttachmentIndicator(messageId, count, namesJson)); } async function loadAndShowAttachments(messageId, indicator) { diff --git a/wwwroot/js/cards.js b/wwwroot/js/cards.js index b1ae60e..4d77d11 100644 --- a/wwwroot/js/cards.js +++ b/wwwroot/js/cards.js @@ -1,3 +1,12 @@ +// ── Avatar Transform Helper ── + +function _buildAvatarTransformStyle(obj) { + const parts = []; + if (obj.avatarScale && obj.avatarScale !== 1) parts.push(`scale(${obj.avatarScale})`); + if (obj.avatarOffsetX || obj.avatarOffsetY) parts.push(`translate(${obj.avatarOffsetX || 0}%, ${obj.avatarOffsetY || 0}%)`); + return parts.length ? ` style="transform: ${parts.join(' ')}"` : ''; +} + // ── Session Card Helper ── // Erzeugt ein .card-Element für eine Session (wiederverwendbar für Dashboard + Picker). @@ -5,10 +14,7 @@ function buildSessionCard(session, agent) { const card = document.createElement('div'); card.className = 'card'; - const transformParts = []; - if (session.avatarScale && session.avatarScale !== 1) transformParts.push(`scale(${session.avatarScale})`); - if (session.avatarOffsetX || session.avatarOffsetY) transformParts.push(`translate(${session.avatarOffsetX || 0}%, ${session.avatarOffsetY || 0}%)`); - const transformStyle = transformParts.length ? ` style="transform: ${transformParts.join(' ')}"` : ''; + const transformStyle = _buildAvatarTransformStyle(session); const imageHtml = session.image ? `` : ''; // Badges @@ -47,10 +53,7 @@ function buildAgentCard(agent, skills) { const card = document.createElement('div'); card.className = 'card'; - const agentTransformParts = []; - if (agent.avatarScale && agent.avatarScale !== 1) agentTransformParts.push(`scale(${agent.avatarScale})`); - if (agent.avatarOffsetX || agent.avatarOffsetY) agentTransformParts.push(`translate(${agent.avatarOffsetX || 0}%, ${agent.avatarOffsetY || 0}%)`); - const agentTransformStyle = agentTransformParts.length ? ` style="transform: ${agentTransformParts.join(' ')}"` : ''; + const agentTransformStyle = _buildAvatarTransformStyle(agent); const imageHtml = agent.avatar ? `` : ''; // Badges diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index 48aff1d..0e43d22 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -204,11 +204,88 @@ function stopStreaming() { if (sid) apiStopChat(sid).catch(function() {}); } +// ── SSE Stream Runner (shared between sendMessage + handleChoiceClick) ── + +async function _runStream(fetchPromise) { + const sendBtn = $('#send'); + const stopBtn = $('#stop'); + const sid = getCurrentSessionId(); + + sendBtn.disabled = true; + sendBtn.hidden = true; + stopBtn.hidden = false; + isStreaming = true; + _abortController = new AbortController(); + _currentToolBox = null; + _chatAbandoned = false; + setMetaHtml(' Streaming...'); + + try { + const resp = await fetchPromise(_abortController.signal); + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let eventType = null; + + while (true) { + const { done, value } = await reader.read(); + if (done || _chatAbandoned) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('event: ')) { + eventType = line.substring(7).trim(); + } else if (line.startsWith('data: ') && eventType) { + let data; + try { data = JSON.parse(line.substring(6)); } catch { continue; } + handleStreamEvent(eventType, data); + eventType = null; + } + } + } + } catch (e) { + if (_chatAbandoned) return; + endStreamBubble(); + if (e.name === 'AbortError') { + setMeta('Abgebrochen'); + await new Promise(r => setTimeout(r, 500)); + } else { + try { + const status = await apiIsStreaming(sid); + if (status.streaming) { + setMetaHtml(' Streaming... (Verbindung wiederhergestellt)'); + startStreamingPoll(sid, sendBtn, status.chainId); + return; + } + } catch (_) {} + addErrorBubble('Netzwerk-Fehler: ' + e.message); + } + } finally { + if (_chatAbandoned) return; + if (!isStreaming || !_streamingPollInterval) { + sendBtn.disabled = false; + sendBtn.hidden = false; + stopBtn.hidden = true; + isStreaming = false; + _abortController = null; + } + endStreamBubble(); + removeStreamedElements(); + await pollHistory(); + if (_lastDoneChainData) { + addChainMetaBubble(_lastDoneChainData, false); + _lastDoneChainData = null; + } + } +} + // ── Send Message ── async function sendMessage() { const input = $('#input'); - const btn = $('#send'); const text = input.value.trim(); if (!text && pendingFiles.length === 0 && pendingRefs.length === 0) return; @@ -271,90 +348,9 @@ async function sendMessage() { addUserBubble(bubbleText, true, '', null, false, null, inlineImages); input.value = ''; - input.dispatchEvent(new Event('input')) - btn.disabled = true; - btn.hidden = true; - const stopBtn = $('#stop'); - stopBtn.hidden = false; - isStreaming = true; - _abortController = new AbortController(); - setMetaHtml(' Streaming...'); + input.dispatchEvent(new Event('input')); - let fullText = ''; - _currentToolBox = null; - - _chatAbandoned = false; - - try { - const resp = await apiChat(content, getCurrentSessionId(), _abortController.signal, _planModeActive); - const reader = resp.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let eventType = null; - - while (true) { - const { done, value } = await reader.read(); - if (done || _chatAbandoned) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (line.startsWith('event: ')) { - eventType = line.substring(7).trim(); - } else if (line.startsWith('data: ') && eventType) { - let data; - try { data = JSON.parse(line.substring(6)); } catch { continue; } - - handleStreamEvent(eventType, data); - eventType = null; - } - } - } - - } catch (e) { - // Bei Navigation nichts tun – der neue Page-Init übernimmt - if (_chatAbandoned) return; - endStreamBubble(); - // User hat Stream abgebrochen - if (e.name === 'AbortError') { - setMeta('Abgebrochen'); - // Kurz warten damit Backend den Disconnect merkt - await new Promise(r => setTimeout(r, 500)); - } else { - // Prüfen ob Backend noch streamt (z.B. bei Netzwerkfehler) - try { - const status = await apiIsStreaming(getCurrentSessionId()); - if (status.streaming) { - // Backend streamt noch weiter – Recovery-Poll starten - setMetaHtml(' Streaming... (Verbindung wiederhergestellt)'); - startStreamingPoll(getCurrentSessionId(), btn, status.chainId); - return; - } - } catch (_) {} - addErrorBubble('Netzwerk-Fehler: ' + e.message); - } - } finally { - // Bei Navigation nichts aufräumen – der neue Page-Init übernimmt - if (_chatAbandoned) return; - if (!isStreaming || !_streamingPollInterval) { - btn.disabled = false; - btn.hidden = false; - stopBtn.hidden = true; - isStreaming = false; - _abortController = null; - } - endStreamBubble(); - - // Gestreamte Elemente entfernen, dann aus DB laden, dann Chain-Meta anhängen - removeStreamedElements(); - await pollHistory(); - if (_lastDoneChainData) { - addChainMetaBubble(_lastDoneChainData, false); - _lastDoneChainData = null; - } - } + await _runStream((signal) => apiChat(content, getCurrentSessionId(), signal, _planModeActive)); } // ── Choice Click Handler ── @@ -372,77 +368,5 @@ async function handleChoiceClick(toolCallId, choice, bubbleDiv) { }); bubbleDiv.classList.add('choice-answered'); - // Stream starten mit Choice-Response - const sid = getCurrentSessionId(); - const sendBtn = $('#send'); - const stopBtn = $('#stop'); - sendBtn.disabled = true; - sendBtn.hidden = true; - stopBtn.hidden = false; - isStreaming = true; - _abortController = new AbortController(); - _currentToolBox = null; - setMetaHtml(' Streaming...'); - _chatAbandoned = false; - - try { - const resp = await apiChatChoice(sid, toolCallId, choice, _abortController.signal); - const reader = resp.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let eventType = null; - - while (true) { - const { done, value } = await reader.read(); - if (done || _chatAbandoned) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (line.startsWith('event: ')) { - eventType = line.substring(7).trim(); - } else if (line.startsWith('data: ') && eventType) { - let data; - try { data = JSON.parse(line.substring(6)); } catch { continue; } - handleStreamEvent(eventType, data); - eventType = null; - } - } - } - } catch (e) { - if (_chatAbandoned) return; - endStreamBubble(); - if (e.name === 'AbortError') { - setMeta('Abgebrochen'); - await new Promise(r => setTimeout(r, 500)); - } else { - try { - const status = await apiIsStreaming(sid); - if (status.streaming) { - setMetaHtml(' Streaming... (Verbindung wiederhergestellt)'); - startStreamingPoll(sid, sendBtn, status.chainId); - return; - } - } catch (_) {} - addErrorBubble('Netzwerk-Fehler: ' + e.message); - } - } finally { - if (_chatAbandoned) return; - if (!isStreaming || !_streamingPollInterval) { - sendBtn.disabled = false; - sendBtn.hidden = false; - stopBtn.hidden = true; - isStreaming = false; - _abortController = null; - } - endStreamBubble(); - removeStreamedElements(); - await pollHistory(); - if (_lastDoneChainData) { - addChainMetaBubble(_lastDoneChainData, false); - _lastDoneChainData = null; - } - } + await _runStream((signal) => apiChatChoice(getCurrentSessionId(), toolCallId, choice, signal)); } diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index 9a5bed4..44cb051 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -104,7 +104,7 @@ async function loadOlderMessages() { const fragment = document.createDocumentFragment(); const imageMessageIds = []; for (const entry of entries) { - _renderMessagesIntoFragment(fragment, entry.messages, imageMessageIds); + _renderMessagesIntoFragment(fragment, _mergeConsecutiveAssistantTexts(entry.messages), imageMessageIds); if (entry.chain && isChainMetaVisible(entry.chain)) { const badges = buildMetaBadges(entry.chain); const metaDiv = document.createElement('div'); @@ -163,10 +163,9 @@ function _mergeConsecutiveAssistantTexts(msgs) { 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.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; @@ -179,151 +178,72 @@ function _mergeConsecutiveAssistantTexts(msgs) { return result; } -// ── Fragment Rendering (for loadOlderMessages) ── +// ── Fragment Rendering (shared between loadOlderMessages + loadLatestArchive) ── -function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) { - msgs = _mergeConsecutiveAssistantTexts(msgs); +/** + * 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]; - 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:')); + const isReviewMsg = _isReviewMessage(msg.content); 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; + const div = _createMessageBubble({ role: 'user', timestamp: msg.createdAt, messageId: msg.id, isMarked: msg.isMarked, deletedOn: msg.deletedOn, archived }); 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); + _addBubbleActions(div, { timestamp: msg.createdAt, messageId: msg.id, deletedOn: msg.deletedOn, isMarked: msg.isMarked }); 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 = ` ${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); + div.appendChild(createAttachmentIndicator(msg.id, msg.attachmentCount, msg.attachmentNames)); } } 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; + 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); - _addTimestamp(div, msg.createdAt); - _addReplyButton(div); - _addDeleteButton(div, msg.id, msg.deletedOn); - _addMarkButton(div, msg.id, msg.isMarked); - _addShareButton(div, msg.id); + _addBubbleActions(div, { timestamp: msg.createdAt, messageId: msg.id, deletedOn: msg.deletedOn, isMarked: msg.isMarked }); 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; + 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); - _addTimestamp(div, msg.createdAt); - _addReplyButton(div); - _addDeleteButton(div, msg.id, msg.deletedOn); - _addMarkButton(div, msg.id, msg.isMarked); - _addShareButton(div, msg.id); + _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') { - // 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); - } + _renderChoiceBubbleInFragment(fragment, msgs, _msgIdx); continue; } if (!isToolVisible(msg.toolName)) continue; - div.className = 'tool-box'; - renderToolBoxContent(div, msg.toolName, msg.content, null, false); - fragment.appendChild(div); + 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; // Leere Suchergebnisse überspringen - div.className = 'tool-box'; - div.dataset.toolName = 'web_search'; - renderToolBoxContent(div, 'web_search', null, null, false, wsSources); - fragment.appendChild(div); + 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': { - // Ü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]; @@ -336,18 +256,74 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) { } continue; } - case 'error': - div.className = 'msg system-info'; - div.style.color = 'var(--dangerous)'; - div.textContent = (msg.content || '').trim(); - fragment.appendChild(div); + 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) { @@ -358,22 +334,16 @@ function renderMessages(msgs, addToConversation) { 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); - } + 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') { - 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); - } + addAssistantBubble(msg.content, msg.createdAt, msg.id, msg.isMarked, msg.deletedOn); } break; diff --git a/wwwroot/js/pages/shared-page.js b/wwwroot/js/pages/shared-page.js index 4fcc6cf..4b40e58 100644 --- a/wwwroot/js/pages/shared-page.js +++ b/wwwroot/js/pages/shared-page.js @@ -40,7 +40,7 @@ registerPage('shared', { continue; } - const isReview = c.startsWith('[Review:') || c.startsWith('[Review-Feedback') || c.startsWith('[Finalizer:'); + const isReview = _isReviewMessage(c); const div = document.createElement('div'); if (isReview) { div.className = 'msg review-bubble markdown' + (c.startsWith('[Finalizer:') ? ' finalizer-bubble' : ''); @@ -77,14 +77,11 @@ function _mergeConsecutiveTexts(msgs) { const result = []; for (const msg of msgs) { const c = msg.content || ''; - // web_search_result und Review/Finalizer-Messages nicht mergen if (msg.type === 'web_search_result') { result.push(Object.assign({}, msg)); continue; } - const isReview = c.startsWith('[Review:') || c.startsWith('[Review-Feedback') || c.startsWith('[Finalizer:'); - if (msg.role === 'assistant' && !isReview && msg.type !== 'web_search_result') { - // Rückwärts suchen – web_search_result überspringen + if (msg.role === 'assistant' && !_isReviewMessage(c) && msg.type !== 'web_search_result') { let prev = null; for (let i = result.length - 1; i >= 0; i--) { if (result[i].type === 'web_search_result') continue; @@ -92,8 +89,7 @@ function _mergeConsecutiveTexts(msgs) { break; } if (prev && prev.role === 'assistant' - && !(prev.content || '').startsWith('[Review:') - && !(prev.content || '').startsWith('[Finalizer:') + && !_isReviewMessage(prev.content) && prev.iteration === msg.iteration) { prev.content = (prev.content || '') + '\n\n' + c; if (msg.createdAt) prev.createdAt = msg.createdAt; diff --git a/wwwroot/js/tool-ui.js b/wwwroot/js/tool-ui.js index 5e0283b..4d9cb53 100644 --- a/wwwroot/js/tool-ui.js +++ b/wwwroot/js/tool-ui.js @@ -160,66 +160,36 @@ function renderToolBoxContent(div, name, inputJson, result, isError, sources) { } } -function buildEditDiff(oldText, newText) { - const oldLines = oldText.split('\n'); - const newLines = newText.split('\n'); +function _buildDiffLines(container, lines, type) { + const cssClass = type === 'added' ? 'diff-added' : 'diff-removed'; + const markerChar = type === 'added' ? '+' : '-'; + for (const line of lines) { + const row = document.createElement('div'); + row.className = 'diff-line ' + cssClass; + const marker = document.createElement('span'); + marker.className = 'diff-marker'; + marker.textContent = markerChar; + const content = document.createElement('span'); + content.className = 'diff-content'; + content.textContent = line; + row.appendChild(marker); + row.appendChild(content); + container.appendChild(row); + } +} +function buildEditDiff(oldText, newText) { const container = document.createElement('div'); container.className = 'tool-diff'; - - // Entfernte Zeilen (-) - for (const line of oldLines) { - const row = document.createElement('div'); - row.className = 'diff-line diff-removed'; - const marker = document.createElement('span'); - marker.className = 'diff-marker'; - marker.textContent = '-'; - const content = document.createElement('span'); - content.className = 'diff-content'; - content.textContent = line; - row.appendChild(marker); - row.appendChild(content); - container.appendChild(row); - } - - // Hinzugefügte Zeilen (+) - for (const line of newLines) { - const row = document.createElement('div'); - row.className = 'diff-line diff-added'; - const marker = document.createElement('span'); - marker.className = 'diff-marker'; - marker.textContent = '+'; - const content = document.createElement('span'); - content.className = 'diff-content'; - content.textContent = line; - row.appendChild(marker); - row.appendChild(content); - container.appendChild(row); - } - + _buildDiffLines(container, oldText.split('\n'), 'removed'); + _buildDiffLines(container, newText.split('\n'), 'added'); return container; } function buildWritePreview(content) { - const lines = content.split('\n'); - const container = document.createElement('div'); container.className = 'tool-diff'; - - for (const line of lines) { - const row = document.createElement('div'); - row.className = 'diff-line diff-added'; - const marker = document.createElement('span'); - marker.className = 'diff-marker'; - marker.textContent = '+'; - const contentSpan = document.createElement('span'); - contentSpan.className = 'diff-content'; - contentSpan.textContent = line; - row.appendChild(marker); - row.appendChild(contentSpan); - container.appendChild(row); - } - + _buildDiffLines(container, content.split('\n'), 'added'); return container; }