diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 21f7c5f7..3b029ad3 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1122,6 +1122,16 @@ "Page menu for {{name}}": "Page menu for {{name}}", "Create subpage of {{name}}": "Create subpage of {{name}}", "AI chat": "AI chat", + "Ask a question about this documentation.": "Ask a question about this documentation.", + "Ask a question…": "Ask a question…", + "Thinking…": "Thinking…", + "The assistant is unavailable right now. Please try again.": "The assistant is unavailable right now. Please try again.", + "Public share assistant": "Public share assistant", + "Enabled": "Enabled", + "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.", + "Public assistant model": "Public assistant model", + "Defaults to the chat model": "Defaults to the chat model", + "Optional cheaper model id for the public assistant. Empty uses the chat model above.": "Optional cheaper model id for the public assistant. Empty uses the chat model above.", "Minimize": "Minimize", "Current context size": "Current context size", "AI agent": "AI agent", diff --git a/apps/client/src/features/share/components/share-ai-widget.tsx b/apps/client/src/features/share/components/share-ai-widget.tsx new file mode 100644 index 00000000..455d3cea --- /dev/null +++ b/apps/client/src/features/share/components/share-ai-widget.tsx @@ -0,0 +1,230 @@ +import { useMemo, useRef, useState } from "react"; +import { generateId } from "ai"; +import { + ActionIcon, + Affix, + Alert, + Box, + Group, + Paper, + ScrollArea, + Stack, + 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"; + +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; +} + +/** Concatenate the visible text parts of a UIMessage. */ +function messageText(message: UIMessage): string { + return (message.parts ?? []) + .filter( + (p): p is { type: "text"; text: string } => + p?.type === "text" && typeof (p as { text?: string }).text === "string", + ) + .map((p) => p.text) + .join(""); +} + +/** + * 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. + */ +export default function ShareAiWidget({ shareId, pageId }: 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"; + + const handleSend = () => { + const text = input.trim(); + if (!text || isStreaming) return; + setInput(""); + void sendMessage({ text }); + }; + + if (!open) { + return ( + + + setOpen(true)} + > + + + + + ); + } + + return ( + + + + + + + {t("Ask AI")} + + + setOpen(false)} + > + + + + + + {messages.length === 0 ? ( + + {t("Ask a question about this documentation.")} + + ) : ( + + {messages.map((message) => ( + + + + {messageText(message) || + (isStreaming ? t("Thinking…") : "")} + + + + ))} + + )} + + {error && ( + } + mt="sm" + title={t("Something went wrong")} + > + {t("The assistant is unavailable right now. Please try again.")} + + )} + + + +