From 6c38ccfe120ac8182718fbe849fb4a90eb1188d8 Mon Sep 17 00:00:00 2001 From: Meleki Date: Sat, 7 Mar 2026 10:46:45 +0100 Subject: [PATCH] Web-Search: Streaming-Fix (pauseStreamBubble statt resumeStreamBubble), leere Suchergebnisse nicht speichern, Sources auf Shared-Pages anzeigen --- Endpoints/SharedEndpoint.cs | 6 +++-- Services/AnthropicService.cs | 43 ++++++++++++++++++++------------- wwwroot/css/chat.css | 18 +++++++++++++- wwwroot/js/chat.js | 26 +++++++------------- wwwroot/js/history.js | 27 +++++++++------------ wwwroot/js/pages/shared-page.js | 31 +++++++++++++++++++++--- wwwroot/js/ui.js | 26 ++++++-------------- 7 files changed, 103 insertions(+), 74 deletions(-) diff --git a/Endpoints/SharedEndpoint.cs b/Endpoints/SharedEndpoint.cs index 0c52d7e..cdfe168 100644 --- a/Endpoints/SharedEndpoint.cs +++ b/Endpoints/SharedEndpoint.cs @@ -118,13 +118,15 @@ public static class SharedEndpoint // Alle Messages der Chain bis zu dieser Nachricht laden var contextMessages = await db.GetChainMessagesUpTo(message.ChainId, shared.MessageId); - // Nur text-Messages zurückgeben (keine tool_use/tool_result) + // Text- und Web-Search-Messages zurückgeben (keine tool_use/tool_result) + var allowedTypes = new HashSet { "text", "web_search_result" }; var textMessages = contextMessages - .Where(m => m.Type == "text" && m.DeletedOn == null) + .Where(m => allowedTypes.Contains(m.Type) && m.DeletedOn == null) .Select(m => new { m.Id, m.Role, + m.Type, m.Content, m.CreatedAt, m.Iteration diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index b69ff85..4c327e5 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -179,12 +179,15 @@ public class AnthropicService } } } - await _db.SaveMessage(new MessageRecord + if (sources.Count > 0) { - ChainId = chainId, Iteration = iterations, - Role = "assistant", Type = "web_search_result", - Content = JsonSerializer.Serialize(sources) - }); + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = iterations, + Role = "assistant", Type = "web_search_result", + Content = JsonSerializer.Serialize(sources) + }); + } } } @@ -683,14 +686,17 @@ public class AnthropicService } } - await _db.SaveMessage(new MessageRecord + if (sources.Count > 0) { - ChainId = chainId, - Iteration = baseIterations + iterations, - Role = "assistant", - Type = "web_search_result", - Content = JsonSerializer.Serialize(sources) - }); + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, + Iteration = baseIterations + iterations, + Role = "assistant", + Type = "web_search_result", + Content = JsonSerializer.Serialize(sources) + }); + } } else if (blockType == "server_tool_use") { @@ -889,12 +895,15 @@ public class AnthropicService } } } - await _db.SaveMessage(new MessageRecord + if (sources.Count > 0) { - ChainId = ctx.ChainId, Iteration = iterations, - Role = "assistant", Type = "web_search_result", - Content = JsonSerializer.Serialize(sources) - }); + await _db.SaveMessage(new MessageRecord + { + ChainId = ctx.ChainId, Iteration = iterations, + Role = "assistant", Type = "web_search_result", + Content = JsonSerializer.Serialize(sources) + }); + } } else if (blockType == "server_tool_use") { diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index 9a0bd08..e07009e 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -125,7 +125,7 @@ width: .55em; height: 1.15em; background: var(--text); - animation: blink .2s step-end infinite; + animation: blink .5s step-end infinite; vertical-align: text-bottom; margin-left: .15em; border-radius: 1px; @@ -1023,6 +1023,22 @@ padding: var(--page-padding); } +.shared-content .tool-box[data-tool-name="web_search"] { + border: none; + max-width: unset; + font-size: unset; + + .tool-box-header { + font-size: var(--font-size); + opacity: var(--semi-transparent); + cursor: unset !important; + } + + .tool-result { + display: none; + } +} + /* Review Bubbles */ .review-bubble { diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index 9c2f90e..14001f5 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -139,7 +139,6 @@ async function sendMessage() { let fullText = ''; let currentToolBox = null; - let _afterWebSearch = false; // true wenn gerade eine Web-Search beendet wurde _chatAbandoned = false; @@ -167,14 +166,10 @@ async function sendMessage() { switch (eventType) { case 'text_start': - if (_afterWebSearch) { - // Nach Web-Search: letzte Bubble wiederverwenden statt neue - _afterWebSearch = false; - resumeStreamBubble(); - } else { - endStreamBubble(); - startStreamBubble(); - } + // Wenn bereits eine Bubble offen ist (z.B. nach Web-Search-Pause), + // einfach weiter reinschreiben – appendToStream kümmert sich darum. + // Nur wenn keine Bubble existiert, wird eine neue erstellt. + if (!_streamDiv) startStreamBubble(); break; case 'text_delta': @@ -183,11 +178,12 @@ async function sendMessage() { break; case 'text_end': - endStreamBubble(); + // Nur Cursor entfernen, Bubble offen lassen + // (nach Web-Search kommt evtl. noch ein text_start in dieselbe Bubble) + pauseStreamBubble(); break; case 'tool_start': - _afterWebSearch = false; endStreamBubble(); if (isToolVisible(data.name)) { currentToolBox = addToolBoxLoading(data.name); @@ -204,7 +200,8 @@ async function sendMessage() { break; case 'web_search_start': - // Bubble NICHT beenden – Text wird nach Search fortgesetzt + // Bubble pausieren, nicht beenden – Text wird nach Search fortgesetzt + pauseStreamBubble(); currentToolBox = addToolBoxLoading('web_search'); break; @@ -213,11 +210,9 @@ async function sendMessage() { finishToolBox(currentToolBox, { name: 'web_search', result: 'Suche abgeschlossen', isError: false, sources: data.sources }); } currentToolBox = null; - _afterWebSearch = true; break; case 'review_start': - _afterWebSearch = false; endStreamBubble(); addReviewBubble('start', data); break; @@ -239,7 +234,6 @@ async function sendMessage() { break; case 'finalizer_start': - _afterWebSearch = false; endStreamBubble(); addReviewBubble('start', data, 'finalizer'); break; @@ -257,13 +251,11 @@ async function sendMessage() { break; case 'error': - _afterWebSearch = false; endStreamBubble(); addErrorBubble(data.error, true); break; case 'done': - _afterWebSearch = false; endStreamBubble(); if (data.stopReason === 'max_tokens') { addWarningBubble('Antwort wurde abgeschnitten (max_tokens erreicht). Bitte frage nach dem Rest.', true); diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index 6ed94f0..e7c4649 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -156,19 +156,12 @@ function _mergeConsecutiveAssistantTexts(msgs) { const result = []; for (const msg of msgs) { if (msg.type === 'text' && msg.role === 'assistant') { - // Finde den letzten assistant-text in result (überspringe web_search_result dazwischen) - var prev = null; - for (var i = result.length - 1; i >= 0; i--) { - if (result[i].type === 'web_search_result') continue; // Web-Search-Ergebnisse überspringen - if (result[i].type === 'text' && result[i].role === 'assistant') prev = result[i]; - break; - } + 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.iteration === msg.iteration && !isReview && !prevIsReview) { - // Gleiche Iteration: nahtlos zusammenfügen (Web-Search-Fragmente etc.) - prev.content = (prev.content || '') + '\n\n' + (msg.content || ''); - // IDs/Timestamps: letztes behalten (für Mark-Button etc.) + 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; @@ -255,10 +248,11 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) { break; case 'web_search_result': { if (!isToolVisible('web_search')) continue; - div.className = 'tool-box'; - div.dataset.toolName = 'web_search'; 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; @@ -323,7 +317,7 @@ function renderMessages(msgs, addToConversation) { if (isToolVisible('web_search')) { var wsSrc = []; try { wsSrc = JSON.parse(msg.content || '[]'); } catch(e) {} - addToolBoxStatic('web_search', null, null, false, wsSrc); + if (wsSrc.length > 0) addToolBoxStatic('web_search', null, null, false, wsSrc); } break; } @@ -426,10 +420,11 @@ async function loadLatestArchive() { break; case 'web_search_result': { if (!isToolVisible('web_search')) continue; - div.className = 'tool-box archived'; - div.dataset.toolName = 'web_search'; 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; } diff --git a/wwwroot/js/pages/shared-page.js b/wwwroot/js/pages/shared-page.js index 9e2fc15..4fcc6cf 100644 --- a/wwwroot/js/pages/shared-page.js +++ b/wwwroot/js/pages/shared-page.js @@ -26,6 +26,20 @@ registerPage('shared', { for (const msg of messages) { const c = msg.content || ''; + + // Web-Search-Results als Tool-Box mit Sources rendern + if (msg.type === 'web_search_result') { + var wsSources = []; + try { wsSources = JSON.parse(c || '[]'); } catch(e) {} + if (wsSources.length === 0) continue; + const div = document.createElement('div'); + div.className = 'tool-box'; + div.dataset.toolName = 'web_search'; + renderToolBoxContent(div, 'web_search', null, null, false, wsSources); + container.appendChild(div); + continue; + } + const isReview = c.startsWith('[Review:') || c.startsWith('[Review-Feedback') || c.startsWith('[Finalizer:'); const div = document.createElement('div'); if (isReview) { @@ -63,14 +77,25 @@ 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) { - const prev = result[result.length - 1]; + if (msg.role === 'assistant' && !isReview && msg.type !== 'web_search_result') { + // Rückwärts suchen – web_search_result überspringen + let prev = null; + for (let i = result.length - 1; i >= 0; i--) { + if (result[i].type === 'web_search_result') continue; + prev = result[i]; + break; + } if (prev && prev.role === 'assistant' && !(prev.content || '').startsWith('[Review:') && !(prev.content || '').startsWith('[Finalizer:') && prev.iteration === msg.iteration) { - prev.content = (prev.content || '') + ' ' + c; + prev.content = (prev.content || '') + '\n\n' + c; if (msg.createdAt) prev.createdAt = msg.createdAt; continue; } diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js index 9d9a0aa..9d41cff 100644 --- a/wwwroot/js/ui.js +++ b/wwwroot/js/ui.js @@ -749,16 +749,12 @@ function finalizeReviewBubble(type, data, agentType) { let _streamDiv = null; let _streamText = ''; -let _lastStreamDiv = null; -let _lastStreamText = ''; function startStreamBubble() { _streamDiv = document.createElement('div'); _streamDiv.className = 'msg assistant markdown streamed'; _streamDiv.innerHTML = '

