/** *
* * * A chatbot panel component for the VN SDK framework. The component allows for a high degree * of customization through various props and custom CSS. * * ## Install Dependencies * * This component is part of the [@vn-sdk/react-ui](https://npmjs.com/package/@vn-sdk/react-ui) package. * * ```shell npm2yarn \"@vn-sdk/react-ui"\ * npm install @vn-sdk/react-core @vn-sdk/react-ui * ``` * * ## Usage * * ```tsx * import { AiChat } from "@vn-sdk/react-ui"; * import "@vn-sdk/react-ui/styles.css"; * * * ``` * * ### With Observability Hooks * * To monitor user interactions, provide the `observabilityHooks` prop. * **Note:** This requires a `publicApiKey` in the `` provider. * * ```tsx * * { * console.log("Message sent:", message); * }, * }} * /> * * ``` * * ### Look & Feel * * By default, VN SDK components do not have any styles. You can import VN SDK's stylesheet at the root of your project: * ```tsx title="YourRootComponent.tsx" * ... * import "@vn-sdk/react-ui/styles.css"; // [!code highlight] * * export function YourRootComponent() { * return ( * * ... * * ); * } * ``` * For more information about how to customize the styles, check out the [Customize Look & Feel](/guides/custom-look-and-feel/customize-built-in-ui-components) guide. */ import { ChatContext, ChatContextProvider, AiChatIcons, AiChatLabels, } from "./ChatContext"; import { Messages as DefaultMessages } from "./Messages"; import { Input as DefaultInput } from "./Input"; import { RenderMessage as DefaultRenderMessage } from "./messages/RenderMessage"; import { AssistantMessage as DefaultAssistantMessage } from "./messages/AssistantMessage"; import { UserMessage as DefaultUserMessage } from "./messages/UserMessage"; import { ImageRenderer as DefaultImageRenderer } from "./messages/ImageRenderer"; import React, { useEffect, useRef, useState, useCallback, useMemo } from "react"; import { SystemMessageFunction, useAiChatInternal as useAiChat, useAiContext, useAiMessagesContext, } from "@vn-sdk/react-core"; import type { SuggestionItem } from "@vn-sdk/react-core"; import { AiSDKError, AiSDKErrorCode, AiErrorEvent, Message, Severity, ErrorVisibility, styledConsole, AiErrorHandler, } from "@vn-sdk/shared"; import { randomId } from "@vn-sdk/shared"; import { AssistantMessageProps, ChatError, ComponentsMap, AiObservabilityHooks, ErrorMessageProps, ImageRendererProps, InputProps, MessagesProps, RenderMessageProps, RenderSuggestionsListProps, UserMessageProps, } from "./props"; import { HintFunction, runAgent, stopAgent } from "@vn-sdk/react-core"; import { ImageUploadQueue } from "./ImageUploadQueue"; import { Suggestions as DefaultRenderSuggestionsList } from "./Suggestions"; /** * The type of suggestions to use in the chat. * * `auto` - Suggestions are generated automatically. * `manual` - Suggestions are controlled programmatically. * `SuggestionItem[]` - Static suggestions array. */ export type ChatSuggestions = "auto" | "manual" | SuggestionItem[]; /** * Props for AiChat component. */ export interface AiChatProps { /** * Custom instructions to be added to the system message. Use this property to * provide additional context or guidance to the language model, influencing * its responses. These instructions can include specific directions, * preferences, or criteria that the model should consider when generating * its output, thereby tailoring the conversation more precisely to the * user's needs or the application's requirements. */ instructions?: string; /** * Controls the behavior of suggestions in the chat interface. * * `auto` (default) - Suggestions are generated automatically: * - When the chat is first opened (empty state) * - After each message exchange completes * - Uses configuration from `useAiChatSuggestions` hooks * * `manual` - Suggestions are controlled programmatically: * - Use `setSuggestions()` to set custom suggestions * - Use `generateSuggestions()` to trigger AI generation * - Access via `useAiChat` hook * * `SuggestionItem[]` - Static suggestions array: * - Always shows the same suggestions * - No AI generation involved */ suggestions?: ChatSuggestions; /** * A callback that gets called when the in progress state changes. */ onInProgress?: (inProgress: boolean) => void; /** * A callback that gets called when a new message it submitted. */ onSubmitMessage?: (message: string) => void | Promise; /** * A custom stop generation function. */ onStopGeneration?: OnStopGeneration; /** * A custom reload messages function. */ onReloadMessages?: OnReloadMessages; /** * A callback function to regenerate the assistant's response */ onRegenerate?: (messageId: string) => void; /** * A callback function when the message is copied */ onCopy?: (message: string) => void; /** * A callback function for thumbs up feedback */ onThumbsUp?: (message: Message) => void; /** * A callback function for thumbs down feedback */ onThumbsDown?: (message: Message) => void; /** * A list of markdown components to render in assistant message. * Useful when you want to render custom elements in the message (e.g a reference tag element) */ markdownTagRenderers?: ComponentsMap; /** * Icons can be used to set custom icons for the chat window. */ icons?: AiChatIcons; /** * Labels can be used to set custom labels for the chat window. */ labels?: AiChatLabels; /** * Enable image upload button (image inputs only supported on some models) */ imageUploadsEnabled?: boolean; /** * The 'accept' attribute for the file input used for image uploads. * Defaults to "image/*". */ inputFileAccept?: string; /** * A function that takes in context string and instructions and returns * the system message to include in the chat request. * Use this to completely override the system message, when providing * instructions is not enough. */ makeSystemMessage?: SystemMessageFunction; /** * Disables inclusion of VN SDK's default system message. When true, no system message is sent (this also suppresses any custom message from makeSystemMessage). */ disableSystemMessage?: boolean; /** * A custom assistant message component to use instead of the default. */ AssistantMessage?: React.ComponentType; /** * A custom user message component to use instead of the default. */ UserMessage?: React.ComponentType; /** * A custom error message component to use instead of the default. */ ErrorMessage?: React.ComponentType; /** * A custom Messages component to use instead of the default. */ Messages?: React.ComponentType; /** * @deprecated - use RenderMessage instead */ RenderTextMessage?: React.ComponentType; /** * @deprecated - use RenderMessage instead */ RenderActionExecutionMessage?: React.ComponentType; /** * @deprecated - use RenderMessage instead */ RenderAgentStateMessage?: React.ComponentType; /** * @deprecated - use RenderMessage instead */ RenderResultMessage?: React.ComponentType; /** * @deprecated - use RenderMessage instead */ RenderImageMessage?: React.ComponentType; /** * A custom RenderMessage component to use instead of the default. * * **Warning**: This is a break-glass solution to allow for custom * rendering of messages. You are most likely looking to swap out * the AssistantMessage and UserMessage components instead which * are also props. */ RenderMessage?: React.ComponentType; /** * A custom suggestions list component to use instead of the default. */ RenderSuggestionsList?: React.ComponentType; /** * A custom Input component to use instead of the default. */ Input?: React.ComponentType; /** * A custom image rendering component to use instead of the default. */ ImageRenderer?: React.ComponentType; /** * A class name to apply to the root element. */ className?: string; /** * Children to render. */ children?: React.ReactNode; hideStopButton?: boolean; /** * Event hooks for VN SDK chat events. * These hooks only work when publicApiKey is provided. */ observabilityHooks?: AiObservabilityHooks; /** * Custom error renderer for chat-specific errors. * When provided, errors will be displayed inline within the chat interface. */ renderError?: (error: { message: string; operation?: string; timestamp: number; onDismiss: () => void; onRetry?: () => void; }) => React.ReactNode; /** * Optional handler for comprehensive debugging and observability. */ onError?: AiErrorHandler; } interface OnStopGenerationArguments { /** * The name of the currently executing agent. */ currentAgentName: string | undefined; /** * The messages in the chat. */ messages: Message[]; /** * Set the messages in the chat. */ setMessages: (messages: Message[]) => void; /** * Stop chat generation. */ stopGeneration: () => void; /** * Restart the currently executing agent. */ restartCurrentAgent: () => void; /** * Stop the currently executing agent. */ stopCurrentAgent: () => void; /** * Run the currently executing agent. */ runCurrentAgent: (hint?: HintFunction) => Promise; /** * Set the state of the currently executing agent. */ setCurrentAgentState: (state: any) => void; } export type OnReloadMessagesArguments = OnStopGenerationArguments & { /** * The message on which "regenerate" was pressed */ messageId: string; }; export type OnStopGeneration = (args: OnStopGenerationArguments) => void; export type OnReloadMessages = (args: OnReloadMessagesArguments) => void; export type ImageUpload = { contentType: string; bytes: string; }; export function AiChat({ instructions, suggestions = "auto", onSubmitMessage, makeSystemMessage, disableSystemMessage, onInProgress, onStopGeneration, onReloadMessages, onRegenerate, onCopy, onThumbsUp, onThumbsDown, markdownTagRenderers, Messages = DefaultMessages, RenderMessage = DefaultRenderMessage, RenderSuggestionsList = DefaultRenderSuggestionsList, Input = DefaultInput, className, icons, labels, AssistantMessage = DefaultAssistantMessage, UserMessage = DefaultUserMessage, ImageRenderer = DefaultImageRenderer, ErrorMessage, imageUploadsEnabled, inputFileAccept = "image/*", hideStopButton, observabilityHooks, renderError, onError, // Legacy props - deprecated RenderTextMessage, RenderActionExecutionMessage, RenderAgentStateMessage, RenderResultMessage, RenderImageMessage, }: AiChatProps) { const { additionalInstructions, setChatInstructions, aiApiConfig, setBannerError, setInternalErrorHandler, removeInternalErrorHandler, } = useAiContext(); // Destructure stable values to avoid object reference changes const { publicApiKey, chatApiEndpoint } = aiApiConfig; const [selectedImages, setSelectedImages] = useState>([]); const [chatError, setChatError] = useState(null); const fileInputRef = useRef(null); // Helper function to trigger event hooks only if publicApiKey is provided const triggerObservabilityHook = useCallback( (hookName: keyof AiObservabilityHooks, ...args: any[]) => { if (publicApiKey && observabilityHooks?.[hookName]) { (observabilityHooks[hookName] as any)(...args); } if (observabilityHooks?.[hookName] && !publicApiKey) { setBannerError( new AiSDKError({ message: "observabilityHooks requires a publicApiKey to function.", code: AiSDKErrorCode.MISSING_PUBLIC_API_KEY_ERROR, severity: Severity.CRITICAL, visibility: ErrorVisibility.BANNER, }), ); styledConsole.publicApiKeyRequired("observabilityHooks"); } }, [publicApiKey, observabilityHooks, setBannerError], ); // Helper function to trigger chat error and render error UI const triggerChatError = useCallback( (error: any, operation: string, originalError?: any) => { const errorMessage = error?.message || error?.toString() || "An error occurred"; // Set chat error state for rendering setChatError({ message: errorMessage, operation, timestamp: Date.now(), }); const errorEvent: AiErrorEvent = { type: "error", timestamp: Date.now(), context: { source: "ui", request: { operation, url: chatApiEndpoint, startTime: Date.now(), }, technical: { environment: "browser", userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined, stackTrace: originalError instanceof Error ? originalError.stack : undefined, }, }, error, }; if (onError) { onError(errorEvent); } // Also trigger observability hook if available if (publicApiKey && observabilityHooks?.onError) { observabilityHooks.onError(errorEvent); } // Show banner error if onError hook is used without publicApiKey if (observabilityHooks?.onError && !publicApiKey) { setBannerError( new AiSDKError({ message: "observabilityHooks.onError requires a publicApiKey to function.", code: AiSDKErrorCode.MISSING_PUBLIC_API_KEY_ERROR, severity: Severity.CRITICAL, visibility: ErrorVisibility.BANNER, }), ); styledConsole.publicApiKeyRequired("observabilityHooks.onError"); } }, [publicApiKey, chatApiEndpoint, observabilityHooks, setBannerError], ); useEffect(() => { const id = "chat-component"; setInternalErrorHandler({ [id]: (error: AiErrorEvent) => { if (!error) return; triggerChatError(error.error, "sendMessage"); }, }); return () => { // unregister when this instance unmounts removeInternalErrorHandler?.(id); }; }, [triggerChatError, setInternalErrorHandler, removeInternalErrorHandler]); // Clipboard paste handler useEffect(() => { if (!imageUploadsEnabled) return; const handlePaste = async (e: ClipboardEvent) => { const target = e.target as HTMLElement; if (!target.parentElement?.classList.contains("aiInput")) return; const items = Array.from(e.clipboardData?.items || []); const imageItems = items.filter((item) => item.type.startsWith("image/")); if (imageItems.length === 0) return; e.preventDefault(); // Prevent default paste behavior for images const imagePromises: Promise[] = imageItems.map((item) => { const file = item.getAsFile(); if (!file) return Promise.resolve(null); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => { const base64String = (e.target?.result as string)?.split(",")[1]; if (base64String) { resolve({ contentType: file.type, bytes: base64String, }); } else { resolve(null); } }; reader.onerror = reject; reader.readAsDataURL(file); }); }); try { const loadedImages = (await Promise.all(imagePromises)).filter((img) => img !== null); setSelectedImages((prev) => [...prev, ...loadedImages]); } catch (error) { // Trigger chat-level error handler triggerChatError(error, "processClipboardImages", error); console.error("Error processing pasted images:", error); } }; document.addEventListener("paste", handlePaste); return () => document.removeEventListener("paste", handlePaste); }, [imageUploadsEnabled, triggerChatError]); useEffect(() => { if (!additionalInstructions?.length) { setChatInstructions(instructions || ""); return; } /* Will result in a prompt like: You are a helpful assistant. Additionally, follow these instructions: - Do not answer questions about the weather. - Do not answer questions about the stock market." */ const combinedAdditionalInstructions = [ instructions, "Additionally, follow these instructions:", ...additionalInstructions.map((instruction) => `- ${instruction}`), ]; setChatInstructions(combinedAdditionalInstructions.join("\n") || ""); }, [instructions, additionalInstructions]); const { messages, isLoading, sendMessage, stopGeneration, reloadMessages, suggestions: currentSuggestions, } = useAiChatLogic( suggestions, makeSystemMessage, disableSystemMessage, onInProgress, onSubmitMessage, onStopGeneration, onReloadMessages, ); // Track loading state changes for chat start/stop events const prevIsLoading = useRef(isLoading); useEffect(() => { if (prevIsLoading.current !== isLoading) { if (isLoading) { triggerObservabilityHook("onChatStarted"); } else { triggerObservabilityHook("onChatStopped"); } prevIsLoading.current = isLoading; } }, [isLoading, triggerObservabilityHook]); // Wrapper for sendMessage to clear selected images const handleSendMessage = (text: string) => { const images = selectedImages; setSelectedImages([]); if (fileInputRef.current) { fileInputRef.current.value = ""; } // Trigger message sent event triggerObservabilityHook("onMessageSent", text); return sendMessage(text, images); }; const chatContext = React.useContext(ChatContext); const isVisible = chatContext ? chatContext.open : true; const handleRegenerate = (messageId: string) => { if (onRegenerate) { onRegenerate(messageId); } // Trigger message regenerated event triggerObservabilityHook("onMessageRegenerated", messageId); reloadMessages(messageId); }; const handleCopy = (message: string) => { if (onCopy) { onCopy(message); } // Trigger message copied event triggerObservabilityHook("onMessageCopied", message); }; const handleImageUpload = async (event: React.ChangeEvent) => { if (!event.target.files || event.target.files.length === 0) { return; } const files = Array.from(event.target.files).filter((file) => file.type.startsWith("image/")); if (files.length === 0) return; const fileReadPromises = files.map((file) => { return new Promise<{ contentType: string; bytes: string }>((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => { const base64String = (e.target?.result as string)?.split(",")[1] || ""; if (base64String) { resolve({ contentType: file.type, bytes: base64String, }); } }; reader.onerror = reject; reader.readAsDataURL(file); }); }); try { const loadedImages = await Promise.all(fileReadPromises); setSelectedImages((prev) => [...prev, ...loadedImages]); } catch (error) { // Trigger chat-level error handler triggerChatError(error, "processUploadedImages", error); console.error("Error reading files:", error); } }; const removeSelectedImage = (index: number) => { setSelectedImages((prev) => prev.filter((_, i) => i !== index)); }; const handleThumbsUp = (message: Message) => { if (onThumbsUp) { onThumbsUp(message); } // Trigger feedback given event triggerObservabilityHook("onFeedbackGiven", message.id, "thumbsUp"); }; const handleThumbsDown = (message: Message) => { if (onThumbsDown) { onThumbsDown(message); } // Trigger feedback given event triggerObservabilityHook("onFeedbackGiven", message.id, "thumbsDown"); }; return ( {/* Render error above messages if present */} {chatError && renderError && renderError({ ...chatError, onDismiss: () => setChatError(null), onRetry: () => { // Clear error and potentially retry based on operation setChatError(null); // TODO: Implement specific retry logic based on operation type }, })} {currentSuggestions.length > 0 && ( )} {imageUploadsEnabled && ( <> )} fileInputRef.current?.click() : undefined} hideStopButton={hideStopButton} /> ); } export function WrappedAiChat({ children, icons, labels, className, }: { children: React.ReactNode; icons?: AiChatIcons; labels?: AiChatLabels; className?: string; }) { const chatContext = React.useContext(ChatContext); if (!chatContext) { return ( {}}>
{children}
); } return <>{children}; } export const useAiChatLogic = ( chatSuggestions: ChatSuggestions, makeSystemMessage?: SystemMessageFunction, disableSystemMessage?: boolean, onInProgress?: (isLoading: boolean) => void, onSubmitMessage?: (messageContent: string) => Promise | void, onStopGeneration?: OnStopGeneration, onReloadMessages?: OnReloadMessages, ) => { const { messages, sendMessage, setMessages, reloadMessages: defaultReloadMessages, stopGeneration: defaultStopGeneration, runChatCompletion, isLoading, suggestions, setSuggestions, generateSuggestions, resetSuggestions: resetSuggestionsFromHook, isLoadingSuggestions, } = useAiChat({ makeSystemMessage, disableSystemMessage, }); const generalContext = useAiContext(); const messagesContext = useAiMessagesContext(); // Get actions from context for message conversion const { actions } = generalContext; // Suggestion state management const [suggestionsFailed, setSuggestionsFailed] = useState(false); const hasGeneratedInitialSuggestions = useRef(false); // Handle static suggestions (when suggestions prop is an array) useEffect(() => { if (Array.isArray(chatSuggestions)) { setSuggestions(chatSuggestions); hasGeneratedInitialSuggestions.current = true; } }, [JSON.stringify(chatSuggestions), setSuggestions]); // Error handling wrapper const generateSuggestionsWithErrorHandling = useCallback( async (context: string) => { try { await generateSuggestions(); } catch (error) { console.error("Failed to generate suggestions:", error); setSuggestionsFailed(true); } }, [generateSuggestions], ); // Automatic suggestion generation logic useEffect(() => { // Only proceed if in auto mode, not currently loading, and not failed if (chatSuggestions !== "auto" || isLoadingSuggestions || suggestionsFailed) { return; } // Don't run during chat loading (when the assistant is responding) if (isLoading) { return; } // Check if we have any configurations if (Object.keys(generalContext.chatSuggestionConfiguration).length === 0) { return; } // Generate initial suggestions when chat is empty if (messages.length === 0 && !hasGeneratedInitialSuggestions.current) { hasGeneratedInitialSuggestions.current = true; generateSuggestionsWithErrorHandling("initial"); return; } // Generate post-message suggestions after assistant responds if (messages.length > 0 && suggestions.length === 0) { generateSuggestionsWithErrorHandling("post-message"); return; } }, [ chatSuggestions, isLoadingSuggestions, suggestionsFailed, messages.length, isLoading, suggestions.length, Object.keys(generalContext.chatSuggestionConfiguration).join(","), // Use stable string instead of object reference generateSuggestionsWithErrorHandling, ]); // Reset suggestion state when switching away from auto mode useEffect(() => { if (chatSuggestions !== "auto") { hasGeneratedInitialSuggestions.current = false; setSuggestionsFailed(false); } }, [chatSuggestions]); // Memoize context to prevent infinite re-renders const stableContext = useMemo( () => ({ ...generalContext, ...messagesContext, }), [ // Only include stable dependencies generalContext.actions, messagesContext.messages.length, generalContext.isLoading, ], ); // Wrapper for resetSuggestions that also resets local state const resetSuggestions = useCallback(() => { resetSuggestionsFromHook(); setSuggestionsFailed(false); hasGeneratedInitialSuggestions.current = false; }, [resetSuggestionsFromHook]); useEffect(() => { onInProgress?.(isLoading); }, [onInProgress, isLoading]); const safelySendMessage = async ( messageContent: string, imagesToUse?: Array<{ contentType: string; bytes: string }>, ) => { const images = imagesToUse || []; // Clear existing suggestions when user sends a message // This prevents stale suggestions from remaining visible during new conversation flow if (chatSuggestions === "auto" || chatSuggestions === "manual") { setSuggestions([]); } let firstMessage: Message | null = null; // Send text message if content provided if (messageContent.trim().length > 0) { const textMessage: Message = { id: randomId(), role: "user", content: messageContent, }; // Call user-provided submit handler if available if (onSubmitMessage) { try { await onSubmitMessage(messageContent); } catch (error) { console.error("Error in onSubmitMessage:", error); } } // Send the message and clear suggestions for auto/manual modes await sendMessage(textMessage, { followUp: images.length === 0, clearSuggestions: chatSuggestions === "auto" || chatSuggestions === "manual", }); if (!firstMessage) { firstMessage = textMessage; } } // Send image messages if (images.length > 0) { for (let i = 0; i < images.length; i++) { const imageMessage = { id: randomId(), role: "user" as const, image: { format: images[i].contentType.replace("image/", ""), bytes: images[i].bytes, }, } as unknown as Message; await sendMessage(imageMessage, { followUp: i === images.length - 1 }); if (!firstMessage) { firstMessage = imageMessage; } } } if (!firstMessage) { // Should not happen if send button is properly disabled, but handle just in case return { role: "user", content: "", id: randomId() } as Message; // Return a dummy message } // The hook implicitly triggers API call on appendMessage. // We return the first message sent (either text or first image) return firstMessage; }; const currentAgentName = generalContext.agentSession?.agentName; const restartCurrentAgent = async (hint?: HintFunction) => { if (generalContext.agentSession) { generalContext.setAgentSession({ ...generalContext.agentSession, nodeName: undefined, threadId: undefined, }); generalContext.setAiAgentStates((prevAgentStates) => { return { ...prevAgentStates, [generalContext.agentSession!.agentName]: { ...prevAgentStates[generalContext.agentSession!.agentName], threadId: undefined, nodeName: undefined, runId: undefined, }, }; }); } }; const runCurrentAgent = async (hint?: HintFunction) => { if (generalContext.agentSession) { await runAgent( generalContext.agentSession.agentName, stableContext, messagesContext.messages, sendMessage, runChatCompletion, ); } }; const stopCurrentAgent = () => { if (generalContext.agentSession) { stopAgent(generalContext.agentSession.agentName, stableContext); } }; const setCurrentAgentState = (state: any) => { if (generalContext.agentSession) { generalContext.setAiAgentStates((prevAgentStates) => { return { ...prevAgentStates, [generalContext.agentSession!.agentName]: { state, }, } as any; }); } }; function stopGeneration() { // Clear suggestions when stopping generation setSuggestions([]); if (onStopGeneration) { onStopGeneration({ messages: messages, setMessages, stopGeneration: defaultStopGeneration, currentAgentName, restartCurrentAgent, stopCurrentAgent, runCurrentAgent, setCurrentAgentState, }); } else { defaultStopGeneration(); } } function reloadMessages(messageId: string) { if (onReloadMessages) { onReloadMessages({ messages: messages, setMessages, stopGeneration: defaultStopGeneration, currentAgentName, restartCurrentAgent, stopCurrentAgent, runCurrentAgent, setCurrentAgentState, messageId, }); } else { defaultReloadMessages(messageId); } } return { messages, isLoading, suggestions, sendMessage: safelySendMessage, stopGeneration, reloadMessages, resetSuggestions, context: stableContext, actions, }; };