fix(ai-chat): keep provider errors visible after a new-chat remount

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 <ChatThread> 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.
This commit is contained in:
vvzvlad
2026-06-17 19:41:54 +03:00
parent 022a1687a5
commit 51c1830383
4 changed files with 68 additions and 49 deletions

View File

@@ -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)}
</Alert>
)}
@@ -155,49 +164,3 @@ export default function ChatThread({
</Box>
);
}
/**
* 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 `"<status>: <message>"` (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
// "<status>: <message>" (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;
}