From d13e5537ed297d063fb5a6384b0eb25fde4d9747 Mon Sep 17 00:00:00 2001 From: team2 Date: Sat, 25 Apr 2026 20:49:45 +0200 Subject: [PATCH] fix sse error handling if shop api error part 3 --- README_ERROR_VISIBILITY_PATCH.md | 10 +++++++ public/assets/js/base.js | 29 +++++++++++++++++-- src/Agent/AgentRunner.php | 15 ++++++++-- src/Controller/AskSseController.php | 45 +++++++++++++++++++++++++++-- src/Shopware/StoreApiClient.php | 19 ++++++++++-- src/Shopware/StoreApiException.php | 6 +++- 6 files changed, 112 insertions(+), 12 deletions(-) create mode 100644 README_ERROR_VISIBILITY_PATCH.md 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; } }