The AI chat error banner always showed a generic "Something went wrong"
with no reason. The server already forwards the provider cause into the
stream (e.g. "Cannot connect to API: read ECONNRESET"), but the client
hid it behind a static heading.
- describeChatError now returns { title, detail }: a short heading naming
the cause category plus a one-line explanation.
- Add classifyProviderError: maps connection reset, timeout, rate limit,
context-window overflow, quota and auth failures to clear categories;
the 403/503 gating responses are preserved; unknown errors fall back to
the verbatim provider text.
- Match HTTP status codes only as the leading token and textual signatures
only against the message head (before "| response body:"), so a number
or phrase in the response-body snippet never mislabels the cause.
- Use the new {title, detail} in all three banners: chat-thread,
share-ai-widget and the persisted-error banner in message-item.
- Cover the classifier with 20 unit tests (categories + regressions).
228 lines
9.6 KiB
TypeScript
228 lines
9.6 KiB
TypeScript
import { useMemo, useRef } from "react";
|
|
import { generateId } from "ai";
|
|
import { Alert, Box, Stack } from "@mantine/core";
|
|
import { IconAlertTriangle } from "@tabler/icons-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useChat, type UIMessage } from "@ai-sdk/react";
|
|
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 RoleCards from "@/features/ai-chat/components/role-cards.tsx";
|
|
import {
|
|
IAiChatMessageRow,
|
|
IAiRole,
|
|
} 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. */
|
|
export interface OpenPageContext {
|
|
id: string;
|
|
title: string;
|
|
}
|
|
|
|
interface ChatThreadProps {
|
|
/** The open chat id, or null for a brand-new (not-yet-created) chat. */
|
|
chatId: string | null;
|
|
/** Persisted rows to seed initial messages (existing chats only). */
|
|
initialRows?: IAiChatMessageRow[];
|
|
/** The page currently open in the workspace, or null on a non-page route.
|
|
* Sent with each turn so the agent knows what "this page" refers to. */
|
|
openPage?: OpenPageContext | null;
|
|
/** The agent role selected for a NEW chat (null = universal assistant). Sent
|
|
* in the request body so the server persists it on chat creation; ignored by
|
|
* the server for existing chats (the role is read from the chat row). */
|
|
roleId?: string | null;
|
|
/** Enabled roles for the new-chat empty state (only meaningful when
|
|
* `chatId === null`). Rendered as the colored role cards. */
|
|
roles?: IAiRole[];
|
|
/** Notify the parent which role was picked via a card, so it can update the
|
|
* header badge / assistant name for the brand-new chat. */
|
|
onRolePicked?: (role: IAiRole) => void;
|
|
/** Display name for the assistant label / typing line (the role name);
|
|
* forwarded to MessageList. Absent => the generic "AI agent". */
|
|
assistantName?: string;
|
|
/** Called when a turn finishes; the parent refreshes the chat list and, for
|
|
* a new chat, adopts the freshly created chat id. */
|
|
onTurnFinished: () => void;
|
|
}
|
|
|
|
/**
|
|
* Map a persisted server row to an AI SDK UIMessage. Mirrors the server's
|
|
* `rowToUiMessage`: `metadata.parts` are the UIMessage parts; otherwise fall
|
|
* back to a single text part built from the plain-text `content`.
|
|
*/
|
|
function rowToUiMessage(row: IAiChatMessageRow): UIMessage {
|
|
const role = row.role === "assistant" ? "assistant" : "user";
|
|
const parts =
|
|
Array.isArray(row.metadata?.parts) && row.metadata.parts.length > 0
|
|
? row.metadata.parts
|
|
: ([{ type: "text", text: row.content ?? "" }] as UIMessage["parts"]);
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Owns the AI SDK `useChat` lifecycle for ONE chat. The parent remounts this
|
|
* with a `key` when the selected chat changes, so initial messages re-seed
|
|
* cleanly (the v6 transport-based hook keeps its state per mount).
|
|
*/
|
|
export default function ChatThread({
|
|
chatId,
|
|
initialRows,
|
|
openPage,
|
|
roleId,
|
|
roles,
|
|
onRolePicked,
|
|
assistantName,
|
|
onTurnFinished,
|
|
}: ChatThreadProps) {
|
|
const { t } = useTranslation();
|
|
|
|
const initialMessages = useMemo<UIMessage[]>(
|
|
() => (initialRows ?? []).map(rowToUiMessage),
|
|
[initialRows],
|
|
);
|
|
|
|
// The server resolves/creates the chat from the `chatId` in the request body.
|
|
// A new chat starts as null; we keep the id in a ref so the SAME hook instance
|
|
// can keep streaming to a chat once it exists (the parent adopts the id on
|
|
// finish, but within this mount the body carries whatever we know).
|
|
const chatIdRef = useRef<string | null>(chatId);
|
|
chatIdRef.current = chatId;
|
|
|
|
// Keep the currently-open page in a ref, updated each render, so the LATEST
|
|
// open page is sent on every send WITHOUT re-creating the `useMemo([])`-stable
|
|
// transport (and thus without re-creating the useChat store mid-stream — see
|
|
// the `chatStoreId` note below). Read live inside `prepareSendMessagesRequest`.
|
|
const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
|
|
openPageRef.current = openPage ?? null;
|
|
|
|
// Keep the selected role id in a ref, same rationale as openPageRef. Only the
|
|
// FIRST request of a brand-new chat uses it (the server persists it then and
|
|
// ignores it for existing chats), but sending it on every send is harmless.
|
|
const roleIdRef = useRef<string | null>(roleId ?? null);
|
|
roleIdRef.current = roleId ?? null;
|
|
|
|
// Stable `useChat` store key for the lifetime of THIS mount.
|
|
//
|
|
// CRITICAL: `useChat` (@ai-sdk/react) re-creates its internal `Chat` store
|
|
// whenever the `id` option no longer equals the store's current id
|
|
// (`"id" in options && chatRef.current.id !== options.id`). For a brand-new
|
|
// chat (`chatId === null`) we previously passed `id: undefined`; the store
|
|
// then generated its OWN random id internally, so `store.id !== undefined`
|
|
// stayed true on EVERY render and the store was re-created on every render —
|
|
// wiping the optimistic user message, the "submitted" status, and every
|
|
// streamed delta until the turn fully finished (then the parent adopts the
|
|
// new chat id and remounts with the persisted history, making everything
|
|
// "appear at once"). Passing a STABLE non-undefined id keeps one store for
|
|
// the whole turn, so the user message shows immediately and tokens stream
|
|
// live. This id is purely the client store key; the server still resolves the
|
|
// real chat from `chatId` in the request body (see `prepareSendMessagesRequest`).
|
|
// The id only needs to be stable per mount — the parent remounts this via
|
|
// `key` on chat switch, which re-seeds cleanly.
|
|
const stableIdRef = useRef<string>(chatId ?? `new-${generateId()}`);
|
|
const chatStoreId = chatId ?? stableIdRef.current;
|
|
|
|
const transport = useMemo(
|
|
() =>
|
|
new DefaultChatTransport<UIMessage>({
|
|
api: "/api/ai-chat/stream",
|
|
credentials: "include",
|
|
// Inject the chat id and the currently-open page alongside the useChat
|
|
// messages so the server can resolve an existing chat (or create one
|
|
// when null) and tell the agent which page "this page" refers to. Both
|
|
// are read live from refs so changing chats/pages does NOT recreate the
|
|
// transport. `openPage` is null on a non-page route.
|
|
prepareSendMessagesRequest: ({ messages, body }) => ({
|
|
body: {
|
|
...body,
|
|
chatId: chatIdRef.current,
|
|
openPage: openPageRef.current,
|
|
// Honoured by the server only when creating a new chat; null =>
|
|
// universal assistant.
|
|
roleId: roleIdRef.current,
|
|
messages,
|
|
},
|
|
}),
|
|
}),
|
|
[],
|
|
);
|
|
|
|
const { messages, sendMessage, status, stop, error } = useChat({
|
|
// Stable per-mount key. Existing chats use their real id; new chats use a
|
|
// generated client id (never `undefined`) so the store is NOT re-created on
|
|
// every render mid-stream (see `chatStoreId` above).
|
|
id: chatStoreId,
|
|
messages: initialMessages,
|
|
transport,
|
|
onFinish: () => onTurnFinished(),
|
|
// In AI SDK v6 `onFinish` does NOT fire when the stream errors, so a brand
|
|
// new chat that fails on its first turn would never invalidate the chat list
|
|
// nor adopt the server-created chat id (the server still creates the row and
|
|
// saves the error message). Run the same post-turn path on error so the
|
|
// failed chat appears in history immediately instead of after a manual
|
|
// refresh. The error itself is still surfaced via `error` below.
|
|
onError: () => onTurnFinished(),
|
|
});
|
|
|
|
const isStreaming = status === "submitted" || status === "streaming";
|
|
|
|
// Classify the turn error into a heading + detail so the banner names the cause
|
|
// (connection reset, timeout, rate limit, context overflow, quota, ...) instead
|
|
// of a generic "Something went wrong".
|
|
const errorView = error ? describeChatError(error.message ?? "", t) : null;
|
|
|
|
// Clicking a role card both binds the role to THIS new chat and immediately
|
|
// starts the conversation. roleIdRef is set synchronously here because the
|
|
// parent's selectedRoleId state update would only reach roleIdRef on the next
|
|
// render — after this synchronous sendMessage has already read it.
|
|
const handleRolePick = (role: IAiRole): void => {
|
|
roleIdRef.current = role.id;
|
|
onRolePicked?.(role);
|
|
sendMessage({ text: t("Take a look at the current document") });
|
|
};
|
|
const showRoleCards = chatId === null && (roles?.length ?? 0) > 0;
|
|
const roleCardsEmptyState = showRoleCards ? (
|
|
<RoleCards roles={roles ?? []} onPick={handleRolePick} />
|
|
) : undefined;
|
|
|
|
return (
|
|
<Box className={classes.panel}>
|
|
<MessageList
|
|
messages={messages}
|
|
isStreaming={isStreaming}
|
|
emptyState={roleCardsEmptyState}
|
|
assistantName={assistantName}
|
|
/>
|
|
|
|
{errorView && (
|
|
<Alert
|
|
variant="light"
|
|
color="red"
|
|
icon={<IconAlertTriangle size={16} />}
|
|
mb="xs"
|
|
title={errorView.title}
|
|
>
|
|
{errorView.detail}
|
|
</Alert>
|
|
)}
|
|
|
|
<Stack gap={0} className={classes.inputWrapper}>
|
|
<ChatInput
|
|
onSend={(text) => sendMessage({ text })}
|
|
onStop={stop}
|
|
isStreaming={isStreaming}
|
|
/>
|
|
</Stack>
|
|
</Box>
|
|
);
|
|
}
|