diff --git a/apps/sim/app/chat/[subdomain]/chat-client.tsx b/apps/sim/app/chat/[subdomain]/chat-client.tsx new file mode 100644 index 0000000000..69ea41f2e0 --- /dev/null +++ b/apps/sim/app/chat/[subdomain]/chat-client.tsx @@ -0,0 +1,421 @@ +'use client' + +import { KeyboardEvent, RefObject, useEffect, useRef, useState } from 'react' +import { v4 as uuidv4 } from 'uuid' +import { getFormattedGitHubStars } from '@/app/(landing)/actions/github' +import EmailAuth from './components/auth/email/email-auth' +import PasswordAuth from './components/auth/password/password-auth' +import { ChatErrorState } from './components/error-state/error-state' +import { ChatHeader } from './components/header/header' +import { ChatInput } from './components/input/input' +import { ChatLoadingState } from './components/loading-state/loading-state' +import { ChatMessageContainer } from './components/message-container/message-container' +import { ChatMessage } from './components/message/message' +import { useChatStreaming } from './hooks/use-chat-streaming' + +interface ChatConfig { + id: string + title: string + description: string + customizations: { + primaryColor?: string + logoUrl?: string + welcomeMessage?: string + headerText?: string + } + authType?: 'public' | 'password' | 'email' +} + +export default function ChatClient({ subdomain }: { subdomain: string }) { + const [messages, setMessages] = useState([]) + const [inputValue, setInputValue] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [chatConfig, setChatConfig] = useState(null) + const [error, setError] = useState(null) + const messagesEndRef = useRef(null) + const messagesContainerRef = useRef(null) + const [starCount, setStarCount] = useState('3.4k') + const [conversationId, setConversationId] = useState('') + + // Simple state for showing scroll button + const [showScrollButton, setShowScrollButton] = useState(false) + + // Track if user has manually scrolled during response + const [userHasScrolled, setUserHasScrolled] = useState(false) + const isUserScrollingRef = useRef(false) + + // Authentication state + const [authRequired, setAuthRequired] = useState<'password' | 'email' | null>(null) + + // Use the custom streaming hook + const { isStreamingResponse, abortControllerRef, stopStreaming, handleStreamedResponse } = + useChatStreaming() + + const scrollToBottom = () => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) + } + } + + const scrollToMessage = (messageId: string, scrollToShowOnlyMessage: boolean = false) => { + const messageElement = document.querySelector(`[data-message-id="${messageId}"]`) + if (messageElement && messagesContainerRef.current) { + const container = messagesContainerRef.current + const containerRect = container.getBoundingClientRect() + const messageRect = messageElement.getBoundingClientRect() + + if (scrollToShowOnlyMessage) { + // ChatGPT-like behavior: scroll so only this message (and loading indicator if present) are visible + // Position the message at the very top of the container + const scrollTop = container.scrollTop + messageRect.top - containerRect.top + + container.scrollTo({ + top: scrollTop, + behavior: 'smooth', + }) + } else { + // Original behavior: Calculate scroll position to put the message near the top of the visible area + const scrollTop = container.scrollTop + messageRect.top - containerRect.top - 80 + + container.scrollTo({ + top: scrollTop, + behavior: 'smooth', + }) + } + } + } + + useEffect(() => { + const container = messagesContainerRef.current + if (!container) return + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = container + const distanceFromBottom = scrollHeight - scrollTop - clientHeight + setShowScrollButton(distanceFromBottom > 100) + + // Track if user is manually scrolling during streaming + if (isStreamingResponse && !isUserScrollingRef.current) { + setUserHasScrolled(true) + } + } + + container.addEventListener('scroll', handleScroll, { passive: true }) + return () => container.removeEventListener('scroll', handleScroll) + }, [isStreamingResponse]) + + // Reset user scroll tracking when streaming starts + useEffect(() => { + if (isStreamingResponse) { + // Reset userHasScrolled when streaming starts + setUserHasScrolled(false) + + // Give a small delay to distinguish between programmatic scroll and user scroll + isUserScrollingRef.current = true + setTimeout(() => { + isUserScrollingRef.current = false + }, 1000) + } + }, [isStreamingResponse]) + + const fetchChatConfig = async () => { + try { + const response = await fetch(`/api/chat/${subdomain}`, { + credentials: 'same-origin', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + }) + + if (!response.ok) { + // Check if auth is required + if (response.status === 401) { + const errorData = await response.json() + + if (errorData.error === 'auth_required_password') { + setAuthRequired('password') + return + } else if (errorData.error === 'auth_required_email') { + setAuthRequired('email') + return + } + } + + throw new Error(`Failed to load chat configuration: ${response.status}`) + } + + const data = await response.json() + + setChatConfig(data) + + if (data?.customizations?.welcomeMessage) { + setMessages([ + { + id: 'welcome', + content: data.customizations.welcomeMessage, + type: 'assistant', + timestamp: new Date(), + isInitialMessage: true, + }, + ]) + } + } catch (error) { + console.error('Error fetching chat config:', error) + setError('This chat is currently unavailable. Please try again later.') + } + } + + // Fetch chat config on mount and generate new conversation ID + useEffect(() => { + fetchChatConfig() + // Generate a new conversation ID whenever the page/chat is refreshed + setConversationId(uuidv4()) + + // Fetch GitHub stars + getFormattedGitHubStars() + .then((formattedStars) => { + setStarCount(formattedStars) + }) + .catch((err) => { + console.error('Failed to fetch GitHub stars:', err) + }) + }, [subdomain]) + + // Handle keyboard input for message sending + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + } + + // Handle sending a message + const handleSendMessage = async (messageParam?: string) => { + const messageToSend = messageParam ?? inputValue + if (!messageToSend.trim() || isLoading) return + + // Reset userHasScrolled when sending a new message + setUserHasScrolled(false) + + const userMessage: ChatMessage = { + id: crypto.randomUUID(), + content: messageToSend, + type: 'user', + timestamp: new Date(), + } + + // Add the user's message to the chat + setMessages((prev) => [...prev, userMessage]) + setInputValue('') + setIsLoading(true) + + // Scroll to show only the user's message and loading indicator + setTimeout(() => { + scrollToMessage(userMessage.id, true) + }, 100) + + try { + // Send structured payload to maintain chat context + const payload = { + message: userMessage.content, + conversationId, + } + + // Create a new AbortController for this request + abortControllerRef.current = new AbortController() + + // Use relative URL with credentials + const response = await fetch(`/api/chat/${subdomain}`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify(payload), + signal: abortControllerRef.current.signal, + }) + + if (!response.ok) { + throw new Error('Failed to get response') + } + + // Detect streaming response via content-type (text/plain) or absence of JSON content-type + const contentType = response.headers.get('Content-Type') || '' + + if (contentType.includes('text/plain')) { + // Handle streaming response - pass the current userHasScrolled value + await handleStreamedResponse( + response, + setMessages, + setIsLoading, + scrollToBottom, + userHasScrolled + ) + } else { + // Fallback to JSON response handling + const responseData = await response.json() + console.log('Message response:', responseData) + + // Handle different response formats from API + if ( + responseData.multipleOutputs && + responseData.contents && + Array.isArray(responseData.contents) + ) { + // For multiple outputs, create separate assistant messages for each + const assistantMessages = responseData.contents.map((content: any) => { + // Format the content appropriately + let formattedContent = content + + // Convert objects to strings for display + if (typeof formattedContent === 'object' && formattedContent !== null) { + try { + formattedContent = JSON.stringify(formattedContent) + } catch (e) { + formattedContent = 'Received structured data response' + } + } + + return { + id: crypto.randomUUID(), + content: formattedContent || 'No content found', + type: 'assistant' as const, + timestamp: new Date(), + } + }) + + // Add all messages at once + setMessages((prev) => [...prev, ...assistantMessages]) + } else { + // Handle single output as before + let messageContent = responseData.output + + if (!messageContent && responseData.content) { + if (typeof responseData.content === 'object') { + if (responseData.content.text) { + messageContent = responseData.content.text + } else { + try { + messageContent = JSON.stringify(responseData.content) + } catch (e) { + messageContent = 'Received structured data response' + } + } + } else { + messageContent = responseData.content + } + } + + const assistantMessage: ChatMessage = { + id: crypto.randomUUID(), + content: messageContent || "Sorry, I couldn't process your request.", + type: 'assistant', + timestamp: new Date(), + } + + setMessages((prev) => [...prev, assistantMessage]) + } + } + } catch (error) { + console.error('Error sending message:', error) + + const errorMessage: ChatMessage = { + id: crypto.randomUUID(), + content: 'Sorry, there was an error processing your message. Please try again.', + type: 'assistant', + timestamp: new Date(), + } + + setMessages((prev) => [...prev, errorMessage]) + } finally { + setIsLoading(false) + } + } + + // If error, show error message using the extracted component + if (error) { + return + } + + // If authentication is required, use the extracted components + if (authRequired) { + // Get title and description from the URL params or use defaults + const title = new URLSearchParams(window.location.search).get('title') || 'chat' + const primaryColor = new URLSearchParams(window.location.search).get('color') || '#802FFF' + + if (authRequired === 'password') { + return ( + + ) + } else if (authRequired === 'email') { + return ( + + ) + } + } + + // Loading state while fetching config using the extracted component + if (!chatConfig) { + return + } + + return ( +
+ + + {/* Header component */} + + + {/* Message Container component */} + } + messagesEndRef={messagesEndRef as RefObject} + scrollToBottom={scrollToBottom} + scrollToMessage={scrollToMessage} + chatConfig={chatConfig} + /> + + {/* Input area (free-standing at the bottom) */} +
+
+ { + void handleSendMessage(value) + }} + isStreaming={isStreamingResponse} + onStopStreaming={() => stopStreaming(setMessages)} + /> +
+
+
+ ) +} diff --git a/apps/sim/app/chat/[subdomain]/components/auth/email/email-auth.tsx b/apps/sim/app/chat/[subdomain]/components/auth/email/email-auth.tsx new file mode 100644 index 0000000000..0f632a0be2 --- /dev/null +++ b/apps/sim/app/chat/[subdomain]/components/auth/email/email-auth.tsx @@ -0,0 +1,304 @@ +'use client' + +import { KeyboardEvent, useState } from 'react' +import { Loader2, Mail } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { OTPInputForm } from '@/components/ui/input-otp-form' +import { ChatHeader } from '../../header/header' + +interface EmailAuthProps { + subdomain: string + starCount: string + onAuthSuccess: () => void + title?: string + primaryColor?: string +} + +export default function EmailAuth({ + subdomain, + starCount, + onAuthSuccess, + title = 'chat', + primaryColor = '#802FFF', +}: EmailAuthProps) { + // Email auth state + const [email, setEmail] = useState('') + const [authError, setAuthError] = useState(null) + const [isSendingOtp, setIsSendingOtp] = useState(false) + const [isVerifyingOtp, setIsVerifyingOtp] = useState(false) + + // OTP verification state + const [showOtpVerification, setShowOtpVerification] = useState(false) + const [otpValue, setOtpValue] = useState('') + + // Handle email input key down + const handleEmailKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSendOtp() + } + } + + // Handle OTP input key down + const handleOtpKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleVerifyOtp() + } + } + + // Handle sending OTP + const handleSendOtp = async () => { + setAuthError(null) + setIsSendingOtp(true) + + try { + const response = await fetch(`/api/chat/${subdomain}/otp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ email }), + }) + + if (!response.ok) { + const errorData = await response.json() + setAuthError(errorData.error || 'Failed to send verification code') + return + } + + // OTP sent successfully, show OTP input + setShowOtpVerification(true) + } catch (error) { + console.error('Error sending OTP:', error) + setAuthError('An error occurred while sending the verification code') + } finally { + setIsSendingOtp(false) + } + } + + // Handle verifying OTP + const handleVerifyOtp = async () => { + setAuthError(null) + setIsVerifyingOtp(true) + + try { + const response = await fetch(`/api/chat/${subdomain}/otp`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ email, otp: otpValue }), + }) + + if (!response.ok) { + const errorData = await response.json() + setAuthError(errorData.error || 'Invalid verification code') + return + } + + // Reset auth state and notify parent + onAuthSuccess() + } catch (error) { + console.error('Error verifying OTP:', error) + setAuthError('An error occurred during verification') + } finally { + setIsVerifyingOtp(false) + } + } + + // Handle resending OTP + const handleResendOtp = async () => { + setAuthError(null) + setIsSendingOtp(true) + + try { + const response = await fetch(`/api/chat/${subdomain}/otp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ email }), + }) + + if (!response.ok) { + const errorData = await response.json() + setAuthError(errorData.error || 'Failed to resend verification code') + return + } + + // Show a message that OTP was sent + setAuthError('Verification code sent. Please check your email.') + } catch (error) { + console.error('Error resending OTP:', error) + setAuthError('An error occurred while resending the verification code') + } finally { + setIsSendingOtp(false) + } + } + + return ( +
+
+ +
+

{title}

+

+ This chat requires email verification. Please enter your email to continue. +

+
+ + {authError && ( +
+ {authError} +
+ )} + +
+
+
+
+ +
+
+ +

Email Verification

+ + {!showOtpVerification ? ( + // Step 1: Email Input + <> +

+ Enter your email address to access this chat +

+ +
+
+ + setEmail(e.target.value)} + onKeyDown={handleEmailKeyDown} + disabled={isSendingOtp} + className="w-full" + /> +
+ + {authError && ( +
{authError}
+ )} + + +
+ + ) : ( + // Step 2: OTP Verification with OTPInputForm + <> +

+ Enter the verification code sent to +

+

{email}

+ + { + setOtpValue(value) + handleVerifyOtp() + }} + isLoading={isVerifyingOtp} + error={authError} + /> + +
+ + • + +
+ + )} +
+
+
+
+ ) +} diff --git a/apps/sim/app/chat/[subdomain]/components/auth/password/password-auth.tsx b/apps/sim/app/chat/[subdomain]/components/auth/password/password-auth.tsx new file mode 100644 index 0000000000..e43105789d --- /dev/null +++ b/apps/sim/app/chat/[subdomain]/components/auth/password/password-auth.tsx @@ -0,0 +1,195 @@ +'use client' + +import { KeyboardEvent, useState } from 'react' +import { Loader2, Lock } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { ChatHeader } from '../../header/header' + +interface PasswordAuthProps { + subdomain: string + starCount: string + onAuthSuccess: () => void + title?: string + primaryColor?: string +} + +export default function PasswordAuth({ + subdomain, + starCount, + onAuthSuccess, + title = 'chat', + primaryColor = '#802FFF', +}: PasswordAuthProps) { + // Password auth state + const [password, setPassword] = useState('') + const [authError, setAuthError] = useState(null) + const [isAuthenticating, setIsAuthenticating] = useState(false) + + // Handle keyboard input for auth forms + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleAuthenticate() + } + } + + // Handle authentication + const handleAuthenticate = async () => { + if (!password.trim()) { + setAuthError('Password is required') + return + } + + setAuthError(null) + setIsAuthenticating(true) + + try { + const payload = { password } + + const response = await fetch(`/api/chat/${subdomain}`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + const errorData = await response.json() + setAuthError(errorData.error || 'Authentication failed') + return + } + + // Authentication successful, notify parent + onAuthSuccess() + + // Reset auth state + setPassword('') + } catch (error) { + console.error('Authentication error:', error) + setAuthError('An error occurred during authentication') + } finally { + setIsAuthenticating(false) + } + } + + return ( +
+
+ +
+

{title}

+

+ This chat is password-protected. Please enter the password to continue. +

+
+ +
+
+
+
+ +
+
+ +

Password Required

+

+ Enter the password to access this chat +

+ +
{ + e.preventDefault() + handleAuthenticate() + }} + > +
+
+ + setPassword(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Enter password" + disabled={isAuthenticating} + autoComplete="new-password" + className="w-full" + /> +
+ + {authError && ( +
{authError}
+ )} + + +
+
+
+
+
+
+ ) +} diff --git a/apps/sim/app/chat/[subdomain]/components/chat-client.tsx b/apps/sim/app/chat/[subdomain]/components/chat-client.tsx deleted file mode 100644 index d15b757131..0000000000 --- a/apps/sim/app/chat/[subdomain]/components/chat-client.tsx +++ /dev/null @@ -1,976 +0,0 @@ -'use client' - -import { - Children, - isValidElement, - KeyboardEvent, - useEffect, - useMemo, - useRef, - useState, -} from 'react' -import { ArrowUp, Loader2, Lock, Mail } from 'lucide-react' -import { v4 as uuidv4 } from 'uuid' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { OTPInputForm } from '@/components/ui/input-otp-form' -import { ScrollArea } from '@/components/ui/scroll-area' -import { cn } from '@/lib/utils' -import { getFormattedGitHubStars } from '@/app/(landing)/actions/github' -import HeaderLinks from './components/header-links/header-links' -import MarkdownRenderer from './components/markdown-renderer/markdown-renderer' - -// Define message type -interface ChatMessage { - id: string - content: string - type: 'user' | 'assistant' - timestamp: Date -} - -// Define chat config type -interface ChatConfig { - id: string - title: string - description: string - customizations: { - primaryColor?: string - logoUrl?: string - welcomeMessage?: string - headerText?: string - } - authType?: 'public' | 'password' | 'email' -} - -// ChatGPT-style message component -function ClientChatMessage({ message }: { message: ChatMessage }) { - // Check if content is a JSON object - const isJsonObject = useMemo(() => { - return typeof message.content === 'object' && message.content !== null - }, [message.content]) - - // For user messages (on the right) - if (message.type === 'user') { - return ( -
-
-
-
-
- {isJsonObject ? ( -
{JSON.stringify(message.content, null, 2)}
- ) : ( - {message.content} - )} -
-
-
-
-
- ) - } - - // For assistant messages (on the left) - return ( -
-
-
-
-
- {isJsonObject ? ( -
{JSON.stringify(message.content, null, 2)}
- ) : ( - - )} -
-
-
-
-
- ) -} - -export default function ChatClient({ subdomain }: { subdomain: string }) { - const [messages, setMessages] = useState([]) - const [inputValue, setInputValue] = useState('') - const [isLoading, setIsLoading] = useState(false) - const [chatConfig, setChatConfig] = useState(null) - const [error, setError] = useState(null) - const messagesEndRef = useRef(null) - const messagesContainerRef = useRef(null) - const inputRef = useRef(null) - const [starCount, setStarCount] = useState('3.4k') - const [conversationId, setConversationId] = useState('') - - // Authentication state - const [authRequired, setAuthRequired] = useState<'password' | 'email' | null>(null) - const [password, setPassword] = useState('') - const [email, setEmail] = useState('') - const [authError, setAuthError] = useState(null) - const [isAuthenticating, setIsAuthenticating] = useState(false) - - // OTP verification state - const [showOtpVerification, setShowOtpVerification] = useState(false) - const [otpValue, setOtpValue] = useState('') - const [isSendingOtp, setIsSendingOtp] = useState(false) - const [isVerifyingOtp, setIsVerifyingOtp] = useState(false) - - // Fetch chat config function - const fetchChatConfig = async () => { - try { - // Use relative URL instead of absolute URL with env.NEXT_PUBLIC_APP_URL - const response = await fetch(`/api/chat/${subdomain}`, { - credentials: 'same-origin', - headers: { - 'X-Requested-With': 'XMLHttpRequest', - }, - }) - - if (!response.ok) { - // Check if auth is required - if (response.status === 401) { - const errorData = await response.json() - - if (errorData.error === 'auth_required_password') { - setAuthRequired('password') - return - } else if (errorData.error === 'auth_required_email') { - setAuthRequired('email') - return - } - } - - throw new Error(`Failed to load chat configuration: ${response.status}`) - } - - const data = await response.json() - - // The API returns the data directly without a wrapper - setChatConfig(data) - - // Add welcome message if configured - if (data?.customizations?.welcomeMessage) { - setMessages([ - { - id: 'welcome', - content: data.customizations.welcomeMessage, - type: 'assistant', - timestamp: new Date(), - }, - ]) - } - } catch (error) { - console.error('Error fetching chat config:', error) - setError('This chat is currently unavailable. Please try again later.') - } - } - - // Fetch chat config on mount and generate new conversation ID - useEffect(() => { - fetchChatConfig() - // Generate a new conversation ID whenever the page/chat is refreshed - setConversationId(uuidv4()) - - // Fetch GitHub stars - getFormattedGitHubStars() - .then((formattedStars) => { - setStarCount(formattedStars) - }) - .catch((err) => { - console.error('Failed to fetch GitHub stars:', err) - }) - }, [subdomain]) - - // Handle keyboard input for message sending - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSendMessage() - } - } - - // Handle keyboard input for auth forms - const handleAuthKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - handleAuthenticate() - } - } - - // Handle authentication - const handleAuthenticate = async () => { - if (authRequired === 'password') { - // Password auth remains the same - setAuthError(null) - setIsAuthenticating(true) - - try { - const payload = { password } - - const response = await fetch(`/api/chat/${subdomain}`, { - method: 'POST', - credentials: 'same-origin', - headers: { - 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - }, - body: JSON.stringify(payload), - }) - - if (!response.ok) { - const errorData = await response.json() - setAuthError(errorData.error || 'Authentication failed') - return - } - - await response.json() - - // Authentication successful, fetch config again - await fetchChatConfig() - - // Reset auth state - setAuthRequired(null) - setPassword('') - } catch (error) { - console.error('Authentication error:', error) - setAuthError('An error occurred during authentication') - } finally { - setIsAuthenticating(false) - } - } else if (authRequired === 'email') { - // For email auth, we now send an OTP first - if (!showOtpVerification) { - // Step 1: User has entered email, send OTP - setAuthError(null) - setIsSendingOtp(true) - - try { - const response = await fetch(`/api/chat/${subdomain}/otp`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - }, - body: JSON.stringify({ email }), - }) - - if (!response.ok) { - const errorData = await response.json() - setAuthError(errorData.error || 'Failed to send verification code') - return - } - - // OTP sent successfully, show OTP input - setShowOtpVerification(true) - } catch (error) { - console.error('Error sending OTP:', error) - setAuthError('An error occurred while sending the verification code') - } finally { - setIsSendingOtp(false) - } - } else { - // Step 2: User has entered OTP, verify it - setAuthError(null) - setIsVerifyingOtp(true) - - try { - const response = await fetch(`/api/chat/${subdomain}/otp`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - }, - body: JSON.stringify({ email, otp: otpValue }), - }) - - if (!response.ok) { - const errorData = await response.json() - setAuthError(errorData.error || 'Invalid verification code') - return - } - - await response.json() - - // OTP verified successfully, fetch config again - await fetchChatConfig() - - // Reset auth state - setAuthRequired(null) - setEmail('') - setOtpValue('') - setShowOtpVerification(false) - } catch (error) { - console.error('Error verifying OTP:', error) - setAuthError('An error occurred during verification') - } finally { - setIsVerifyingOtp(false) - } - } - } - } - - // Add this function to handle resending OTP - const handleResendOtp = async () => { - setAuthError(null) - setIsSendingOtp(true) - - try { - const response = await fetch(`/api/chat/${subdomain}/otp`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - }, - body: JSON.stringify({ email }), - }) - - if (!response.ok) { - const errorData = await response.json() - setAuthError(errorData.error || 'Failed to resend verification code') - return - } - - // Show a message that OTP was sent - setAuthError('Verification code sent. Please check your email.') - } catch (error) { - console.error('Error resending OTP:', error) - setAuthError('An error occurred while resending the verification code') - } finally { - setIsSendingOtp(false) - } - } - - // Add a function to handle email input key down - const handleEmailKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - handleAuthenticate() - } - } - - // Add a function to handle OTP input key down - const handleOtpKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - handleAuthenticate() - } - } - - // Scroll to bottom of messages - useEffect(() => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) - } - }, [messages]) - - // Handle sending a message - const handleSendMessage = async () => { - if (!inputValue.trim() || isLoading) return - - const userMessage: ChatMessage = { - id: crypto.randomUUID(), - content: inputValue, - type: 'user', - timestamp: new Date(), - } - - setMessages((prev) => [...prev, userMessage]) - setInputValue('') - setIsLoading(true) - - // Ensure focus remains on input field - if (inputRef.current) { - inputRef.current.focus() - } - - try { - // Send structured payload to maintain chat context - const payload = { - message: userMessage.content, - conversationId, - } - - // Use relative URL with credentials - const response = await fetch(`/api/chat/${subdomain}`, { - method: 'POST', - credentials: 'same-origin', - headers: { - 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - }, - body: JSON.stringify(payload), - }) - - if (!response.ok) { - throw new Error('Failed to get response') - } - - // Detect streaming response via content-type (text/plain) or absence of JSON content-type - const contentType = response.headers.get('Content-Type') || '' - - if (contentType.includes('text/plain')) { - // Handle streaming response - const messageId = crypto.randomUUID() - - // Add placeholder message - setMessages((prev) => [ - ...prev, - { - id: messageId, - content: '', - type: 'assistant', - timestamp: new Date(), - }, - ]) - - // Stop showing loading indicator once streaming begins - setIsLoading(false) - - // Ensure the response body exists and is a ReadableStream - const reader = response.body?.getReader() - if (reader) { - const decoder = new TextDecoder() - let done = false - while (!done) { - const { value, done: readerDone } = await reader.read() - if (value) { - const chunk = decoder.decode(value, { stream: true }) - if (chunk) { - setMessages((prev) => - prev.map((msg) => - msg.id === messageId ? { ...msg, content: msg.content + chunk } : msg - ) - ) - } - } - done = readerDone - } - } - } else { - // Fallback to JSON response handling - const responseData = await response.json() - console.log('Message response:', responseData) - - // Handle different response formats from API - if ( - responseData.multipleOutputs && - responseData.contents && - Array.isArray(responseData.contents) - ) { - // For multiple outputs, create separate assistant messages for each - const assistantMessages = responseData.contents.map((content: any) => { - // Format the content appropriately - let formattedContent = content - - // Convert objects to strings for display - if (typeof formattedContent === 'object' && formattedContent !== null) { - try { - formattedContent = JSON.stringify(formattedContent) - } catch (e) { - formattedContent = 'Received structured data response' - } - } - - return { - id: crypto.randomUUID(), - content: formattedContent || 'No content found', - type: 'assistant' as const, - timestamp: new Date(), - } - }) - - // Add all messages at once - setMessages((prev) => [...prev, ...assistantMessages]) - } else { - // Handle single output as before - let messageContent = responseData.output - - if (!messageContent && responseData.content) { - if (typeof responseData.content === 'object') { - if (responseData.content.text) { - messageContent = responseData.content.text - } else { - try { - messageContent = JSON.stringify(responseData.content) - } catch (e) { - messageContent = 'Received structured data response' - } - } - } else { - messageContent = responseData.content - } - } - - const assistantMessage: ChatMessage = { - id: crypto.randomUUID(), - content: messageContent || "Sorry, I couldn't process your request.", - type: 'assistant', - timestamp: new Date(), - } - - setMessages((prev) => [...prev, assistantMessage]) - } - } - } catch (error) { - console.error('Error sending message:', error) - - const errorMessage: ChatMessage = { - id: crypto.randomUUID(), - content: 'Sorry, there was an error processing your message. Please try again.', - type: 'assistant', - timestamp: new Date(), - } - - setMessages((prev) => [...prev, errorMessage]) - } finally { - setIsLoading(false) - // Ensure focus remains on input field even after the response - if (inputRef.current) { - inputRef.current.focus() - } - } - } - - // If error, show error message - if (error) { - return ( -
-
- -

Error

-

{error}

-
-
- ) - } - - // If authentication is required, show auth form - if (authRequired) { - // Get title and description from the URL params or use defaults - const title = new URLSearchParams(window.location.search).get('title') || 'chat' - const primaryColor = new URLSearchParams(window.location.search).get('color') || '#802FFF' - - return ( -
-
- -
-

