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;
}