Files
gitmost/apps/client/src/features/share/components/share-ai-widget.tsx
T
claude_code 0bbf94c154 feat(ai-chat): surface the real cause in the error banner
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).
2026-06-21 18:54:43 +03:00

231 lines
7.3 KiB
TypeScript

import { useMemo, useRef, useState } from "react";
import { generateId } from "ai";
import {
ActionIcon,
Affix,
Alert,
Box,
Group,
Paper,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import {
IconAlertTriangle,
IconArrowUp,
IconSparkles,
IconX,
} 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 { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
interface ShareAiWidgetProps {
/** The share id (or key) the assistant is scoped to. */
shareId: string;
/** The page the reader currently has open (context for "this page"). */
pageId: string;
/** Display name of the configured assistant identity; falls back to 'AI agent' when absent. */
assistantName?: string;
}
/**
* Lightweight, EPHEMERAL "Ask AI" widget for a public shared page.
*
* A stripped version of the authenticated chat: text input only, no chat list,
* no history, no persistence, no voice input. The transcript lives only in
* memory (this component's `useChat` store) and is sent with `credentials:
* "omit"` to the anonymous `/api/shares/ai/stream` endpoint. The server stores
* nothing.
*
* Presentation is now shared with the internal chat: the same `MessageList`
* renders the streamed transcript, so the public share gets the SAME
* incremental markdown render, animated typing indicator, and tool-call cards
* as the internal chat. Only the anonymous specifics differ — no auth, no
* history, `credentials: "omit"`, suppressed page citations (an anonymous
* reader cannot open the linked internal pages), neutralized internal markdown
* links (so internal UUIDs/auth-gated routes in the answer don't leak as
* clickable links), and a documentation-focused empty state.
*/
export default function ShareAiWidget({
shareId,
pageId,
assistantName,
}: ShareAiWidgetProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
// Stable per-mount store key (see ai-chat ChatThread for the rationale on why
// useChat needs a stable, non-undefined id to avoid re-creating its store).
const storeIdRef = useRef<string>(`share-ai-${generateId()}`);
const transport = useMemo(
() =>
new DefaultChatTransport<UIMessage>({
api: "/api/shares/ai/stream",
// Anonymous endpoint: never send cookies/credentials.
credentials: "omit",
prepareSendMessagesRequest: ({ messages, body }) => ({
body: {
...body,
shareId,
pageId,
messages,
},
}),
}),
[shareId, pageId],
);
const { messages, sendMessage, status, stop, error } = useChat({
id: storeIdRef.current,
transport,
});
const isStreaming = status === "submitted" || status === "streaming";
// Same classified-error banner as the internal chat: name the cause instead of a
// generic heading.
const errorView = error ? describeChatError(error.message ?? "", t) : null;
const handleSend = () => {
const text = input.trim();
if (!text || isStreaming) return;
setInput("");
void sendMessage({ text });
};
if (!open) {
return (
// Offset 80px from the bottom so the FAB stacks ABOVE the bottom-right
// "Powered by Gitmost" branding button (share-branding.tsx) without
// overlapping it.
<Affix position={{ bottom: 80, right: 20 }}>
<Tooltip label={t("Ask AI")} position="left">
<ActionIcon
size="xl"
radius="xl"
variant="filled"
aria-label={t("Ask AI")}
onClick={() => setOpen(true)}
>
<IconSparkles size={22} />
</ActionIcon>
</Tooltip>
</Affix>
);
}
return (
<Affix position={{ bottom: 80, right: 20 }}>
<Paper
shadow="md"
radius="md"
withBorder
style={{
width: 360,
maxWidth: "calc(100vw - 40px)",
height: 480,
maxHeight: "calc(100vh - 100px)",
display: "flex",
flexDirection: "column",
}}
>
<Group
justify="space-between"
p="xs"
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
>
<Group gap="xs">
<IconSparkles size={18} />
<Text fw={600} size="sm">
{t("Ask AI")}
</Text>
</Group>
<ActionIcon
variant="subtle"
aria-label={t("Close")}
onClick={() => setOpen(false)}
>
<IconX size={18} />
</ActionIcon>
</Group>
{/* Shared transcript: same incremental streaming render, animated typing
indicator, markdown, and tool-call cards as the internal chat. The
share is anonymous, so page citation links are suppressed (an
anonymous reader cannot open the linked internal pages). */}
<Box style={{ flex: 1, minHeight: 0, display: "flex", padding: "var(--mantine-spacing-sm)" }}>
<MessageList
messages={messages}
isStreaming={isStreaming}
assistantName={assistantName}
showCitations={false}
// Anonymous reader: neutralize internal/relative links in the
// assistant's markdown so internal UUIDs/auth-gated routes don't
// leak as clickable links (external http(s) links are kept).
neutralizeInternalLinks={true}
emptyState={
<Text size="sm" c="dimmed" ta="center">
{t("Ask a question about this documentation.")}
</Text>
}
/>
</Box>
{errorView && (
<Alert
variant="light"
color="red"
icon={<IconAlertTriangle size={16} />}
mx="sm"
mb="xs"
title={errorView.title}
>
{/* Surface the real cause (provider/gating category) instead of a
generic line — same helper the internal chat uses. */}
{errorView.detail}
</Alert>
)}
<Group
gap="xs"
p="xs"
align="flex-end"
style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}
>
<Textarea
style={{ flex: 1 }}
autosize
minRows={1}
maxRows={4}
placeholder={t("Ask a question…")}
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
/>
<ActionIcon
size="lg"
radius="xl"
variant="filled"
aria-label={isStreaming ? t("Stop") : t("Send")}
onClick={isStreaming ? () => stop() : handleSend}
disabled={!isStreaming && input.trim().length === 0}
>
{isStreaming ? <IconX size={18} /> : <IconArrowUp size={18} />}
</ActionIcon>
</Group>
</Paper>
</Affix>
);
}