{title}

-

- {authRequired === 'password' - ? 'This chat is password-protected. Please enter the password to continue.' - : 'This chat requires email verification. Please enter your email to continue.'} -

-
- - {authError && ( -
- {authError} -
- )} - -
- {authRequired === 'password' ? ( -
-
-
-
- -
-
- -

Password Required

-

- Enter the password to access this chat -

- -
{ - e.preventDefault() - handleAuthenticate() - }} - > -
-
- - setPassword(e.target.value)} - placeholder="Enter password" - disabled={isAuthenticating} - className="w-full" - /> -
- - {authError && ( -
{authError}
- )} - - -
-
-
-
- ) : ( -
-
-
-
- -
-
- -

Email Verification

- - {!showOtpVerification ? ( - // Step 1: Email Input - <> -

- Enter your email address to access this chat -

- -
-
- - setEmail(e.target.value)} - onKeyDown={handleEmailKeyDown} - disabled={isSendingOtp || isAuthenticating} - className="w-full" - /> -
- - {authError && ( -
{authError}
- )} - - -
- - ) : ( - // Step 2: OTP Verification with OTPInputForm - <> -

- Enter the verification code sent to -

-

{email}

- - { - setOtpValue(value) - handleAuthenticate() - }} - isLoading={isVerifyingOtp} - error={authError} - /> - -
- - • - -
- - )} -
-
- )} -
-
-
- ) - } - - // Loading state while fetching config - if (!chatConfig) { - return ( -
-
-
-
-
-
- ) - } - - return ( -
- - - {/* Header with title and links */} -
-
- {chatConfig?.customizations?.logoUrl && ( - {`${chatConfig?.title - )} -

- {chatConfig?.customizations?.headerText || chatConfig?.title || 'Chat'} -

-
-
- - {!chatConfig?.customizations?.logoUrl && ( - - - - - - - - - - - )} -
-
- - {/* Messages container */} -
-
- {messages.length === 0 ? ( -
-
-

How can I help you today?

-

- {chatConfig.description || 'Ask me anything.'} -

-
-
- ) : ( - messages.map((message) => ) - )} - - {/* Loading indicator (shows only when executing) */} - {isLoading && ( -
-
-
-
-
-
-
-
-
-
-
- )} - -
-
-
- - {/* Input area (fixed at bottom) */} -
-
-
- setInputValue(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Message..." - className="flex-1 border-0 focus-visible:ring-0 focus-visible:ring-offset-0 py-7 pr-16 bg-transparent pl-6 text-base min-h-[50px] rounded-2xl" - /> - -
-
-
-
- ) -} diff --git a/apps/sim/app/chat/[subdomain]/components/components/header-links/header-links.tsx b/apps/sim/app/chat/[subdomain]/components/components/header-links/header-links.tsx deleted file mode 100644 index 761b09901a..0000000000 --- a/apps/sim/app/chat/[subdomain]/components/components/header-links/header-links.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'use client' - -import { motion } from 'framer-motion' -import { GithubIcon } from '@/components/icons' - -interface HeaderLinksProps { - stars: string -} - -export default function HeaderLinks({ stars }: HeaderLinksProps) { - return ( -
- - - {stars} - -
- ) -} diff --git a/apps/sim/app/chat/[subdomain]/components/components/markdown-renderer/markdown-renderer.tsx b/apps/sim/app/chat/[subdomain]/components/components/markdown-renderer/markdown-renderer.tsx deleted file mode 100644 index 9cbfbf4acf..0000000000 --- a/apps/sim/app/chat/[subdomain]/components/components/markdown-renderer/markdown-renderer.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import ReactMarkdown from 'react-markdown' - -export default function MarkdownRenderer({ content }: { content: string }) { - const customComponents = { - // Paragraph - p: ({ children }: React.HTMLAttributes) => ( -

{children}

- ), - - // Headings - h1: ({ children }: React.HTMLAttributes) => ( -

{children}

- ), - h2: ({ children }: React.HTMLAttributes) => ( -

{children}

- ), - h3: ({ children }: React.HTMLAttributes) => ( -

{children}

- ), - h4: ({ children }: React.HTMLAttributes) => ( -

{children}

- ), - - // Lists - ul: ({ children }: React.HTMLAttributes) => ( -
    {children}
- ), - ol: ({ children }: React.HTMLAttributes) => ( -
    {children}
- ), - li: ({ children }: React.HTMLAttributes) => ( -
  • {children}
  • - ), - - // Code blocks - pre: ({ children }: React.HTMLAttributes) => ( -
    -        {children}
    -      
    - ), - - // Inline code - code: ({ - inline, - className, - children, - ...props - }: React.HTMLAttributes & { className?: string; inline?: boolean }) => { - if (inline) { - return ( - - {children} - - ) - } - - // Extract language from className (format: language-xxx) - const match = /language-(\w+)/.exec(className || '') - const language = match ? match[1] : '' - - return ( -
    - {language && ( -
    - {language} -
    - )} - - {children} - -
    - ) - }, - - // Blockquotes - blockquote: ({ children }: React.HTMLAttributes) => ( -
    -
    {children}
    -
    - ), - - // Horizontal rule - hr: () =>
    , - - // Links - a: ({ href, children, ...props }: React.AnchorHTMLAttributes) => ( - - {children} - - ), - - // Tables - table: ({ children }: React.TableHTMLAttributes) => ( -
    - {children}
    -
    - ), - thead: ({ children }: React.HTMLAttributes) => ( - - {children} - - ), - tbody: ({ children }: React.HTMLAttributes) => ( - - {children} - - ), - tr: ({ children, ...props }: React.HTMLAttributes) => ( - - {children} - - ), - th: ({ children }: React.ThHTMLAttributes) => ( - - {children} - - ), - td: ({ children }: React.TdHTMLAttributes) => ( - {children} - ), - - // Images - img: ({ src, alt, ...props }: React.ImgHTMLAttributes) => ( - {alt - ), - } - - // Process text to clean up unnecessary whitespace and formatting issues - const processedContent = content - .replace(/\n{2,}/g, '\n\n') // Replace multiple newlines with exactly double newlines - .replace(/^(#{1,6})\s+(.+?)\n{2,}/gm, '$1 $2\n') // Reduce space after headings to single newline - .trim() - - return ( -
    - {processedContent} -
    - ) -} diff --git a/apps/sim/app/chat/[subdomain]/components/error-state/error-state.tsx b/apps/sim/app/chat/[subdomain]/components/error-state/error-state.tsx new file mode 100644 index 0000000000..f06a85275a --- /dev/null +++ b/apps/sim/app/chat/[subdomain]/components/error-state/error-state.tsx @@ -0,0 +1,63 @@ +'use client' + +import React from 'react' +import { ChatHeader } from '../header/header' + +interface ChatErrorStateProps { + error: string + starCount: string +} + +export function ChatErrorState({ error, starCount }: ChatErrorStateProps) { + return ( +
    +
    + +

    Error

    +

    {error}

    +
    +
    + ) +} diff --git a/apps/sim/app/chat/[subdomain]/components/header/header.tsx b/apps/sim/app/chat/[subdomain]/components/header/header.tsx new file mode 100644 index 0000000000..608b88ce9d --- /dev/null +++ b/apps/sim/app/chat/[subdomain]/components/header/header.tsx @@ -0,0 +1,98 @@ +'use client' + +import React from 'react' +import { motion } from 'framer-motion' +import { GithubIcon } from '@/components/icons' + +interface ChatHeaderProps { + chatConfig: { + title?: string + customizations?: { + headerText?: string + logoUrl?: string + primaryColor?: string + } + } | null + starCount: string +} + +export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) { + const primaryColor = chatConfig?.customizations?.primaryColor || '#701FFC' + + return ( +
    +
    + {chatConfig?.customizations?.logoUrl && ( + {`${chatConfig?.title + )} +

    + {chatConfig?.customizations?.headerText || chatConfig?.title || 'Chat'} +

    +
    +
    + + + {starCount} + + +
    + + + + + + +
    +
    +
    +
    + ) +} diff --git a/apps/sim/app/chat/[subdomain]/components/input/input.tsx b/apps/sim/app/chat/[subdomain]/components/input/input.tsx new file mode 100644 index 0000000000..c5a20000a7 --- /dev/null +++ b/apps/sim/app/chat/[subdomain]/components/input/input.tsx @@ -0,0 +1,209 @@ +'use client' + +import React, { useEffect, useRef, useState } from 'react' +import { motion } from 'framer-motion' +import { Send, Square } from 'lucide-react' + +const PLACEHOLDER = 'Enter a message' +const MAX_TEXTAREA_HEIGHT = 160 // Max height in pixels (e.g., for about 4-5 lines) + +const containerVariants = { + collapsed: { + height: '56px', // Fixed height when collapsed + boxShadow: '0 1px 6px 0 rgba(0,0,0,0.05)', + }, + expanded: { + height: 'auto', + boxShadow: '0 2px 10px 0 rgba(0,0,0,0.1)', + }, +} as const + +export const ChatInput: React.FC<{ + onSubmit?: (value: string) => void + isStreaming?: boolean + onStopStreaming?: () => void +}> = ({ onSubmit, isStreaming = false, onStopStreaming }) => { + const wrapperRef = useRef(null) + const textareaRef = useRef(null) // Ref for the textarea + const [isActive, setIsActive] = useState(false) + const [inputValue, setInputValue] = useState('') + + // Function to adjust textarea height + const adjustTextareaHeight = () => { + if (textareaRef.current) { + const el = textareaRef.current + el.style.height = 'auto' // Reset height to correctly calculate scrollHeight + const scrollHeight = el.scrollHeight + + if (scrollHeight > MAX_TEXTAREA_HEIGHT) { + el.style.height = `${MAX_TEXTAREA_HEIGHT}px` + el.style.overflowY = 'auto' + } else { + el.style.height = `${scrollHeight}px` + el.style.overflowY = 'hidden' + } + } + } + + // Adjust height on input change + useEffect(() => { + adjustTextareaHeight() + }, [inputValue]) + + // Close the input when clicking outside (only when empty) + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) { + if (!inputValue) { + setIsActive(false) + if (textareaRef.current) { + textareaRef.current.style.height = 'auto' // Reset height + textareaRef.current.style.overflowY = 'hidden' // Ensure overflow is hidden + } + } + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [inputValue]) + + // Handle focus and initial height when activated + useEffect(() => { + if (isActive && textareaRef.current) { + textareaRef.current.focus() + adjustTextareaHeight() // Adjust height when becoming active + } + }, [isActive]) + + const handleActivate = () => { + setIsActive(true) + // Focus is now handled by the useEffect above + } + + const handleSubmit = () => { + if (!inputValue.trim()) return + onSubmit?.(inputValue.trim()) + setInputValue('') + if (textareaRef.current) { + textareaRef.current.style.height = 'auto' // Reset height after submit + textareaRef.current.style.overflowY = 'hidden' // Ensure overflow is hidden + } + setIsActive(false) + } + + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value) + } + + return ( +
    + +
    + {/* Text Input & Placeholder */} +
    +