'; _streamText = ''; - _lastStreamDiv = null; - _lastStreamText = ''; chatEl().appendChild(_streamDiv); scrollDown(); } @@ -779,6 +775,14 @@ function appendToStream(text) { scrollDown(); } +function pauseStreamBubble() { + // Cursor entfernen, aber Bubble + Text behalten (z.B. bei Web-Search-Pause) + if (_streamDiv) { + const cursor = _streamDiv.querySelector('.cursor'); + if (cursor) cursor.remove(); + } +} + function endStreamBubble() { if (_streamDiv) { const cursor = _streamDiv.querySelector('.cursor'); @@ -790,24 +794,10 @@ function endStreamBubble() { wrapKreaGalleries(_streamDiv); } } - _lastStreamDiv = _streamDiv; - _lastStreamText = _streamText; _streamDiv = null; _streamText = ''; } -function resumeStreamBubble() { - // Letzte finalisierte Bubble wiederverwenden (z.B. nach Web-Search) - if (_lastStreamDiv && _lastStreamDiv.parentNode) { - _streamDiv = _lastStreamDiv; - _streamText = _lastStreamText; - } else { - startStreamBubble(); - } - _lastStreamDiv = null; - _lastStreamText = ''; -} - // ── Tool Call Visibility ── const MODIFICATION_TOOLS = ['write_file', 'edit_file', 'delete_file', 'memory_write'];