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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user