From 51c18303835058a749d5978413611b1f24e0e5e8 Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Wed, 17 Jun 2026 19:41:54 +0300 Subject: [PATCH] fix(ai-chat): keep provider errors visible after a new-chat remount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfacing the stream error via useChat().error alone was not enough: on a brand-new chat the errored turn still fires onFinish -> onTurnFinished, which adopts the freshly-created chat id and changes the key, remounting it with a fresh useChat whose transient `error` is gone. The thread re-seeds from persisted history, where the assistant row has empty parts and the error lives only in metadata.error — which was never rendered. Result: an empty "AI agent" row and no visible error. - Render the persisted metadata.error inline in MessageItem, so the error survives the remount and is also shown in reopened chat history. - Carry metadata.error onto the rebuilt UIMessage in rowToUiMessage. - Extract the error formatter into utils/error-message.ts (describeChatError) and reuse it for both the live Alert and the persisted error. - Add metadata.error to the IAiChatMessageRow type. Client-only; the server already persists metadata.error. No new i18n keys. --- .../ai-chat/components/chat-thread.tsx | 59 ++++--------------- .../ai-chat/components/message-item.tsx | 20 ++++++- .../features/ai-chat/types/ai-chat.types.ts | 3 + .../features/ai-chat/utils/error-message.ts | 35 +++++++++++ 4 files changed, 68 insertions(+), 49 deletions(-) create mode 100644 apps/client/src/features/ai-chat/utils/error-message.ts diff --git a/apps/client/src/features/ai-chat/components/chat-thread.tsx b/apps/client/src/features/ai-chat/components/chat-thread.tsx index fda96ad8..801d2183 100644 --- a/apps/client/src/features/ai-chat/components/chat-thread.tsx +++ b/apps/client/src/features/ai-chat/components/chat-thread.tsx @@ -8,6 +8,7 @@ import { DefaultChatTransport } from "ai"; import MessageList from "@/features/ai-chat/components/message-list.tsx"; import ChatInput from "@/features/ai-chat/components/chat-input.tsx"; import { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts"; +import { describeChatError } from "@/features/ai-chat/utils/error-message.ts"; import classes from "@/features/ai-chat/components/ai-chat.module.css"; /** The page the user is currently viewing, sent as chat context. */ @@ -40,7 +41,15 @@ function rowToUiMessage(row: IAiChatMessageRow): UIMessage { Array.isArray(row.metadata?.parts) && row.metadata.parts.length > 0 ? row.metadata.parts : ([{ type: "text", text: row.content ?? "" }] as UIMessage["parts"]); - return { id: row.id, role, parts } as UIMessage; + const error = row.metadata?.error; + return { + id: row.id, + role, + parts, + // Carry a persisted turn error so MessageItem can render it after a remount + // (e.g. when a new chat adopts its id) and in reopened chat history. + ...(error ? { metadata: { error } } : {}), + } as UIMessage; } /** @@ -141,7 +150,7 @@ export default function ChatThread({ mb="xs" title={t("Something went wrong")} > - {describeError(error, t)} + {describeChatError(error.message ?? "", t)} )} @@ -155,49 +164,3 @@ export default function ChatThread({ ); } - -/** - * Turn a useChat error into a friendly inline message. The transport throws on - * non-2xx with the response text/status in the message, and stream failures - * arrive as `": "` (forwarded by the server's - * pipeUIMessageStreamToResponse onError). We keep the friendly mappings for the - * gating responses (403 chat disabled, 503 provider not configured) and - * otherwise surface the real provider message (e.g. 402 insufficient credits / - * 429 rate limit) so the actual cause is visible — never a crash. - */ -function describeError( - error: Error, - t: (key: string) => string, -): string { - const msg = error.message ?? ""; - // Our own gating responses arrive PRE-stream as the raw JSON error body - // (NestJS default exception shape — no global filter overrides it), which - // carries a numeric "statusCode" field. Match that field, NOT a bare - // substring, so a provider stream error that merely contains "403"/"503" (or - // a word like "disabled") is never misclassified and its real cause hidden. - if (/"statusCode"\s*:\s*403\b/.test(msg)) { - return t("AI chat is disabled for this workspace."); - } - if (/"statusCode"\s*:\s*503\b/.test(msg)) { - return t("The AI provider is not configured. Ask an administrator to set it up."); - } - // Any other failure — including provider stream failures forwarded as - // ": " (402 credits, 429 rate limit, ...) — is surfaced - // verbatim so the real cause is visible. Fall back to a generic message only - // when there is no usable text or just an opaque placeholder. - return providerDetail(msg) ?? t("The AI agent could not respond. Please try again."); -} - -/** - * Extract a human-readable provider detail from a useChat error message, or - * null when there is nothing useful to show. Returns null for empty strings, - * the AI SDK's opaque "An error occurred." placeholder, and our own post-hijack - * "Internal server error" fallback, so the caller can use a friendly message. - */ -function providerDetail(msg: string): string | null { - const trimmed = msg.trim(); - if (!trimmed) return null; - if (/^an error occurred\.?$/i.test(trimmed)) return null; - if (/internal server error/i.test(trimmed)) return null; - return trimmed; -} diff --git a/apps/client/src/features/ai-chat/components/message-item.tsx b/apps/client/src/features/ai-chat/components/message-item.tsx index 5f6b5d0a..680d4715 100644 --- a/apps/client/src/features/ai-chat/components/message-item.tsx +++ b/apps/client/src/features/ai-chat/components/message-item.tsx @@ -1,9 +1,11 @@ -import { Box, Text } from "@mantine/core"; +import { Alert, Box, Text } from "@mantine/core"; +import { IconAlertTriangle } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import type { UIMessage } from "@ai-sdk/react"; import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx"; import { ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx"; import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts"; +import { describeChatError } from "@/features/ai-chat/utils/error-message.ts"; import classes from "@/features/ai-chat/components/ai-chat.module.css"; interface MessageItemProps { @@ -81,6 +83,22 @@ export default function MessageItem({ message }: MessageItemProps) { return null; })} + {/* A persisted turn error (server stored it in metadata.error). Rendered + here so it survives a thread remount and shows in reopened history. */} + {(() => { + const errorText = (message.metadata as { error?: string } | undefined)?.error; + if (!errorText) return null; + return ( + } + mt={4} + > + {describeChatError(errorText, t)} + + ); + })()} ); } diff --git a/apps/client/src/features/ai-chat/types/ai-chat.types.ts b/apps/client/src/features/ai-chat/types/ai-chat.types.ts index f65890ec..d225242d 100644 --- a/apps/client/src/features/ai-chat/types/ai-chat.types.ts +++ b/apps/client/src/features/ai-chat/types/ai-chat.types.ts @@ -35,6 +35,9 @@ export interface IAiChatMessageRow { outputTokens?: number; totalTokens?: number; }; + // Set on an assistant row whose turn ended in a provider/stream error; the + // raw provider error text (e.g. "402: ...") for inline display in the thread. + error?: string; } | null; createdAt: string; } diff --git a/apps/client/src/features/ai-chat/utils/error-message.ts b/apps/client/src/features/ai-chat/utils/error-message.ts new file mode 100644 index 00000000..257fbd53 --- /dev/null +++ b/apps/client/src/features/ai-chat/utils/error-message.ts @@ -0,0 +1,35 @@ +/** + * Turn an AI chat error message into a friendly inline string. Used for BOTH the + * live `useChat().error` (its `.message`) and a persisted assistant error stored + * in `metadata.error`. Our own gating responses arrive as a raw NestJS JSON error + * body carrying a numeric "statusCode" field (matched precisely, not by bare + * substring, so a provider message that merely contains "403"/"503"/"disabled" is + * never misclassified). Everything else — provider stream failures forwarded as + * ": " (402 credits, 429 rate limit, ...) — is surfaced verbatim. + */ +export function describeChatError( + message: string, + t: (key: string) => string, +): string { + const msg = message ?? ""; + if (/"statusCode"\s*:\s*403\b/.test(msg)) { + return t("AI chat is disabled for this workspace."); + } + if (/"statusCode"\s*:\s*503\b/.test(msg)) { + return t("The AI provider is not configured. Ask an administrator to set it up."); + } + return providerDetail(msg) ?? t("The AI agent could not respond. Please try again."); +} + +/** + * Extract a human-readable provider detail, or null when there is nothing useful + * to show: empty text, the AI SDK's opaque "An error occurred." placeholder, or + * our own post-hijack "Internal server error" fallback. + */ +function providerDetail(msg: string): string | null { + const trimmed = msg.trim(); + if (!trimmed) return null; + if (/^an error occurred\.?$/i.test(trimmed)) return null; + if (/internal server error/i.test(trimmed)) return null; + return trimmed; +}