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