diff --git a/public/assets/js/base.js b/public/assets/js/base.js index 10c1c27..ab78e62 100644 --- a/public/assets/js/base.js +++ b/public/assets/js/base.js @@ -1,7 +1,7 @@ document.addEventListener('DOMContentLoaded', () => { - const chatEl = document.getElementById('chat'); + const chatEl = document.getElementById('chat'); const promptEl = document.getElementById('prompt'); - const sendBtn = document.getElementById('send'); + const sendBtn = document.getElementById('send'); const abortBtn = document.getElementById('abort'); const clearBtn = document.getElementById('clear'); @@ -32,14 +32,165 @@ document.addEventListener('DOMContentLoaded', () => { return addMessage('assistant', 'AI is thinking…', 'loader'); } + function cleanupEmptyBlocks(container) { + container.querySelectorAll('p, div, li, blockquote').forEach((el) => { + const html = el.innerHTML + .replace(//gi, '') + .replace(/ /gi, '') + .trim(); + + const text = (el.textContent || '').trim(); + + if (html === '' || text === '') { + el.remove(); + } + }); + } + + function stripAllThinkContent(container) { + const blockSelector = 'p, div, li, blockquote'; + const thinkSpans = Array.from(container.querySelectorAll('.think')); + + if (thinkSpans.length === 0) { + return; + } + + const handledBlocks = new Set(); + + thinkSpans.forEach((span) => { + const block = span.closest(blockSelector) || span.parentElement; + + if (!block || handledBlocks.has(block)) { + return; + } + + handledBlocks.add(block); + + const thinksInBlock = Array.from(block.querySelectorAll('.think')); + const lastThinkInBlock = thinksInBlock[thinksInBlock.length - 1]; + + if (!lastThinkInBlock) { + return; + } + + let node = block.firstChild; + + while (node) { + const next = node.nextSibling; + node.remove(); + + if (node === lastThinkInBlock) { + break; + } + + node = next; + } + + while ( + block.firstChild && + ( + (block.firstChild.nodeType === Node.TEXT_NODE && + block.firstChild.textContent.trim() === '') || + (block.firstChild.nodeType === Node.ELEMENT_NODE && + block.firstChild.tagName === 'BR') + ) + ) { + block.firstChild.remove(); + } + }); + + cleanupEmptyBlocks(container); + } + + function hasNonThinkContent(container) { + const clone = container.cloneNode(true); + + stripAllThinkContent(clone); + cleanupEmptyBlocks(clone); + + if ((clone.textContent || '').trim() !== '') { + return true; + } + + return clone.querySelector( + 'img, table, pre, code, ul, ol, h1, h2, h3, h4, h5, h6, a' + ) !== null; + } + + function cleanupThinkSpans(container) { + if (!container) { + return; + } + + const thinkSpans = Array.from(container.querySelectorAll('.think')); + + if (thinkSpans.length === 0) { + return; + } + + if (hasNonThinkContent(container)) { + stripAllThinkContent(container); + return; + } + + if (thinkSpans.length <= 1) { + return; + } + + const blockSelector = 'p, div, li, blockquote'; + const lastThink = thinkSpans[thinkSpans.length - 1]; + const lastBlock = lastThink.closest(blockSelector) || lastThink.parentElement; + + thinkSpans.slice(0, -1).forEach((span) => { + const block = span.closest(blockSelector) || span.parentElement; + + if (block && block !== lastBlock) { + block.remove(); + return; + } + + if (block === lastBlock) { + span.remove(); + } + }); + + if (lastBlock && lastBlock.contains(lastThink)) { + let node = lastBlock.firstChild; + + while (node && node !== lastThink) { + const next = node.nextSibling; + node.remove(); + node = next; + } + + while ( + lastThink.nextSibling && + ( + (lastThink.nextSibling.nodeType === Node.TEXT_NODE && + lastThink.nextSibling.textContent.trim() === '') || + (lastThink.nextSibling.nodeType === Node.ELEMENT_NODE && + lastThink.nextSibling.tagName === 'BR') + ) + ) { + lastThink.nextSibling.remove(); + } + } + + cleanupEmptyBlocks(container); + } + + function renderBubbleContent(bubble, raw) { + bubble.innerHTML = renderMarkdown(raw); + cleanupThinkSpans(bubble); + chatEl.scrollTop = chatEl.scrollHeight; + } + async function loadHistory() { try { const res = await fetch('/history'); if (!res.ok) return; const messages = await res.json(); - messages.forEach(m => - addMessage(m.role, renderMarkdown(m.text)) - ); + messages.forEach(m => addMessage(m.role, renderMarkdown(m.text))); } catch {} } @@ -47,8 +198,8 @@ document.addEventListener('DOMContentLoaded', () => { promptEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); // verhindert normalen Zeilenumbruch - sendBtn.click(); // löst vorhandene Send-Logik aus + e.preventDefault(); + sendBtn.click(); } }); @@ -64,14 +215,11 @@ document.addEventListener('DOMContentLoaded', () => { let firstChunk = true; let renderTimer = null; - // 🔥 LÖSUNG: Throttled Rendering - maximal alle 100ms function scheduleRender() { if (renderTimer) return; renderTimer = setTimeout(() => { - // Der StreamChunker sendet bereits korrekt strukturierte Chunks - bubble.innerHTML = renderMarkdown(raw); - chatEl.scrollTop = chatEl.scrollHeight; + renderBubbleContent(bubble, raw); renderTimer = null; }, 100); } @@ -101,14 +249,12 @@ document.addEventListener('DOMContentLoaded', () => { const chunk = decoder.decode(value, { stream: true }); sseBuffer += chunk; - // Parse SSE Events (können mehrere data:-Zeilen haben) const events = sseBuffer.split('\n\n'); - sseBuffer = events.pop() || ''; // Letzten unvollständigen Event behalten + sseBuffer = events.pop() || ''; events.forEach(event => { if (!event.trim()) return; - // Sammle alle "data:"-Zeilen und füge \n wieder ein const dataLines = event .split('\n') .filter(line => line.startsWith('data: ')) @@ -116,20 +262,15 @@ document.addEventListener('DOMContentLoaded', () => { if (dataLines.length === 0) return; - // Verbinde mit \n (so wie es vom Backend kam) const text = dataLines.join('\n'); if (text === '[DONE]') { - - // 🔥 Final render flush if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; } - bubble.innerHTML = renderMarkdown(raw); - chatEl.scrollTop = chatEl.scrollHeight; - + renderBubbleContent(bubble, raw); abort = true; return; } @@ -141,16 +282,17 @@ document.addEventListener('DOMContentLoaded', () => { document.getElementById('ai-cloud')?.classList.add('d-none'); } - // Text sammeln und verzögert rendern raw += text; scheduleRender(); }); } - } catch { bubble.innerHTML += '
Error occurred.'; } finally { - if (renderTimer) clearTimeout(renderTimer); + if (renderTimer) { + clearTimeout(renderTimer); + } + sendBtn.disabled = false; abortBtn.disabled = true; clearBtn.disabled = false; diff --git a/public/assets/styles/base.css b/public/assets/styles/base.css index 83e1499..f42a772 100644 --- a/public/assets/styles/base.css +++ b/public/assets/styles/base.css @@ -371,4 +371,42 @@ body { } .text-glow{ text-shadow: #86b7fe 2px 1px 14px; +} + +span.think { + color: #86b7fe; +} + +.think { + display: inline-block; + color: rgba(255, 255, 255, 0.72); + background-image: linear-gradient( + 100deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0) 32%, + rgba(255, 255, 255, 1) 50%, + rgba(255, 255, 255, 0) 58%, + rgba(255, 255, 255, 0) 100% + ); + background-size: 180% 100%; + background-repeat: no-repeat; + background-position: 220% 0; + -webkit-background-clip: text; + background-clip: text; + animation: thinkScan 2.2s linear infinite; +} + +@keyframes thinkScan { + 0% { + background-position: 180% 0; + } + 100% { + background-position: -20% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .think { + animation: none; + } } \ No newline at end of file diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php index 0be01ec..ed13410 100644 --- a/src/Agent/AgentRunner.php +++ b/src/Agent/AgentRunner.php @@ -13,10 +13,11 @@ use App\Knowledge\Retrieval\RetrieverInterface; use Generator; use Psr\Log\LoggerInterface; use Throwable; -use App\Agent\StreamChunker; final readonly class AgentRunner { + private bool $systemMsgOn; + public function __construct( private PromptBuilder $promptBuilder, private ThinkSuppressor $thinkSuppressor, @@ -32,12 +33,14 @@ final readonly class AgentRunner private bool $logContext, ) { + $this->systemMsgOn = true; } - public function run(string $prompt, string $userId, ?bool $includeFullContext = false): Generator + public function run(string $prompt, string $userId, ?bool $includeFullContext = true): Generator { $prompt = trim($prompt); $shopResults = []; + $swagFullOutPut = ''; if ($prompt === '') { yield '❌ Empty prompt.'; @@ -53,6 +56,49 @@ final readonly class AgentRunner // 1) Context strategy // --------------------------------------------------------- //$includeFullContext = false; + if ($includeFullContext) { + + yield $this->systemMsg("Ich analyse deine Anfrage...", "think"); + + $promptSwagSearch = ' + Erzeuge aus dem folgenden Nutzereingabetext einen kurzen Suchtext für die Shopware-6-Suche. + + Regeln: + - Gib nur den finalen Suchtext aus. + - Keine Einleitung, keine Erklärung, keine Anführungszeichen. + - Verwende nur die wichtigsten Suchbegriffe aus dem Text. + - Maximal 6 Keywords, besser weniger. + - Entferne Füllwörter, Höflichkeitsformen und irrelevante Wörter. + - Erhalte Produktnamen, Marken, Modellnummern und zusammengesetzte Begriffe exakt, wenn sie relevant sind. + - Zahlen, die zu einem Produktnamen oder Modell gehören, müssen erhalten bleiben. + - Trenne die Begriffe nur durch Leerzeichen. + + Ausgabeformat: + Keyword1 Keyword2 Keyword3 + + Text: ' . $prompt . ' + '; + + $this->thinkSuppressor->reset(); + foreach ($this->ollamaClient->stream($promptSwagSearch) as $swagToken) { + + if (!is_string($swagToken)) { + continue; + } + + $swagCleanToken = $this->thinkSuppressor->filter($swagToken); + + if ($swagCleanToken === '') { + continue; + } + + $swagFullOutPut .= $swagCleanToken; + } + + yield $this->systemMsg("Ich habe folgende Keywords an die Shopsuche geschickt: " . $swagFullOutPut, "think"); + + } + // --------------------------------------------------------- // 2) Extract URL content (if present) @@ -62,7 +108,7 @@ final readonly class AgentRunner // --------------------------------------------------------- // 3) Retrieve RAG knowledge // --------------------------------------------------------- - yield "Hole Daten aus dem RAG Wissen... \n"; + yield $this->systemMsg("Ich hole relevante Daten aus meinem RAG Wissen...", "think"); $knowledgeChunks = $this->retriever->retrieve($prompt); @@ -71,11 +117,11 @@ final readonly class AgentRunner // --------------------------------------------------------- $commerceMeta = $this->commerceIntentLite->detect($prompt); - $commerceIntent = (string) ($commerceMeta['intent'] ?? CommerceIntentLite::NONE); + $commerceIntent = (string)($commerceMeta['intent'] ?? CommerceIntentLite::NONE); - if($commerceIntent === CommerceIntentLite::ADVISORY_PRODUCT_SEARCH || $commerceIntent === CommerceIntentLite::PRODUCT_SEARCH){ - yield "Rufe Shop auf (type: ".$commerceIntent.")... \n"; - $shopResults = $this->shopSearchService->search($prompt,$commerceIntent); + if ($commerceIntent === CommerceIntentLite::ADVISORY_PRODUCT_SEARCH || $commerceIntent === CommerceIntentLite::PRODUCT_SEARCH) { + yield $this->systemMsg("Rufe Shop auf (type: " . $commerceIntent . ")", "think"); + $shopResults = $swagFullOutPut ? $this->shopSearchService->search($swagFullOutPut, $commerceIntent) : ''; } if ($commerceIntent === CommerceIntentLite::PRODUCT_SEARCH) { @@ -84,13 +130,13 @@ final readonly class AgentRunner $knowledgeChunks = array_slice($knowledgeChunks, 0, 3); } - if($shopResults){ - yield "Verarbeite Shopdaten... \n
\n"; - }else{ - yield "Keine releveanten Shopdaten gefunden... \n
\n"; + if ($shopResults) { + yield $this->systemMsg("Verarbeite Shopdaten...", "think"); + } else { + yield $this->systemMsg("Keine releveanten Shopdaten gefunden...", "think"); } - yield "Denke nach...\n
\n"; + yield $this->systemMsg("Denke nach...", "think"); // --------------------------------------------------------- @@ -123,6 +169,8 @@ final readonly class AgentRunner // --------------------------------------------------------- $fullOutput = ''; $chunker = new StreamChunker(); + $chunker->flush(); + $this->thinkSuppressor->reset(); foreach ($this->ollamaClient->stream($finalPrompt) as $token) { @@ -142,16 +190,16 @@ final readonly class AgentRunner // ⬇️ Token in Chunker geben $chunk = $chunker->push($cleanToken); if ($chunk !== null) { - yield $chunk; + yield $this->systemMsg($chunk, 'answer'); } } // ⬇️ Rest flushen $finalChunk = $chunker->flush(); if ($finalChunk !== null) { - yield $finalChunk; + yield $this->systemMsg($finalChunk, 'answer'); } else { - yield '... no data received from llm'; + yield $this->systemMsg('... no data received from llm', 'err'); } // --------------------------------------------------------- @@ -176,7 +224,20 @@ final readonly class AgentRunner 'exception' => $e, ]); - yield "\n❌ An internal error occurred while processing the request. \nError: " . $e->getMessage(); + $this->systemMsg("\n❌ An internal error occurred while processing the request. \nError: " . $e->getMessage(), 'err'); } } + + private function systemMsg(string $msg, string $type = ''): string + { + if (!$this->systemMsgOn) { + return ''; + } + + return match ($type) { + 'answer' => '' . $msg, + 'err' => '' . $msg . "\n
\n", + 'think' => '' . $msg . "\n" + }; + } } \ No newline at end of file