diff --git a/README_ERROR_VISIBILITY_PATCH.md b/README_ERROR_VISIBILITY_PATCH.md
new file mode 100644
index 0000000..642a698
--- /dev/null
+++ b/README_ERROR_VISIBILITY_PATCH.md
@@ -0,0 +1,10 @@
+# RetrieX error visibility patch
+
+This patch makes Shopware Store API authentication/configuration failures visible in the chat stream and improves client-side display of SSE/job errors.
+
+Changed files:
+- src/Shopware/StoreApiClient.php
+- src/Shopware/StoreApiException.php
+- src/Agent/AgentRunner.php
+- src/Controller/AskSseController.php
+- public/assets/js/base.js
diff --git a/public/assets/js/base.js b/public/assets/js/base.js
index cba6c90..f54eac9 100644
--- a/public/assets/js/base.js
+++ b/public/assets/js/base.js
@@ -69,8 +69,28 @@ document.addEventListener('DOMContentLoaded', () => {
) !== null;
}
+ function isWhitespaceTextNode(node) {
+ return node.nodeType === Node.TEXT_NODE && (node.textContent || '').trim() === '';
+ }
+
+ function isBreakNode(node) {
+ return node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR';
+ }
+
+ function trimEdgeBreaks(element) {
+ while (element.firstChild && (isWhitespaceTextNode(element.firstChild) || isBreakNode(element.firstChild))) {
+ element.firstChild.remove();
+ }
+
+ while (element.lastChild && (isWhitespaceTextNode(element.lastChild) || isBreakNode(element.lastChild))) {
+ element.lastChild.remove();
+ }
+ }
+
function cleanupEmptyBlocks(container) {
container.querySelectorAll('p, div, li, blockquote').forEach((el) => {
+ trimEdgeBreaks(el);
+
const html = el.innerHTML
.replace(/
/gi, '')
.replace(/ /gi, '')
@@ -87,6 +107,8 @@ document.addEventListener('DOMContentLoaded', () => {
el.remove();
}
});
+
+ trimEdgeBreaks(container);
}
function removeThinkSpansOnly(container) {
@@ -165,6 +187,7 @@ document.addEventListener('DOMContentLoaded', () => {
const thinkSpans = Array.from(container.querySelectorAll('.think'));
if (thinkSpans.length === 0) {
+ cleanupEmptyBlocks(container);
return;
}
@@ -350,7 +373,8 @@ document.addEventListener('DOMContentLoaded', () => {
firstChunk = false;
}
- raw += `\n\n${safeMessage}`;
+ const formattedMessage = `${safeMessage}`;
+ raw += raw.trim() === '' ? formattedMessage : `\n\n${formattedMessage}`;
finalizeStream(bubble, raw);
};
@@ -486,7 +510,8 @@ document.addEventListener('DOMContentLoaded', () => {
const userMessage = 'Die Verbindung zum Antwort-Stream wurde unterbrochen. Bitte sende die Anfrage erneut, falls die Antwort unvollständig ist.';
if (raw.trim() !== '') {
- raw += `\n\n${userMessage}`;
+ const formattedMessage = `${userMessage}`;
+ raw += raw.trim() === '' ? formattedMessage : `\n\n${formattedMessage}`;
renderBubbleContent(bubble, raw);
} else {
bubble.innerHTML = `${userMessage}`;
diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php
index 06bc43e..c685459 100644
--- a/src/Agent/AgentRunner.php
+++ b/src/Agent/AgentRunner.php
@@ -156,7 +156,7 @@ final readonly class AgentRunner
yield $this->systemMsg(
$this->buildShopUnavailableMessage($primaryShopSearchFailureReason),
- 'info'
+ 'err'
);
$repairPayload = [
@@ -811,12 +811,21 @@ final readonly class AgentRunner
private function buildUserErrorMessage(Throwable $e): string
{
+ $message = trim($e->getMessage());
+
+ if ($message === '') {
+ $message = $e::class;
+ }
+
+ $safeMessage = htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+
if (!$this->debug) {
- return $this->agentRunnerConfig->getGenericInternalErrorMessage();
+ return $this->agentRunnerConfig->getGenericInternalErrorMessage()
+ . '
Technischer Fehler: ' . $safeMessage . '';
}
return $this->agentRunnerConfig->getDebugInternalErrorPrefix()
- . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+ . $safeMessage;
}
private function badge(string $label): string
diff --git a/src/Controller/AskSseController.php b/src/Controller/AskSseController.php
index 6bee4a0..fc7be8c 100644
--- a/src/Controller/AskSseController.php
+++ b/src/Controller/AskSseController.php
@@ -55,9 +55,9 @@ final readonly class AskSseController
'includeFullContext' => $includeFullContext,
'createdAt' => time(),
]);
- } catch (\Throwable) {
+ } catch (\Throwable $e) {
return new JsonResponse(
- ['error' => 'Stream job could not be created.'],
+ ['error' => 'Stream job could not be created: ' . $this->formatThrowableForClient($e)],
Response::HTTP_INTERNAL_SERVER_ERROR
);
}
@@ -139,6 +139,7 @@ final readonly class AskSseController
?Response $cookieResponse
): void {
$this->prepareStreamRuntime();
+ $this->registerStreamShutdownErrorHandler();
if ($cookieResponse !== null) {
foreach ($cookieResponse->headers->getCookies() as $cookie) {
@@ -167,13 +168,51 @@ final readonly class AskSseController
} catch (\Throwable $e) {
$this->sendEvent(
'error',
- '❌ Stream abgebrochen: ' . $e->getMessage()
+ '❌ Stream abgebrochen: ' . $this->formatThrowableForClient($e)
);
}
$this->sendEvent('done', '[DONE]');
}
+ private function registerStreamShutdownErrorHandler(): void
+ {
+ register_shutdown_function(function (): void {
+ $error = error_get_last();
+
+ if ($error === null) {
+ return;
+ }
+
+ $fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
+
+ if (!in_array((int) ($error['type'] ?? 0), $fatalTypes, true)) {
+ return;
+ }
+
+ $message = sprintf(
+ '❌ Fataler Serverfehler: %s in %s:%s',
+ (string) ($error['message'] ?? 'unknown error'),
+ (string) ($error['file'] ?? 'unknown file'),
+ (string) ($error['line'] ?? '?')
+ );
+
+ $this->sendEvent('error', $message);
+ $this->sendEvent('done', '[DONE]');
+ });
+ }
+
+ private function formatThrowableForClient(\Throwable $e): string
+ {
+ $message = trim($e->getMessage());
+
+ if ($message === '') {
+ $message = $e::class;
+ }
+
+ return preg_replace('/\s+/u', ' ', $message) ?? $message;
+ }
+
private function prepareStreamRuntime(): void
{
@set_time_limit(0);
diff --git a/src/Shopware/StoreApiClient.php b/src/Shopware/StoreApiClient.php
index 754f321..730de82 100644
--- a/src/Shopware/StoreApiClient.php
+++ b/src/Shopware/StoreApiClient.php
@@ -83,19 +83,32 @@ final readonly class StoreApiClient
{
$preview = mb_substr(trim($content), 0, 1000);
$utf8Failure = $this->containsUtf8FailureSignal($preview);
- $serverFailure = $statusCode >= 500;
+ $normalizedPreview = mb_strtolower($preview, 'UTF-8');
+ $accessKeyFailure = $statusCode === 401
+ || $statusCode === 403
+ || str_contains($preview, 'FRAMEWORK__API_INVALID_ACCESS_KEY')
+ || str_contains($normalizedPreview, 'access key is invalid');
+ $serverFailure = $statusCode >= 500 || $accessKeyFailure;
+
+ $hint = '';
+
+ if ($accessKeyFailure) {
+ $hint = ' Hint: The configured Shopware Sales Channel access key is invalid or does not match the Store API endpoint.';
+ } elseif ($utf8Failure) {
+ $hint = ' Hint: The request body was valid JSON; this Shopware error usually means Shopware failed while encoding response/product data as UTF-8.';
+ }
return new StoreApiException(
sprintf(
'Shopware Store API request failed with status %d. Response: %s%s',
$statusCode,
$preview,
- $utf8Failure ? ' Hint: The request body was valid JSON; this Shopware error usually means Shopware failed while encoding response/product data as UTF-8.' : ''
+ $hint
),
$statusCode,
$serverFailure,
$utf8Failure,
- $serverFailure || $utf8Failure
+ $serverFailure || $utf8Failure || $accessKeyFailure
);
}
diff --git a/src/Shopware/StoreApiException.php b/src/Shopware/StoreApiException.php
index 9e278ab..1a3cdfe 100644
--- a/src/Shopware/StoreApiException.php
+++ b/src/Shopware/StoreApiException.php
@@ -42,6 +42,10 @@ final class StoreApiException extends RuntimeException
public function isSystemFailure(): bool
{
- return $this->serverFailure || $this->utf8Failure;
+ if ($this->serverFailure || $this->utf8Failure) {
+ return true;
+ }
+
+ return $this->statusCode === 401 || $this->statusCode === 403;
}
}