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(`share-ai-${generateId()}`); const transport = useMemo( () => new DefaultChatTransport({ 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. setOpen(true)} > ); } return ( {t("Ask AI")} setOpen(false)} > {/* 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). */} {t("Ask a question about this documentation.")} } /> {errorView && ( } 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} )}