diff --git a/chat-example/ChatContainer.tsx b/chat-example/ChatContainer.tsx new file mode 100644 index 0000000..7952376 --- /dev/null +++ b/chat-example/ChatContainer.tsx @@ -0,0 +1,90 @@ +'use client' + +import { useEffect, useState } from 'react' + +import { ChatInterface } from './ChatInterface' + +type ChatError = 'SESSION_CREATION_FAILED' | 'UNEXPECTED_ERROR' + +/** + * Error component to display when session creation fails. + */ +const ErrorDisplay: React.FC<{ error: ChatError }> = ({ error }) => { + const errorMessages = { + SESSION_CREATION_FAILED: 'Failed to create chat session. Please try again.', + UNEXPECTED_ERROR: 'An unexpected error occurred. Please try again later.', + } + + return ( +
+ {errorMessages[error]} +
+ ) +} + +/** + * Container component that handles session creation and renders the chat interface. + * This is a client component that creates a new chat session before rendering the chat UI. + */ +export const ChatContainer: React.FC = () => { + const [sessionId, setSessionId] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + const createSession = async () => { + try { + setIsLoading(true) + setError(null) + + const response = await fetch('/api/chat/session', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error('Failed to create session') + } + + const data = await response.json() + + if (!data?.sessionId) { + throw new Error('SESSION_CREATION_FAILED') + } + + setSessionId(data.sessionId) + } catch (err) { + console.error('Error creating chat session:', err) + setError( + err instanceof Error && err.message === 'SESSION_CREATION_FAILED' + ? 'SESSION_CREATION_FAILED' + : 'UNEXPECTED_ERROR', + ) + } finally { + setIsLoading(false) + } + } + + void createSession() + }, []) + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (error) { + return + } + + if (!sessionId) { + return null + } + + return +} diff --git a/chat-example/ChatInterface.tsx b/chat-example/ChatInterface.tsx new file mode 100644 index 0000000..c34d94e --- /dev/null +++ b/chat-example/ChatInterface.tsx @@ -0,0 +1,140 @@ +'use client' + +import { useCallback, useMemo } from 'react' + +import { Header, Button, Icon } from '@your-org/components' +import classNames from 'classnames' + +import { useChat } from '../hooks/useChat' +import { useChatScroll } from '../hooks/useChatScroll' +import { StreamingState } from '../types' + +type ChatInterfaceProps = { + /** + * The ID of the conversation to use. + */ + sessionId: string +} + +/** + * Renders a chat interface with streaming responses. + * This is a client component that manages the chat state for a given conversation. + */ +export const ChatInterface: React.FC = ({ sessionId }) => { + const { handleSendMessage, messages, streamingState, error, handleRetry } = + useChat({ sessionId }) + + const hasMessages = useMemo(() => messages.length > 0, [messages]) + + const { scrollContainerRef, chatEndRef, isAtBottom, scrollToBottom } = + useChatScroll(hasMessages) + + const handleScrollToBottom = useCallback(() => { + scrollToBottom() + }, [scrollToBottom]) + + const handleSubmit = useCallback( + (message: string) => { + void (async () => { + scrollToBottom() + await handleSendMessage(message) + })() + }, + [handleSendMessage, scrollToBottom], + ) + + return ( + <> +
+
+ {!hasMessages && ( +
+
+ Chat Interface + + Start a conversation with our AI assistant + +
+
+ )} + + {hasMessages && ( +
+
+ {new Date().toLocaleString()} +
+
    + {messages.map((message) => + message.type === 'user' ? ( +
    + {message.content} +
    + ) : ( +
    + {message.content} + {error && message.id === messages.at(-1)?.id && ( + + )} +
    + ), + )} +
+ + )} +
+
+ +
+ {!isAtBottom && hasMessages && ( +
+ +
+ )} + +
+
+ { + if (e.key === 'Enter') { + handleSubmit(e.currentTarget.value) + e.currentTarget.value = '' + } + }} + placeholder="Type your message..." + type="text" + /> + +
+
+
+ + ) +} diff --git a/chat-example/useChat.tsx b/chat-example/useChat.tsx new file mode 100644 index 0000000..ccd605d --- /dev/null +++ b/chat-example/useChat.tsx @@ -0,0 +1,254 @@ +import { useCallback, useEffect, useReducer, useRef } from 'react' + +// Types +export enum StreamingState { + IDLE = 'idle', + STREAMING = 'streaming', + RETRYING = 'retrying', +} + +type Message = { + id: string + type: 'user' | 'bot' + content: string + timestamp: Date + isComplete?: boolean +} + +type ChatState = { + messages: Message[] + streamingState: StreamingState + error: boolean +} + +// Action types +type ChatAction = + | { type: 'ADD_USER_MESSAGE'; payload: { content: string } } + | { type: 'ADD_BOT_MESSAGE'; payload: { id: string } } + | { type: 'APPEND_TO_BOT_MESSAGE'; payload: { content: string } } + | { type: 'COMPLETE_BOT_MESSAGE' } + | { type: 'SET_STREAMING_STATE'; payload: StreamingState } + | { type: 'SET_ERROR'; payload: boolean } + | { type: 'REMOVE_LAST_BOT_MESSAGE' } + +// API endpoint +const CHAT_API_URL = '/api/chat' + +// Initial state +const initialState: ChatState = { + messages: [], + streamingState: StreamingState.IDLE, + error: false, +} + +// Reducer +function chatReducer(state: ChatState, action: ChatAction): ChatState { + switch (action.type) { + case 'ADD_USER_MESSAGE': + return { + ...state, + messages: [ + ...state.messages, + { + id: crypto.randomUUID(), + type: 'user', + content: action.payload.content, + timestamp: new Date(), + }, + ], + } + + case 'ADD_BOT_MESSAGE': + return { + ...state, + messages: [ + ...state.messages, + { + id: action.payload.id, + type: 'bot', + content: '', + timestamp: new Date(), + isComplete: false, + }, + ], + } + + case 'APPEND_TO_BOT_MESSAGE': + return { + ...state, + messages: state.messages.map((msg, index) => + index === state.messages.length - 1 && msg.type === 'bot' + ? { ...msg, content: msg.content + action.payload.content } + : msg, + ), + } + + case 'COMPLETE_BOT_MESSAGE': + return { + ...state, + messages: state.messages.map((msg, index) => + index === state.messages.length - 1 && msg.type === 'bot' + ? { ...msg, isComplete: true } + : msg, + ), + } + + case 'SET_STREAMING_STATE': + return { + ...state, + streamingState: action.payload, + } + + case 'SET_ERROR': + return { + ...state, + error: action.payload, + } + + case 'REMOVE_LAST_BOT_MESSAGE': + return { + ...state, + messages: state.messages.slice(0, -1), + } + + default: + return state + } +} + +type UseChatProps = { + sessionId: string +} + +/** + * A React hook that manages chat message interactions with a streaming API endpoint. + * Provides functionality to send messages, receive streaming responses, and manage chat state. + */ +export const useChat = ({ sessionId }: UseChatProps) => { + const [state, dispatch] = useReducer(chatReducer, initialState) + const isMounted = useRef(true) + + // Cleanup on unmount + useEffect(() => { + isMounted.current = true + return () => { + isMounted.current = false + } + }, []) + + // Safe dispatch to avoid updating unmounted component + const safeDispatch = useCallback( + (action: ChatAction) => { + if (isMounted.current) { + dispatch(action) + } + }, + [dispatch], + ) + + const handleError = useCallback(() => { + safeDispatch({ type: 'SET_ERROR', payload: true }) + safeDispatch({ type: 'SET_STREAMING_STATE', payload: StreamingState.IDLE }) + }, [safeDispatch]) + + const processStream = useCallback( + async (reader: ReadableStreamDefaultReader) => { + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + // Convert the chunk to text + const chunk = new TextDecoder().decode(value) + const data = JSON.parse(chunk) + + // Handle the chunk + if (data.type === 'completion') { + safeDispatch({ type: 'COMPLETE_BOT_MESSAGE' }) + continue + } + + if (data.content) { + safeDispatch({ + type: 'APPEND_TO_BOT_MESSAGE', + payload: { content: data.content }, + }) + } + } + } catch (error) { + handleError() + } + }, + [safeDispatch, handleError], + ) + + const handleSendMessage = useCallback( + async (content: string) => { + if (!isMounted.current) return + + // Reset error state + safeDispatch({ type: 'SET_ERROR', payload: false }) + + // Set streaming state + safeDispatch({ + type: 'SET_STREAMING_STATE', + payload: StreamingState.STREAMING, + }) + + // Add bot message placeholder + const botMessageId = crypto.randomUUID() + safeDispatch({ type: 'ADD_BOT_MESSAGE', payload: { id: botMessageId } }) + + try { + const response = await fetch(CHAT_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message: content, + sessionId, + }), + }) + + if (!response.ok) { + throw new Error('HTTP error') + } + + const reader = response.body?.getReader() + if (!reader) { + throw new Error('No reader available') + } + + await processStream(reader) + } catch (error) { + handleError() + } finally { + safeDispatch({ + type: 'SET_STREAMING_STATE', + payload: StreamingState.IDLE, + }) + } + }, + [safeDispatch, sessionId, processStream, handleError], + ) + + const handleRetry = useCallback(async () => { + safeDispatch({ type: 'REMOVE_LAST_BOT_MESSAGE' }) + const lastUserMessage = state.messages + .filter((message) => message.type === 'user') + .at(-1)?.content + + if (lastUserMessage) { + await handleSendMessage(lastUserMessage) + } + }, [safeDispatch, handleSendMessage, state.messages]) + + return { + handleSendMessage, + messages: state.messages, + streamingState: state.streamingState, + error: state.error, + handleRetry, + } +}