Chatbot
Open the chatbot by clicking the button at the bottom right corner.
import Chatbot, { type Message,} from "@/registry/new-york/chatbot/components/chatbot/chatbot"import { MessageCircleQuestionMark } from "lucide-react"import { useState } from "react"
const title = "Chatbot Assistant"const promptSuggestions = [ "What's the weather like today?", "Tell me a joke.", "How do I bake a chocolate cake?", "Explain quantum computing in simple terms.",]// Base URL of a FastRAG backend started per `sdk/app/` (see// `.agent/results/006-pipeline-image.md`) — no path. The widget creates and// continues chats itself against `POST /v1/chats` and// `POST /v1/chats/{chat_id}/messages`, authenticating with the session// cookie the backend sets (`credentials: "include"`), so no chat id is// assembled here. Point a backend running with `ServeConfig.bearer_tokens`// enabled instead by passing `bearerToken` to `Chatbot`.const endpoint = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"
const icon = <MessageCircleQuestionMark className="size-6" />
export default function Default() { const [messages, setMessages] = useState<Message[]>([ { id: crypto.randomUUID(), role: "assistant", content: "Hello! I'm your Chatbot Assistant. How can I help you today?", }, ])
const clearMessages = async () => { setMessages([]) }
return ( <Chatbot endpoint={endpoint} strings={{ title, promptSuggestions }} messages={messages} setMessages={setMessages} icon={icon} newChat={clearMessages} /> )}Installation
Section titled “Installation”Install the following dependencies.
Copy and paste the following code into your project.
src/components/chatbot.tsx
import { Button } from "@/components/ui/button"import { ChatBubble, ChatBubbleMessage,} from "@/registry/new-york/chat/components/chat/chat-bubble"import { ChatInput } from "@/registry/new-york/chat/components/chat/chat-input"import { ChatMessageList } from "@/registry/new-york/chat/components/chat/chat-message-list"import { ExpandableChat, ExpandableChatBody, ExpandableChatFooter, ExpandableChatHeader,} from "@/registry/new-york/chat/components/chat/expandable-chat"import Markdown, { type Citation,} from "@/registry/new-york/chat/components/chat/markdown/markdown"import PromptSuggestions from "@/registry/new-york/chatbot/components/chatbot/prompt-suggestions"import { useChatbot } from "@/registry/new-york/chatbot/hooks/useChatbot"import type { ChatStrings, Message, Source,} from "@/registry/new-york/chatbot/lib/chatbot/types"import { Send, Trash } from "lucide-react"
export type { Role, FinishReason, MessageError, Source, UserMessage, AssistantMessage, Message, ChatStrings,} from "@/registry/new-york/chatbot/lib/chatbot/types"
export interface ChatbotProps { /** Base URL of the FastRAG backend, e.g. `"http://localhost:8000"` — no path. */ endpoint?: string strings?: Partial<ChatStrings> messages: Message[] icon?: React.ReactNode setMessages: React.Dispatch<React.SetStateAction<Message[]>> newChat?: () => Promise<void> /** * Bearer token for a backend running with `ServeConfig.bearer_tokens` * enabled, sent as `Authorization: Bearer <token>`. Omit this to use the * default cookie-session mode instead (the registry example's mode). */ bearerToken?: string}
/** * Map an answer's `[n]` citations to the sources they cite. * * `sources` holds one entry per page with the number the answer cites it by * in `positions`. Messages stored when the server numbered each chunk hold * several, and ones stored before `positions` existed hold none: those are * cited by their own place in the list instead. */function toCitations(sources: Source[] = []): Array<Citation | undefined> { const citations: Array<Citation | undefined> = [] sources.forEach((source, index) => { const citation: Citation = { url: source.url, title: source.title, siteName: source.site_name, faviconUrl: source.favicon_url, fragments: source.fragments, } const positions = source.positions?.length ? source.positions : [index + 1] for (const n of positions) { if (Number.isInteger(n) && n >= 1) citations[n - 1] = citation } }) return citations}
export default function Chatbot({ endpoint = "http://localhost:8000", strings, messages, icon, setMessages, newChat, bearerToken,}: ChatbotProps) { const title = strings?.title ?? "Chatbot Assistant" const promptSuggestions = strings?.promptSuggestions ?? [] const placeholder = strings?.placeholder ?? "Ask me anything..." const interruptedStreamingError = strings?.interruptedStreamingError ?? "(Response was interrupted)" const genericErrorAnswer = strings?.genericErrorAnswer ?? "Sorry, at this moment I am not able to help you. Try again later."
const { input, setInput, isLoading, isStreaming, sendMessage, cancel, resetChat, } = useChatbot({ endpoint, setMessages, genericErrorAnswer, bearerToken, })
const canSend = input.trim().length > 0 && !isLoading
return ( <ExpandableChat icon={icon}> <ExpandableChatHeader> <h2 className="m-0! w-full text-center text-xl font-semibold"> {title} </h2> </ExpandableChatHeader> <ExpandableChatBody style={{ scrollbarGutter: "stable", }} > {messages.length === 0 && ( <PromptSuggestions suggestions={promptSuggestions} onClickSuggestion={sendMessage} /> )} <ChatMessageList className="w-full max-w-3xl"> {messages.map((message, index) => { if (message.role === "assistant") { const showLoading = isLoading && index === messages.length - 1 const isLastMessage = index === messages.length - 1 const interruptedStreaming = message.finishReason === "interrupted" && (!isLastMessage || !isStreaming) return ( <ChatBubble key={message.id} variant="received"> <ChatBubbleMessage isLoading={showLoading}> <Markdown citations={toCitations(message.sources)}> {message.content} </Markdown> {interruptedStreaming && ( <p className="mt-2 text-sm text-muted-foreground"> {interruptedStreamingError} </p> )} </ChatBubbleMessage> </ChatBubble> ) }
return ( <ChatBubble key={message.id} variant="sent"> <ChatBubbleMessage>{message.content}</ChatBubbleMessage> </ChatBubble> ) })} </ChatMessageList> </ExpandableChatBody> <ExpandableChatFooter> <div className="flex flex-col gap-2 rounded-2xl border p-2"> <ChatInput value={input} onChange={e => setInput(e.currentTarget.value)} placeholder={placeholder} onKeyDown={e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() if (canSend) sendMessage(input) } }} /> <div className="flex gap-2"> <Button type="button" variant="destructive" size="icon-sm" className="cursor-pointer" aria-label="Clear chat" onClick={() => { cancel() resetChat() newChat?.() }} disabled={messages.length === 0} > <Trash className="size-4" /> </Button> <div className="grow" /> <Button type="submit" size="icon-lg" className="cursor-pointer" aria-label="Send message" onClick={() => sendMessage(input)} disabled={!canSend} > <Send className="size-4" /> </Button> </div> </div> </ExpandableChatFooter> </ExpandableChat> )}src/components/chatbot.tsx
import { Button } from "@/components/ui/button"
interface Props { suggestions: string[] onClickSuggestion: (prompt: string) => void}
export default function PromptSuggestions({ suggestions, onClickSuggestion,}: Props) { if (!suggestions.length) return null
return ( <div className="flex h-full w-full items-center justify-center"> <div className="flex flex-wrap items-center justify-center gap-4 py-2"> {suggestions.map((prompt, index) => ( <Button key={index} variant="secondary" className="inline-block h-auto max-w-full cursor-pointer rounded-xl px-4 py-2 text-left leading-snug whitespace-normal" onClick={() => onClickSuggestion(prompt)} > {prompt} </Button> ))} </div> </div> )}src/hooks/chatbot.ts
import { useRef, useState } from "react"
import type { AssistantMessage, DeltaEvent, ErrorEnvelope, Message, MessageEndEvent, MessageError, MessageStartEvent, SourcesEvent, UserMessage,} from "@/registry/new-york/chatbot/lib/chatbot/types"
interface UseChatbotOptions { /** Base URL of the FastRAG backend, e.g. `"http://localhost:8000"` — no path. */ endpoint: string setMessages: React.Dispatch<React.SetStateAction<Message[]>> genericErrorAnswer: string /** * Bearer token for a backend running with `ServeConfig.bearer_tokens` * enabled. When omitted (the default), the widget relies on the * server-set session cookie instead (`credentials: "include"`) — see * the component docs for which mode the registry example uses. */ bearerToken?: string}
// The named SSE events `sse_frame` emits (`fastrag.serve._generation`),// framed as `event: <name>\ndata: <json>\n\n` — see// `sdk/docs/source/guide/serving/streaming.md`. Their payload shapes// (`MessageStartEvent`, `SourcesEvent`, `DeltaEvent`, `MessageEndEvent`) and// the request-level `ErrorEnvelope` are generated from the server's OpenAPI// document — see `lib/chatbot/types.ts` and `lib/chatbot/api.d.ts`.
/** One parsed SSE record: an `event: <name>` line and a `data: <json>` line. */interface SSERecord { event: string | null data: string | null}
/** * Split one SSE record's raw text into its named event and JSON data. * * A record is everything between two blank lines. Lines starting with `:` * (the server's `: keep-alive` comment) are not data and are skipped; a * pure keep-alive record parses to `{ event: null, data: null }` and the * caller ignores it. */function parseSSERecord(record: string): SSERecord { let event: string | null = null const dataLines: string[] = []
for (const line of record.split("\n")) { if (line === "" || line.startsWith(":")) continue if (line.startsWith("event:")) { event = line.slice("event:".length).trim() } else if (line.startsWith("data:")) { dataLines.push(line.slice("data:".length).trimStart()) } }
return { event, data: dataLines.length > 0 ? dataLines.join("\n") : null }}
export function useChatbot({ endpoint, setMessages, genericErrorAnswer, bearerToken,}: UseChatbotOptions) { const [input, setInput] = useState("") const [isLoading, setIsLoading] = useState(false) const [isStreaming, setIsStreaming] = useState(false) const abortControllerRef = useRef<AbortController | null>(null) // The server-assigned chat id, learned from `message_start`. `null` means // no chat has been created yet — the widget never invents its own id, and // never sends a per-chat path itself; `POST /v1/chats` mints the chat and // the id comes back in the stream. const chatIdRef = useRef<string | null>(null)
const cancel = () => { abortControllerRef.current?.abort() }
/** Forget the current server chat, so the next message starts a new one. */ const resetChat = () => { chatIdRef.current = null }
const sendMessage = async (input: string) => { setInput("")
const userMessage: UserMessage = { id: crypto.randomUUID(), role: "user", content: input, }
const assistantMessage: AssistantMessage = { id: crypto.randomUUID(), role: "assistant", content: "", }
setMessages(messages => [...messages, userMessage, assistantMessage])
const abortController = new AbortController() abortControllerRef.current = abortController
setIsLoading(true) setIsStreaming(true)
const updateAssistantMessage = ( update: (msg: AssistantMessage) => AssistantMessage, ) => { setMessages(prev => { const lastIndex = prev.length - 1 return prev.map((msg, i) => i === lastIndex && msg.role === "assistant" ? update(msg) : msg, ) }) }
const reportError = (error: MessageError) => { updateAssistantMessage(msg => ({ ...msg, content: msg.content || genericErrorAnswer, finishReason: "error", error, })) }
try { const chatId = chatIdRef.current const url = chatId ? `${endpoint}/v1/chats/${chatId}/messages` : `${endpoint}/v1/chats`
const headers: Record<string, string> = { "Content-Type": "application/json", } if (bearerToken) { headers["Authorization"] = `Bearer ${bearerToken}` }
const response = await fetch(url, { method: "post", headers, // The backend authenticates via a session cookie it sets itself // (or, when `bearerToken` is supplied, the header above) — never a // client-chosen id. credentials: "include", body: JSON.stringify({ content: input }), signal: abortController.signal, })
if (!response.ok) { // Every non-2xx before the stream opens carries the documented // `{"error": {code, message}}` envelope (`fastrag.serve._errors`) — // `429` arrives with `code: "RATE_LIMIT"`, distinguishing rate // limiting from any other request-level failure. const body: ErrorEnvelope | null = await response .json() .catch(() => null) reportError( body?.error ?? { code: "REQUEST_FAILED", message: response.statusText || `Request failed (${response.status})`, }, ) return }
if (!response.body) { reportError({ code: "REQUEST_FAILED", message: "Response had no body", }) return }
const reader = response.body.getReader() const decoder = new TextDecoder()
let buffer = "" let content = ""
while (true) { const { value, done } = await reader.read() if (value) buffer += decoder.decode(value, { stream: true })
// SSE records are separated by a blank line; an event can arrive // split across reads, so only complete records (ending in "\n\n") // are consumed here — the rest stays in `buffer` for the next read. let separator: number while ((separator = buffer.indexOf("\n\n")) !== -1) { const record = buffer.slice(0, separator) buffer = buffer.slice(separator + 2)
const { event, data } = parseSSERecord(record) if (!event || data === null) continue // e.g. a keep-alive comment
let payload: unknown try { payload = JSON.parse(data) } catch (e) { console.error("Error parsing SSE data", e) continue }
switch (event) { case "message_start": chatIdRef.current = (payload as MessageStartEvent).chat_id break case "sources": { const sources = (payload as SourcesEvent).sources updateAssistantMessage(msg => ({ ...msg, sources })) break } case "delta": { const delta = (payload as DeltaEvent).delta if (delta.type === "text") { content += delta.text updateAssistantMessage(msg => ({ ...msg, content })) setIsLoading(false) } break } case "message_end": { const end = payload as MessageEndEvent updateAssistantMessage(msg => ({ ...msg, finishReason: end.finish_reason, // The generated type allows `null` (a JSON-Schema nullable // field); the wire frame itself never sends it — `error` is // omitted entirely when there is none (`exclude_none=True` // server-side) — so this only ever narrows `null` away. error: end.error ?? undefined, })) break } } }
if (done) break } } catch (error) { if (error instanceof DOMException && error.name === "AbortError") { // Cancelled by the caller (`cancel()`), not a server-reported // failure — the same terminal state the server itself uses for a // torn-down stream, and not surfaced as an error. updateAssistantMessage(msg => ({ ...msg, finishReason: "interrupted" })) } else { console.error("Error during fetch:", error) reportError({ code: "NETWORK_ERROR", message: error instanceof Error ? error.message : String(error), }) } } finally { setIsLoading(false) setIsStreaming(false) abortControllerRef.current = null } }
return { input, setInput, isLoading, isStreaming, sendMessage, cancel, resetChat, }}src/lib/chatbot.ts
import type { components } from "./api"
/** * Hand-maintained aliases over `./api.d.ts` (generated from the FastRAG * server's OpenAPI document — see `task generate:api-types` and * `sdk/docs/source/contributing/frontend-types.md`). Every shape here that * crosses the wire is a type alias onto `components["schemas"][...]`, not a * duplicate hand-typed union — regenerating `./api.d.ts` after the server's * contract changes is enough to catch a drift at compile time, here and at * every place that imports from this file. */
/** * The server's full `role` union is `components["schemas"]["MessageRole"]` * (`"user" | "assistant" | "system"`); this widget never renders a * `system` turn, so it narrows to the two roles it actually displays. */export type Role = Extract< components["schemas"]["MessageRole"], "user" | "assistant">
/** * Mirrors the server's `finish_reason` (see `sdk/docs/source/guide/serving/streaming.md`): * `stop` | `length` | `error` | `interrupted`. There is no client-only * `"cancelled"` value — a cancelled generation is also reported as * `"interrupted"`, the same value the server itself uses when a stream is * torn down before it completes. */export type FinishReason = components["schemas"]["FinishReason"]
export type MessageError = { /** * A server-reported failure carries one of `ErrorCode` (a generation * that started and then failed) or `ApiErrorCode` (a request rejected * before any stream opened) — see `sdk/docs/source/guide/serving/streaming.md` * for why those are two different vocabularies. `"REQUEST_FAILED"` and * `"NETWORK_ERROR"` are synthesized by `useChatbot.ts` itself, for a * failure the server never got a chance to report at all (an unparsable * error body, or the request never reaching it) — not part of the * server's own vocabulary. */ code: | components["schemas"]["ErrorCode"] | components["schemas"]["ApiErrorCode"] | "REQUEST_FAILED" | "NETWORK_ERROR" message: string}
/** A retrieved source, as the server sends it in the `sources` SSE event. */export type Source = components["schemas"]["Source"]
interface BaseMessage { id: string role: Role content: string finishReason?: FinishReason error?: MessageError}
export interface UserMessage extends BaseMessage { role: "user"}
export interface AssistantMessage extends BaseMessage { role: "assistant" sources?: Source[]}
export type Message = UserMessage | AssistantMessage
export type ChatStrings = { title: string promptSuggestions: string[] placeholder: string interruptedStreamingError: string genericErrorAnswer: string}
/** The named SSE events `fastrag.serve._generation.sse_frame` emits. */export type MessageStartEvent = components["schemas"]["MessageStartEvent"]export type SourcesEvent = components["schemas"]["SourcesEvent"]export type DeltaEvent = components["schemas"]["DeltaEvent"]export type MessageEndEvent = components["schemas"]["MessageEndEvent"]
/** One ``{"error": {code, message}}`` envelope, per `fastrag.serve._errors`. */export type ErrorEnvelope = components["schemas"]["ApiErrorEnvelope"]src/lib/chatbot.ts
/** * This file was auto-generated by openapi-typescript. * Do not make direct changes to the file. */
export interface paths { "/healthz": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; /** * Healthz * @description Liveness and readiness. * * Returns ``200 {"status": "ok"}``. With a ``ChatStore`` entered, checks * the store's engine connection (``503`` when unreachable); the store's * engine is the only one the app owns — it creates none itself. */ get: operations["healthz_healthz_get"]; put?: never; post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; "/metrics": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; /** * Metrics Response * @description Build the ``GET /metrics`` response: ``401`` when a token is set and missing/wrong. * * ``config.metrics_token`` compared with :func:`hmac.compare_digest` * against the request's ``Authorization: Bearer <token>`` header — a * mismatch or absent header is ``401`` with no metric text, exactly like * every other authentication failure in ``fastrag.serve``. */ get: operations["metrics_response_metrics_get"]; put?: never; post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; "/v1/chats": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; /** List Chats */ get: operations["list_chats_v1_chats_get"]; put?: never; /** * Create Chat * @description Create a chat and stream the first answer. * * The only endpoint that doesn't require an existing session. */ post: operations["create_chat_v1_chats_post"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; "/v1/chats/{chat_id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; /** Get Chat */ get: operations["get_chat_v1_chats__chat_id__get"]; put?: never; post?: never; /** Delete Chat */ delete: operations["delete_chat_v1_chats__chat_id__delete"]; options?: never; head?: never; patch?: never; trace?: never; }; "/v1/chats/{chat_id}/messages": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; get?: never; put?: never; /** Add Message */ post: operations["add_message_v1_chats__chat_id__messages_post"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; "/v1/chats/{chat_id}/messages/{message_id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; get?: never; put?: never; /** * Regenerate Message * @description Regenerate or edit the answer for a turn and stream it. * * Covers both editing/resubmitting a user question (``body.content`` set, * target a ``user`` message) and regenerating an assistant answer * (``body.content`` absent, target an ``assistant`` message). The * ``ChatStore`` has no range-delete truncation primitive (task 027), so the * edit is expressed as appending the resubmitted question as a fresh user * turn and regenerating from it — the chat stays linear and the new answer * lands as the latest assistant message. */ post: operations["regenerate_message_v1_chats__chat_id__messages__message_id__post"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; "/v1/sessions": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; get?: never; put?: never; /** * Get Session * @description Return the caller's current session id, minting one only when needed. * * ``200`` when it revealed an existing session, ``201`` when it * minted one. ``404`` when bearer tokens are disabled. Sets no cookie: * this is the header client's channel. */ post: operations["get_session_v1_sessions_post"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; };}export type webhooks = Record<string, never>;export interface components { schemas: { /** * ApiErrorCode * @enum {string} */ ApiErrorCode: "UNAUTHORIZED" | "NOT_FOUND" | "CONFLICT" | "PAYLOAD_TOO_LARGE" | "UNSUPPORTED_MEDIA_TYPE" | "RATE_LIMIT" | "INVALID_REQUEST" | "INTERNAL_ERROR"; /** * ApiErrorEnvelope * @description The ``{"error": {"code", "message"}}`` body of a request-level failure. * * Raised as a ``4xx``/``5xx`` before a generation's stream opens. Never * returned through FastAPI's response-model validation — the * exception handlers in ``_errors.py`` build this exact shape by hand as a * plain dict, since a ``BaseHTTPMiddleware``/exception-handler response * bypasses per-route ``response_model`` machinery. This model exists only * so the shape still has a name in the OpenAPI document: it is attached to * every ``/v1`` route via ``include_router(responses=...)`` in ``app.py``. */ ApiErrorEnvelope: { error: components["schemas"]["ApiErrorInfo"]; }; /** * ApiErrorInfo * @description A request-level failure's outcome — ``code`` is one of :data:`ApiErrorCode`. */ ApiErrorInfo: { code: components["schemas"]["ApiErrorCode"]; /** Message */ message: string; }; /** ChatListResponse */ ChatListResponse: { /** Chats */ chats: components["schemas"]["ChatSummary"][]; /** First Id */ first_id: string | null; /** Has More */ has_more: boolean; /** Last Id */ last_id: string | null; }; /** ChatSummary */ ChatSummary: { /** * Created At * Format: date-time */ created_at: string; /** Id */ id: string; }; /** * ContentRequest * @description ``POST /v1/chats`` and ``POST /v1/chats/{chat_id}/messages`` body. */ ContentRequest: { /** Content */ content: string; }; /** * DeltaEvent * @description Payload of the SSE ``delta`` event. */ DeltaEvent: { delta: components["schemas"]["TextDelta"]; /** Message Id */ message_id: string; }; /** * ErrorCode * @enum {string} */ ErrorCode: "MODEL_ERROR" | "TIMEOUT" | "RATE_LIMIT" | "CONTEXT_TOO_LONG" | "NETWORK_ERROR" | "SERVICE_UNAVAILABLE" | "INVALID_REQUEST" | "INVALID_RESPONSE" | "INTERNAL_ERROR"; /** * ErrorInfo * @description A failed *generation*'s outcome — ``code`` is one of :data:`ErrorCode`. */ ErrorInfo: { code: components["schemas"]["ErrorCode"]; /** Message */ message: string; }; /** * FinishReason * @enum {string} */ FinishReason: "stop" | "length" | "error" | "interrupted"; /** * MessageEndEvent * @description Payload of the SSE ``message_end`` event. * * ``error`` is present only when ``finish_reason == "error"`` — omitted * from the wire frame entirely otherwise, not sent as ``null`` * (``_generation.py`` dumps this with ``exclude_none=True``). */ MessageEndEvent: { error?: components["schemas"]["ErrorInfo"] | null; finish_reason: components["schemas"]["FinishReason"]; /** Message Id */ message_id: string; }; /** MessageListResponse */ MessageListResponse: { /** First Id */ first_id: string | null; /** Has More */ has_more: boolean; /** Last Id */ last_id: string | null; /** Messages */ messages: components["schemas"]["MessageResponse"][]; }; /** MessageResponse */ MessageResponse: { /** Content */ content: string | null; error?: components["schemas"]["ErrorInfo"] | null; finish_reason?: components["schemas"]["FinishReason"] | null; /** Id */ id: string; role: components["schemas"]["MessageRole"]; /** Sources */ sources?: components["schemas"]["Source"][] | null; }; /** * MessageRole * @enum {string} */ MessageRole: "user" | "assistant" | "system"; /** * MessageStartEvent * @description Payload of the SSE ``message_start`` event — see ``streaming.md``. */ MessageStartEvent: { /** Chat Id */ chat_id: string; /** Message Id */ message_id: string; /** User Message Id */ user_message_id: string; }; /** * OptionalContentRequest * @description ``POST /v1/chats/{chat_id}/messages/{message_id}`` body. * * ``content`` is only meaningful when the target message is a ``user`` * message — rejected with ``422`` when the target is ``assistant`` (checked * in the route). */ OptionalContentRequest: { /** Content */ content?: string | null; }; /** SessionResponse */ SessionResponse: { /** * Expires At * Format: date-time */ expires_at: string; /** Session Id */ session_id: string; }; /** * Source * @description One retrieved page an answer may cite, deduplicated by ``url``. */ Source: { /** Favicon Url */ favicon_url?: string | null; /** Fragments */ fragments?: components["schemas"]["TextFragment"][]; /** Positions */ positions?: number[]; /** Site Name */ site_name?: string | null; /** Title */ title: string; /** Url */ url: string; }; /** * SourcesEvent * @description Payload of the SSE ``sources`` event. */ SourcesEvent: { /** Message Id */ message_id: string; /** Sources */ sources: components["schemas"]["Source"][]; }; /** * TextDelta * @description One token of an assistant answer, as the ``delta`` SSE event's ``delta`` field. */ TextDelta: { /** Text */ text: string; /** * Type * @default text * @constant */ type: "text"; }; /** * TextFragment * @description One passage of a page, as the terms of a URL text fragment. * * A link to ``url#:~:text=start,end`` scrolls to and highlights the text * from ``start`` through ``end``; to ``url#:~:text=start`` when ``end`` is * ``None``, the text ``start`` itself. The terms are plain text: a client * percent-encodes them, including ``-``, ``,`` and ``&``, which the syntax * reserves. */ TextFragment: { /** End */ end?: string | null; /** Start */ start: string; }; }; responses: never; parameters: never; requestBodies: never; headers: never; pathItems: never;}export type $defs = Record<string, never>;export interface operations { healthz_healthz_get: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": unknown; }; }; }; }; metrics_response_metrics_get: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": unknown; }; }; }; }; list_chats_v1_chats_get: { parameters: { query?: { before?: string | null; limit?: number; }; header?: { authorization?: string | null; }; path?: never; cookie?: { session_id?: string | null; }; }; requestBody?: never; responses: { /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ChatListResponse"]; }; }; /** @description Missing or invalid session cookie/bearer token. */ 401: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Chat or message not found. */ 404: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description A generation is already running for this chat. */ 409: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Request body too large. */ 413: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Unsupported Content-Type. */ 415: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Validation failed, or a configured cap was reached. */ 422: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Rate limit exceeded. */ 429: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Unhandled server error. */ 500: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; }; }; create_chat_v1_chats_post: { parameters: { query?: never; header?: { authorization?: string | null; }; path?: never; cookie?: { session_id?: string | null; }; }; requestBody: { content: { "application/json": components["schemas"]["ContentRequest"]; }; }; responses: { /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": unknown; }; }; /** @description Missing or invalid session cookie/bearer token. */ 401: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Chat or message not found. */ 404: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description A generation is already running for this chat. */ 409: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Request body too large. */ 413: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Unsupported Content-Type. */ 415: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Validation failed, or a configured cap was reached. */ 422: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Rate limit exceeded. */ 429: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Unhandled server error. */ 500: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; }; }; get_chat_v1_chats__chat_id__get: { parameters: { query?: { before?: string | null; limit?: number; }; header?: { authorization?: string | null; }; path: { chat_id: string; }; cookie?: { session_id?: string | null; }; }; requestBody?: never; responses: { /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["MessageListResponse"]; }; }; /** @description Missing or invalid session cookie/bearer token. */ 401: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Chat or message not found. */ 404: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description A generation is already running for this chat. */ 409: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Request body too large. */ 413: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Unsupported Content-Type. */ 415: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Validation failed, or a configured cap was reached. */ 422: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Rate limit exceeded. */ 429: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Unhandled server error. */ 500: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; }; }; delete_chat_v1_chats__chat_id__delete: { parameters: { query?: never; header?: { authorization?: string | null; }; path: { chat_id: string; }; cookie?: { session_id?: string | null; }; }; requestBody?: never; responses: { /** @description Successful Response */ 204: { headers: { [name: string]: unknown; }; content?: never; }; /** @description Missing or invalid session cookie/bearer token. */ 401: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Chat or message not found. */ 404: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description A generation is already running for this chat. */ 409: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Request body too large. */ 413: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Unsupported Content-Type. */ 415: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Validation failed, or a configured cap was reached. */ 422: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Rate limit exceeded. */ 429: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Unhandled server error. */ 500: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; }; }; add_message_v1_chats__chat_id__messages_post: { parameters: { query?: never; header?: { authorization?: string | null; }; path: { chat_id: string; }; cookie?: { session_id?: string | null; }; }; requestBody: { content: { "application/json": components["schemas"]["ContentRequest"]; }; }; responses: { /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": unknown; }; }; /** @description Missing or invalid session cookie/bearer token. */ 401: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Chat or message not found. */ 404: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description A generation is already running for this chat. */ 409: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Request body too large. */ 413: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Unsupported Content-Type. */ 415: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Validation failed, or a configured cap was reached. */ 422: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Rate limit exceeded. */ 429: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Unhandled server error. */ 500: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; }; }; regenerate_message_v1_chats__chat_id__messages__message_id__post: { parameters: { query?: never; header?: { authorization?: string | null; }; path: { chat_id: string; message_id: string; }; cookie?: { session_id?: string | null; }; }; requestBody: { content: { "application/json": components["schemas"]["OptionalContentRequest"]; }; }; responses: { /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": unknown; }; }; /** @description Missing or invalid session cookie/bearer token. */ 401: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Chat or message not found. */ 404: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description A generation is already running for this chat. */ 409: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Request body too large. */ 413: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Unsupported Content-Type. */ 415: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Validation failed, or a configured cap was reached. */ 422: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Rate limit exceeded. */ 429: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Unhandled server error. */ 500: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; }; }; get_session_v1_sessions_post: { parameters: { query?: never; header?: { authorization?: string | null; }; path?: never; cookie?: { session_id?: string | null; }; }; requestBody?: never; responses: { /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["SessionResponse"]; }; }; /** @description Missing or invalid session cookie/bearer token. */ 401: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Chat or message not found. */ 404: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description A generation is already running for this chat. */ 409: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Request body too large. */ 413: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Unsupported Content-Type. */ 415: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Validation failed, or a configured cap was reached. */ 422: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Rate limit exceeded. */ 429: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; /** @description Unhandled server error. */ 500: { headers: { [name: string]: unknown; }; content: { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; }; };}Update the import paths to match your project setup.
import { Chatbot } from "@fastrag-ui/chatbot"<Chatbot endpoint="http://localhost:8000" strings={{ title: "Chatbot Assistant", promptSuggestions: [] }} messages={messages} setMessages={setMessages}/>Talking to a FastRAG backend
Section titled “Talking to a FastRAG backend”endpoint is the base URL of a FastRAG backend serving fastrag.serve.create_app
(see sdk/app/, and the SDK’s serving guide — sdk/docs/source/guide/serving/http-api.md
and streaming.md — for the full contract): POST /v1/chats and
POST /v1/chats/{chat_id}/messages. The widget assembles those paths itself;
do not pass a per-chat path.
- Sessions. The example above uses the backend’s default mode:
POST /v1/chatsmints a session and sets it as anhttpOnlycookie, and every subsequent request is sent withcredentials: "include". The widget never invents its own chat id — it learns the server-assigned one from the stream’smessage_startevent. - Bearer tokens. When the backend instead runs with
ServeConfig.bearer_tokensenabled, pass a token via thebearerTokenprop (<Chatbot bearerToken={token} ... />) and the widget sends it asAuthorization: Bearer <token>on every request instead of relying on the cookie. - CORS. The backend’s
ServeConfig.allowed_origins(FASTRAG_ALLOWED_ORIGINSforsdk/app/) must include the origin this component is served from — credentialed cross-origin requests fail otherwise. The registry’s own dev server (astro dev) runs athttp://localhost:4321. - Errors and cancellation. A
4xx/5xxreturned before the stream opens (e.g.429withcode: "RATE_LIMIT"when the backend has rate limiting enabled) is shown as the assistant message’serror, using the server’s{code, message}envelope. Clicking the input’s stop control (or the “Clear chat” button, mid-stream) aborts the fetch and marks the messagefinishReason: "interrupted"— the same terminal state the server itself uses for a torn-down stream — without treating the cancellation as an error.