fix sse error handling if shop api error part 3

This commit is contained in:
team2
2026-04-25 20:49:45 +02:00
parent 2044a465ad
commit d13e5537ed
6 changed files with 112 additions and 12 deletions

View File

@@ -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

View File

@@ -69,8 +69,28 @@ document.addEventListener('DOMContentLoaded', () => {
) !== null; ) !== 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) { function cleanupEmptyBlocks(container) {
container.querySelectorAll('p, div, li, blockquote').forEach((el) => { container.querySelectorAll('p, div, li, blockquote').forEach((el) => {
trimEdgeBreaks(el);
const html = el.innerHTML const html = el.innerHTML
.replace(/<br\s*\/?>/gi, '') .replace(/<br\s*\/?>/gi, '')
.replace(/&nbsp;/gi, '') .replace(/&nbsp;/gi, '')
@@ -87,6 +107,8 @@ document.addEventListener('DOMContentLoaded', () => {
el.remove(); el.remove();
} }
}); });
trimEdgeBreaks(container);
} }
function removeThinkSpansOnly(container) { function removeThinkSpansOnly(container) {
@@ -165,6 +187,7 @@ document.addEventListener('DOMContentLoaded', () => {
const thinkSpans = Array.from(container.querySelectorAll('.think')); const thinkSpans = Array.from(container.querySelectorAll('.think'));
if (thinkSpans.length === 0) { if (thinkSpans.length === 0) {
cleanupEmptyBlocks(container);
return; return;
} }
@@ -350,7 +373,8 @@ document.addEventListener('DOMContentLoaded', () => {
firstChunk = false; firstChunk = false;
} }
raw += `\n\n<em>${safeMessage}</em>`; const formattedMessage = `<em>${safeMessage}</em>`;
raw += raw.trim() === '' ? formattedMessage : `\n\n${formattedMessage}`;
finalizeStream(bubble, raw); 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.'; const userMessage = 'Die Verbindung zum Antwort-Stream wurde unterbrochen. Bitte sende die Anfrage erneut, falls die Antwort unvollständig ist.';
if (raw.trim() !== '') { if (raw.trim() !== '') {
raw += `\n\n<em>${userMessage}</em>`; const formattedMessage = `<em>${userMessage}</em>`;
raw += raw.trim() === '' ? formattedMessage : `\n\n${formattedMessage}`;
renderBubbleContent(bubble, raw); renderBubbleContent(bubble, raw);
} else { } else {
bubble.innerHTML = `<em>${userMessage}</em>`; bubble.innerHTML = `<em>${userMessage}</em>`;

View File

@@ -156,7 +156,7 @@ final readonly class AgentRunner
yield $this->systemMsg( yield $this->systemMsg(
$this->buildShopUnavailableMessage($primaryShopSearchFailureReason), $this->buildShopUnavailableMessage($primaryShopSearchFailureReason),
'info' 'err'
); );
$repairPayload = [ $repairPayload = [
@@ -811,12 +811,21 @@ final readonly class AgentRunner
private function buildUserErrorMessage(Throwable $e): string 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) { if (!$this->debug) {
return $this->agentRunnerConfig->getGenericInternalErrorMessage(); return $this->agentRunnerConfig->getGenericInternalErrorMessage()
. '<br><small>Technischer Fehler: ' . $safeMessage . '</small>';
} }
return $this->agentRunnerConfig->getDebugInternalErrorPrefix() return $this->agentRunnerConfig->getDebugInternalErrorPrefix()
. htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); . $safeMessage;
} }
private function badge(string $label): string private function badge(string $label): string

View File

@@ -55,9 +55,9 @@ final readonly class AskSseController
'includeFullContext' => $includeFullContext, 'includeFullContext' => $includeFullContext,
'createdAt' => time(), 'createdAt' => time(),
]); ]);
} catch (\Throwable) { } catch (\Throwable $e) {
return new JsonResponse( 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 Response::HTTP_INTERNAL_SERVER_ERROR
); );
} }
@@ -139,6 +139,7 @@ final readonly class AskSseController
?Response $cookieResponse ?Response $cookieResponse
): void { ): void {
$this->prepareStreamRuntime(); $this->prepareStreamRuntime();
$this->registerStreamShutdownErrorHandler();
if ($cookieResponse !== null) { if ($cookieResponse !== null) {
foreach ($cookieResponse->headers->getCookies() as $cookie) { foreach ($cookieResponse->headers->getCookies() as $cookie) {
@@ -167,13 +168,51 @@ final readonly class AskSseController
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->sendEvent( $this->sendEvent(
'error', 'error',
'❌ Stream abgebrochen: ' . $e->getMessage() '❌ Stream abgebrochen: ' . $this->formatThrowableForClient($e)
); );
} }
$this->sendEvent('done', '[DONE]'); $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 private function prepareStreamRuntime(): void
{ {
@set_time_limit(0); @set_time_limit(0);

View File

@@ -83,19 +83,32 @@ final readonly class StoreApiClient
{ {
$preview = mb_substr(trim($content), 0, 1000); $preview = mb_substr(trim($content), 0, 1000);
$utf8Failure = $this->containsUtf8FailureSignal($preview); $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( return new StoreApiException(
sprintf( sprintf(
'Shopware Store API request failed with status %d. Response: %s%s', 'Shopware Store API request failed with status %d. Response: %s%s',
$statusCode, $statusCode,
$preview, $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, $statusCode,
$serverFailure, $serverFailure,
$utf8Failure, $utf8Failure,
$serverFailure || $utf8Failure $serverFailure || $utf8Failure || $accessKeyFailure
); );
} }

View File

@@ -42,6 +42,10 @@ final class StoreApiException extends RuntimeException
public function isSystemFailure(): bool public function isSystemFailure(): bool
{ {
return $this->serverFailure || $this->utf8Failure; if ($this->serverFailure || $this->utf8Failure) {
return true;
}
return $this->statusCode === 401 || $this->statusCode === 403;
} }
} }