mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 14:43:54 -05:00
fix(chat-rendering): fixed rendering of tables, code. added copy, fixed error for settings sync, added additional formatting and styles to chat (#404)
* fix: styling of tables, need to add pushing message to top * message gets sent with some offset space * fixed scroll * updated styles, scrolling still messed up * fixed rendering of tables, code. added copy, fixed error for settings sync, added additional formatting and styles to chat * acknowledged PR comments --------- Co-authored-by: Adam Gough <adamgough@Adams-MacBook-Pro.local>
This commit is contained in:
421
apps/sim/app/chat/[subdomain]/chat-client.tsx
Normal file
421
apps/sim/app/chat/[subdomain]/chat-client.tsx
Normal file
@@ -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<ChatMessage[]>([])
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [chatConfig, setChatConfig] = useState<ChatConfig | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
|
||||
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 <ChatErrorState error={error} starCount={starCount} />
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<PasswordAuth
|
||||
subdomain={subdomain}
|
||||
starCount={starCount}
|
||||
onAuthSuccess={fetchChatConfig}
|
||||
title={title}
|
||||
primaryColor={primaryColor}
|
||||
/>
|
||||
)
|
||||
} else if (authRequired === 'email') {
|
||||
return (
|
||||
<EmailAuth
|
||||
subdomain={subdomain}
|
||||
starCount={starCount}
|
||||
onAuthSuccess={fetchChatConfig}
|
||||
title={title}
|
||||
primaryColor={primaryColor}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state while fetching config using the extracted component
|
||||
if (!chatConfig) {
|
||||
return <ChatLoadingState />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] bg-background flex flex-col">
|
||||
<style jsx>{`
|
||||
@keyframes growShrink {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
.loading-dot {
|
||||
animation: growShrink 1.5s infinite ease-in-out;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Header component */}
|
||||
<ChatHeader chatConfig={chatConfig} starCount={starCount} />
|
||||
|
||||
{/* Message Container component */}
|
||||
<ChatMessageContainer
|
||||
messages={messages}
|
||||
isLoading={isLoading}
|
||||
showScrollButton={showScrollButton}
|
||||
messagesContainerRef={messagesContainerRef as RefObject<HTMLDivElement>}
|
||||
messagesEndRef={messagesEndRef as RefObject<HTMLDivElement>}
|
||||
scrollToBottom={scrollToBottom}
|
||||
scrollToMessage={scrollToMessage}
|
||||
chatConfig={chatConfig}
|
||||
/>
|
||||
|
||||
{/* Input area (free-standing at the bottom) */}
|
||||
<div className="p-4 pb-6 relative">
|
||||
<div className="max-w-3xl mx-auto relative">
|
||||
<ChatInput
|
||||
onSubmit={(value) => {
|
||||
void handleSendMessage(value)
|
||||
}}
|
||||
isStreaming={isStreamingResponse}
|
||||
onStopStreaming={() => stopStreaming(setMessages)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string | null>(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<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSendOtp()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle OTP input key down
|
||||
const handleOtpKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50">
|
||||
<div className="p-6 max-w-md w-full mx-auto bg-white rounded-xl shadow-md">
|
||||
<div className="flex justify-between items-center w-full mb-4">
|
||||
<a href="https://simstudio.ai" target="_blank" rel="noopener noreferrer">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 50 50"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="rounded-[6px]"
|
||||
>
|
||||
<rect width="50" height="50" fill="#701FFC" />
|
||||
<path
|
||||
d="M34.1455 20.0728H16.0364C12.7026 20.0728 10 22.7753 10 26.1091V35.1637C10 38.4975 12.7026 41.2 16.0364 41.2H34.1455C37.4792 41.2 40.1818 38.4975 40.1818 35.1637V26.1091C40.1818 22.7753 37.4792 20.0728 34.1455 20.0728Z"
|
||||
fill="#701FFC"
|
||||
stroke="white"
|
||||
strokeWidth="3.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M25.0919 14.0364C26.7588 14.0364 28.1101 12.6851 28.1101 11.0182C28.1101 9.35129 26.7588 8 25.0919 8C23.425 8 22.0737 9.35129 22.0737 11.0182C22.0737 12.6851 23.425 14.0364 25.0919 14.0364Z"
|
||||
fill="#701FFC"
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M25.0915 14.856V19.0277V14.856ZM20.5645 32.1398V29.1216V32.1398ZM29.619 29.1216V32.1398V29.1216Z"
|
||||
fill="#701FFC"
|
||||
/>
|
||||
<path
|
||||
d="M25.0915 14.856V19.0277M20.5645 32.1398V29.1216M29.619 29.1216V32.1398"
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="25" cy="11" r="2" fill="#701FFC" />
|
||||
</svg>
|
||||
</a>
|
||||
<ChatHeader chatConfig={null} starCount={starCount} />
|
||||
</div>
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-xl font-bold mb-2">{title}</h2>
|
||||
<p className="text-gray-600">
|
||||
This chat requires email verification. Please enter your email to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{authError && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-600 rounded-md">
|
||||
{authError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full max-w-sm mx-auto">
|
||||
<div className="bg-white dark:bg-black/10 rounded-lg shadow-md p-6 space-y-4 border border-neutral-200 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="p-2 rounded-full bg-primary/10 text-primary">
|
||||
<Mail className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-medium text-center">Email Verification</h2>
|
||||
|
||||
{!showOtpVerification ? (
|
||||
// Step 1: Email Input
|
||||
<>
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-sm text-center">
|
||||
Enter your email address to access this chat
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="email" className="text-sm font-medium sr-only">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={handleEmailKeyDown}
|
||||
disabled={isSendingOtp}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{authError && (
|
||||
<div className="text-sm text-red-600 dark:text-red-500">{authError}</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleSendOtp}
|
||||
disabled={!email || isSendingOtp}
|
||||
className="w-full"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
{isSendingOtp ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending Code...
|
||||
</div>
|
||||
) : (
|
||||
'Continue'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Step 2: OTP Verification with OTPInputForm
|
||||
<>
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-sm text-center">
|
||||
Enter the verification code sent to
|
||||
</p>
|
||||
<p className="text-center font-medium text-sm break-all mb-3">{email}</p>
|
||||
|
||||
<OTPInputForm
|
||||
onSubmit={(value) => {
|
||||
setOtpValue(value)
|
||||
handleVerifyOtp()
|
||||
}}
|
||||
isLoading={isVerifyingOtp}
|
||||
error={authError}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-center pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResendOtp}
|
||||
disabled={isSendingOtp}
|
||||
className="text-sm text-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
{isSendingOtp ? 'Sending...' : 'Resend code'}
|
||||
</button>
|
||||
<span className="mx-2 text-neutral-300 dark:text-neutral-600">•</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowOtpVerification(false)
|
||||
setOtpValue('')
|
||||
setAuthError(null)
|
||||
}}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Change email
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string | null>(null)
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false)
|
||||
|
||||
// Handle keyboard input for auth forms
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50">
|
||||
<div className="p-6 max-w-md w-full mx-auto bg-white rounded-xl shadow-md">
|
||||
<div className="flex justify-between items-center w-full mb-4">
|
||||
<a href="https://simstudio.ai" target="_blank" rel="noopener noreferrer">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 50 50"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="rounded-[6px]"
|
||||
>
|
||||
<rect width="50" height="50" fill="#701FFC" />
|
||||
<path
|
||||
d="M34.1455 20.0728H16.0364C12.7026 20.0728 10 22.7753 10 26.1091V35.1637C10 38.4975 12.7026 41.2 16.0364 41.2H34.1455C37.4792 41.2 40.1818 38.4975 40.1818 35.1637V26.1091C40.1818 22.7753 37.4792 20.0728 34.1455 20.0728Z"
|
||||
fill="#701FFC"
|
||||
stroke="white"
|
||||
strokeWidth="3.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M25.0919 14.0364C26.7588 14.0364 28.1101 12.6851 28.1101 11.0182C28.1101 9.35129 26.7588 8 25.0919 8C23.425 8 22.0737 9.35129 22.0737 11.0182C22.0737 12.6851 23.425 14.0364 25.0919 14.0364Z"
|
||||
fill="#701FFC"
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M25.0915 14.856V19.0277V14.856ZM20.5645 32.1398V29.1216V32.1398ZM29.619 29.1216V32.1398V29.1216Z"
|
||||
fill="#701FFC"
|
||||
/>
|
||||
<path
|
||||
d="M25.0915 14.856V19.0277M20.5645 32.1398V29.1216M29.619 29.1216V32.1398"
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="25" cy="11" r="2" fill="#701FFC" />
|
||||
</svg>
|
||||
</a>
|
||||
<ChatHeader chatConfig={null} starCount={starCount} />
|
||||
</div>
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-xl font-bold mb-2">{title}</h2>
|
||||
<p className="text-gray-600">
|
||||
This chat is password-protected. Please enter the password to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm mx-auto">
|
||||
<div className="bg-white dark:bg-black/10 rounded-lg shadow-sm p-6 space-y-4 border border-neutral-200 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="p-2 rounded-full bg-primary/10 text-primary">
|
||||
<Lock className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-medium text-center">Password Required</h2>
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-sm text-center">
|
||||
Enter the password to access this chat
|
||||
</p>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleAuthenticate()
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="password" className="text-sm font-medium sr-only">
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter password"
|
||||
disabled={isAuthenticating}
|
||||
autoComplete="new-password"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{authError && (
|
||||
<div className="text-sm text-red-600 dark:text-red-500">{authError}</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!password || isAuthenticating}
|
||||
className="w-full"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
{isAuthenticating ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Authenticating...
|
||||
</div>
|
||||
) : (
|
||||
'Continue'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="py-5 px-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="flex justify-end">
|
||||
<div className="bg-[#F4F4F4] dark:bg-gray-600 rounded-3xl max-w-[80%] py-3 px-4">
|
||||
<div className="whitespace-pre-wrap break-words text-base leading-relaxed text-[#0D0D0D]">
|
||||
{isJsonObject ? (
|
||||
<pre>{JSON.stringify(message.content, null, 2)}</pre>
|
||||
) : (
|
||||
<span>{message.content}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// For assistant messages (on the left)
|
||||
return (
|
||||
<div className="py-5 px-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="flex">
|
||||
<div className="max-w-[80%]">
|
||||
<div className="whitespace-pre-wrap break-words text-base leading-relaxed">
|
||||
{isJsonObject ? (
|
||||
<pre>{JSON.stringify(message.content, null, 2)}</pre>
|
||||
) : (
|
||||
<MarkdownRenderer content={message.content as string} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ChatClient({ subdomain }: { subdomain: string }) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [chatConfig, setChatConfig] = useState<ChatConfig | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(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<string | null>(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<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle keyboard input for auth forms
|
||||
const handleAuthKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAuthenticate()
|
||||
}
|
||||
}
|
||||
|
||||
// Add a function to handle OTP input key down
|
||||
const handleOtpKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50">
|
||||
<div className="p-6 max-w-md mx-auto bg-white rounded-xl shadow-md">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<a href="https://simstudio.ai" target="_blank" rel="noopener noreferrer">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 50 50"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="rounded-[6px]"
|
||||
>
|
||||
<rect width="50" height="50" fill="#701FFC" />
|
||||
<path
|
||||
d="M34.1455 20.0728H16.0364C12.7026 20.0728 10 22.7753 10 26.1091V35.1637C10 38.4975 12.7026 41.2 16.0364 41.2H34.1455C37.4792 41.2 40.1818 38.4975 40.1818 35.1637V26.1091C40.1818 22.7753 37.4792 20.0728 34.1455 20.0728Z"
|
||||
fill="#701FFC"
|
||||
stroke="white"
|
||||
strokeWidth="3.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M25.0919 14.0364C26.7588 14.0364 28.1101 12.6851 28.1101 11.0182C28.1101 9.35129 26.7588 8 25.0919 8C23.425 8 22.0737 9.35129 22.0737 11.0182C22.0737 12.6851 23.425 14.0364 25.0919 14.0364Z"
|
||||
fill="#701FFC"
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M25.0915 14.856V19.0277V14.856ZM20.5645 32.1398V29.1216V32.1398ZM29.619 29.1216V32.1398V29.1216Z"
|
||||
fill="#701FFC"
|
||||
/>
|
||||
<path
|
||||
d="M25.0915 14.856V19.0277M20.5645 32.1398V29.1216M29.619 29.1216V32.1398"
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="25" cy="11" r="2" fill="#701FFC" />
|
||||
</svg>
|
||||
</a>
|
||||
<HeaderLinks stars={starCount} />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-red-500 mb-2">Error</h2>
|
||||
<p className="text-gray-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50">
|
||||
<div className="p-6 max-w-md w-full mx-auto bg-white rounded-xl shadow-md">
|
||||
<div className="flex justify-between items-center w-full mb-4">
|
||||
<a href="https://simstudio.ai" target="_blank" rel="noopener noreferrer">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 50 50"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="rounded-[6px]"
|
||||
>
|
||||
<rect width="50" height="50" fill="#701FFC" />
|
||||
<path
|
||||
d="M34.1455 20.0728H16.0364C12.7026 20.0728 10 22.7753 10 26.1091V35.1637C10 38.4975 12.7026 41.2 16.0364 41.2H34.1455C37.4792 41.2 40.1818 38.4975 40.1818 35.1637V26.1091C40.1818 22.7753 37.4792 20.0728 34.1455 20.0728Z"
|
||||
fill="#701FFC"
|
||||
stroke="white"
|
||||
strokeWidth="3.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M25.0919 14.0364C26.7588 14.0364 28.1101 12.6851 28.1101 11.0182C28.1101 9.35129 26.7588 8 25.0919 8C23.425 8 22.0737 9.35129 22.0737 11.0182C22.0737 12.6851 23.425 14.0364 25.0919 14.0364Z"
|
||||
fill="#701FFC"
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M25.0915 14.856V19.0277V14.856ZM20.5645 32.1398V29.1216V32.1398ZM29.619 29.1216V32.1398V29.1216Z"
|
||||
fill="#701FFC"
|
||||
/>
|
||||
<path
|
||||
d="M25.0915 14.856V19.0277M20.5645 32.1398V29.1216M29.619 29.1216V32.1398"
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="25" cy="11" r="2" fill="#701FFC" />
|
||||
</svg>
|
||||
</a>
|
||||
<HeaderLinks stars={starCount} />
|
||||
</div>
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-xl font-bold mb-2">{title}</h2>
|
||||
<p className="text-gray-600">
|
||||
{authRequired === 'password'
|
||||
? 'This chat is password-protected. Please enter the password to continue.'
|
||||
: 'This chat requires email verification. Please enter your email to continue.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{authError && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-600 rounded-md">
|
||||
{authError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{authRequired === 'password' ? (
|
||||
<div className="w-full max-w-sm mx-auto">
|
||||
<div className="bg-white dark:bg-black/10 rounded-lg shadow-sm p-6 space-y-4 border border-neutral-200 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="p-2 rounded-full bg-primary/10 text-primary">
|
||||
<Lock className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-medium text-center">Password Required</h2>
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-sm text-center">
|
||||
Enter the password to access this chat
|
||||
</p>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleAuthenticate()
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="password" className="text-sm font-medium sr-only">
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
disabled={isAuthenticating}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{authError && (
|
||||
<div className="text-sm text-red-600 dark:text-red-500">{authError}</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!password || isAuthenticating}
|
||||
className="w-full"
|
||||
style={{
|
||||
backgroundColor: chatConfig?.customizations?.primaryColor || '#802FFF',
|
||||
}}
|
||||
>
|
||||
{isAuthenticating ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Authenticating...
|
||||
</div>
|
||||
) : (
|
||||
'Continue'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full max-w-sm mx-auto">
|
||||
<div className="bg-white dark:bg-black/10 rounded-lg shadow-md p-6 space-y-4 border border-neutral-200 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="p-2 rounded-full bg-primary/10 text-primary">
|
||||
<Mail className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-medium text-center">Email Verification</h2>
|
||||
|
||||
{!showOtpVerification ? (
|
||||
// Step 1: Email Input
|
||||
<>
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-sm text-center">
|
||||
Enter your email address to access this chat
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="email" className="text-sm font-medium sr-only">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={handleEmailKeyDown}
|
||||
disabled={isSendingOtp || isAuthenticating}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{authError && (
|
||||
<div className="text-sm text-red-600 dark:text-red-500">{authError}</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleAuthenticate}
|
||||
disabled={!email || isSendingOtp || isAuthenticating}
|
||||
className="w-full"
|
||||
style={{
|
||||
backgroundColor: chatConfig?.customizations?.primaryColor || '#802FFF',
|
||||
}}
|
||||
>
|
||||
{isSendingOtp ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending Code...
|
||||
</div>
|
||||
) : (
|
||||
'Continue'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Step 2: OTP Verification with OTPInputForm
|
||||
<>
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-sm text-center">
|
||||
Enter the verification code sent to
|
||||
</p>
|
||||
<p className="text-center font-medium text-sm break-all mb-3">{email}</p>
|
||||
|
||||
<OTPInputForm
|
||||
onSubmit={(value) => {
|
||||
setOtpValue(value)
|
||||
handleAuthenticate()
|
||||
}}
|
||||
isLoading={isVerifyingOtp}
|
||||
error={authError}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-center pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleResendOtp()}
|
||||
disabled={isSendingOtp}
|
||||
className="text-sm text-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
{isSendingOtp ? 'Sending...' : 'Resend code'}
|
||||
</button>
|
||||
<span className="mx-2 text-neutral-300 dark:text-neutral-600">•</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowOtpVerification(false)
|
||||
setOtpValue('')
|
||||
setAuthError(null)
|
||||
}}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Change email
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading state while fetching config
|
||||
if (!chatConfig) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50">
|
||||
<div className="animate-pulse text-center">
|
||||
<div className="h-8 w-48 bg-gray-200 rounded mx-auto mb-4"></div>
|
||||
<div className="h-4 w-64 bg-gray-200 rounded mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] bg-background flex flex-col">
|
||||
<style jsx>{`
|
||||
@keyframes growShrink {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
.loading-dot {
|
||||
animation: growShrink 1.5s infinite ease-in-out;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Header with title and links */}
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{chatConfig?.customizations?.logoUrl && (
|
||||
<img
|
||||
src={chatConfig.customizations.logoUrl}
|
||||
alt={`${chatConfig?.title || 'Chat'} logo`}
|
||||
className="h-6 w-6 object-contain"
|
||||
/>
|
||||
)}
|
||||
<h2 className="text-lg font-medium">
|
||||
{chatConfig?.customizations?.headerText || chatConfig?.title || 'Chat'}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<HeaderLinks stars={starCount} />
|
||||
{!chatConfig?.customizations?.logoUrl && (
|
||||
<a href="https://simstudio.ai" target="_blank" rel="noopener noreferrer">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 50 50"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="rounded-[6px]"
|
||||
>
|
||||
<rect width="50" height="50" fill="#701FFC" />
|
||||
<path
|
||||
d="M34.1455 20.0728H16.0364C12.7026 20.0728 10 22.7753 10 26.1091V35.1637C10 38.4975 12.7026 41.2 16.0364 41.2H34.1455C37.4792 41.2 40.1818 38.4975 40.1818 35.1637V26.1091C40.1818 22.7753 37.4792 20.0728 34.1455 20.0728Z"
|
||||
fill="#701FFC"
|
||||
stroke="white"
|
||||
strokeWidth="3.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M25.0919 14.0364C26.7588 14.0364 28.1101 12.6851 28.1101 11.0182C28.1101 9.35129 26.7588 8 25.0919 8C23.425 8 22.0737 9.35129 22.0737 11.0182C22.0737 12.6851 23.425 14.0364 25.0919 14.0364Z"
|
||||
fill="#701FFC"
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M25.0915 14.856V19.0277V14.856ZM20.5645 32.1398V29.1216V32.1398ZM29.619 29.1216V32.1398V29.1216Z"
|
||||
fill="#701FFC"
|
||||
/>
|
||||
<path
|
||||
d="M25.0915 14.856V19.0277M20.5645 32.1398V29.1216M29.619 29.1216V32.1398"
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="25" cy="11" r="2" fill="#701FFC" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages container */}
|
||||
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full py-10 px-4">
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-lg font-medium">How can I help you today?</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{chatConfig.description || 'Ask me anything.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => <ClientChatMessage key={message.id} message={message} />)
|
||||
)}
|
||||
|
||||
{/* Loading indicator (shows only when executing) */}
|
||||
{isLoading && (
|
||||
<div className="py-5 px-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="flex">
|
||||
<div className="max-w-[80%]">
|
||||
<div className="flex items-center h-6">
|
||||
<div className="w-3 h-3 rounded-full bg-black dark:bg-black loading-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} className="h-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input area (fixed at bottom) */}
|
||||
<div className="bg-background p-6">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="relative rounded-2xl border bg-background shadow-sm">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
size="icon"
|
||||
disabled={!inputValue.trim() || isLoading}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 h-10 w-10 p-0 rounded-xl bg-black text-white hover:bg-gray-800"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex items-center">
|
||||
<motion.a
|
||||
href="https://github.com/simstudioai/sim"
|
||||
className="flex items-center gap-1.5 text-foreground/80 hover:text-foreground/100 p-1 rounded-md transition-colors duration-200"
|
||||
aria-label="GitHub"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut', delay: 0.1 }}
|
||||
>
|
||||
<GithubIcon className="w-[24px] h-[24px]" />
|
||||
<span className="text-sm font-medium hidden sm:inline">{stars}</span>
|
||||
</motion.a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
export default function MarkdownRenderer({ content }: { content: string }) {
|
||||
const customComponents = {
|
||||
// Paragraph
|
||||
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
|
||||
<p className="mt-0.5 mb-1 text-base leading-normal">{children}</p>
|
||||
),
|
||||
|
||||
// Headings
|
||||
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h1 className="text-xl font-semibold mt-3 mb-1">{children}</h1>
|
||||
),
|
||||
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h2 className="text-lg font-semibold mt-3 mb-1">{children}</h2>
|
||||
),
|
||||
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h3 className="text-base font-semibold mt-3 mb-1">{children}</h3>
|
||||
),
|
||||
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h4 className="text-sm font-semibold mt-3 mb-1">{children}</h4>
|
||||
),
|
||||
|
||||
// Lists
|
||||
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
|
||||
<ul className="list-disc pl-5 my-1 space-y-0.5">{children}</ul>
|
||||
),
|
||||
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
|
||||
<ol className="list-decimal pl-5 my-1 space-y-0.5">{children}</ol>
|
||||
),
|
||||
li: ({ children }: React.HTMLAttributes<HTMLLIElement>) => (
|
||||
<li className="text-base">{children}</li>
|
||||
),
|
||||
|
||||
// Code blocks
|
||||
pre: ({ children }: React.HTMLAttributes<HTMLPreElement>) => (
|
||||
<pre className="bg-gray-100 dark:bg-gray-800 my-2 p-3 rounded-md overflow-x-auto text-sm font-mono">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
|
||||
// Inline code
|
||||
code: ({
|
||||
inline,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLElement> & { className?: string; inline?: boolean }) => {
|
||||
if (inline) {
|
||||
return (
|
||||
<code
|
||||
className="text-[0.9em] bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded-md font-mono"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
// Extract language from className (format: language-xxx)
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const language = match ? match[1] : ''
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{language && (
|
||||
<div className="absolute right-2 top-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{language}
|
||||
</div>
|
||||
)}
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
// Blockquotes
|
||||
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
|
||||
<blockquote className="border-l-4 border-gray-200 dark:border-gray-700 pl-4 py-0 my-2 italic text-gray-700 dark:text-gray-300">
|
||||
<div className="py-0 flex items-center">{children}</div>
|
||||
</blockquote>
|
||||
),
|
||||
|
||||
// Horizontal rule
|
||||
hr: () => <hr className="my-3 border-gray-200 dark:border-gray-700" />,
|
||||
|
||||
// Links
|
||||
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
|
||||
// Tables
|
||||
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
|
||||
<div className="my-2 overflow-x-auto rounded-md border border-gray-200 dark:border-gray-700">
|
||||
<table className="w-full border-collapse">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
||||
<thead className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
{children}
|
||||
</thead>
|
||||
),
|
||||
tbody: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-900">
|
||||
{children}
|
||||
</tbody>
|
||||
),
|
||||
tr: ({ children, ...props }: React.HTMLAttributes<HTMLTableRowElement>) => (
|
||||
<tr className="hover:bg-gray-50 dark:hover:bg-gray-800/60 transition-colors" {...props}>
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
th: ({ children }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
|
||||
<td className="px-4 py-3 text-sm border-0">{children}</td>
|
||||
),
|
||||
|
||||
// Images
|
||||
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || 'Image'}
|
||||
className="max-w-full h-auto my-2 rounded-md"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="text-base leading-normal text-[#0D0D0D] dark:text-gray-100">
|
||||
<ReactMarkdown components={customComponents}>{processedContent}</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50">
|
||||
<div className="p-6 max-w-md mx-auto bg-white rounded-xl shadow-md">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<a href="https://simstudio.ai" target="_blank" rel="noopener noreferrer">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 50 50"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="rounded-[6px]"
|
||||
>
|
||||
<rect width="50" height="50" fill="#701FFC" />
|
||||
<path
|
||||
d="M34.1455 20.0728H16.0364C12.7026 20.0728 10 22.7753 10 26.1091V35.1637C10 38.4975 12.7026 41.2 16.0364 41.2H34.1455C37.4792 41.2 40.1818 38.4975 40.1818 35.1637V26.1091C40.1818 22.7753 37.4792 20.0728 34.1455 20.0728Z"
|
||||
fill="#701FFC"
|
||||
stroke="white"
|
||||
strokeWidth="3.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M25.0919 14.0364C26.7588 14.0364 28.1101 12.6851 28.1101 11.0182C28.1101 9.35129 26.7588 8 25.0919 8C23.425 8 22.0737 9.35129 22.0737 11.0182C22.0737 12.6851 23.425 14.0364 25.0919 14.0364Z"
|
||||
fill="#701FFC"
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M25.0915 14.856V19.0277V14.856ZM20.5645 32.1398V29.1216V32.1398ZM29.619 29.1216V32.1398V29.1216Z"
|
||||
fill="#701FFC"
|
||||
/>
|
||||
<path
|
||||
d="M25.0915 14.856V19.0277M20.5645 32.1398V29.1216M29.619 29.1216V32.1398"
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="25" cy="11" r="2" fill="#701FFC" />
|
||||
</svg>
|
||||
</a>
|
||||
<ChatHeader chatConfig={null} starCount={starCount} />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-red-500 mb-2">Error</h2>
|
||||
<p className="text-gray-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
98
apps/sim/app/chat/[subdomain]/components/header/header.tsx
Normal file
98
apps/sim/app/chat/[subdomain]/components/header/header.tsx
Normal file
@@ -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 (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex items-center gap-3">
|
||||
{chatConfig?.customizations?.logoUrl && (
|
||||
<img
|
||||
src={chatConfig.customizations.logoUrl}
|
||||
alt={`${chatConfig?.title || 'Chat'} logo`}
|
||||
className="h-7 w-7 object-contain rounded-md"
|
||||
/>
|
||||
)}
|
||||
<h2 className="text-base font-medium">
|
||||
{chatConfig?.customizations?.headerText || chatConfig?.title || 'Chat'}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<motion.a
|
||||
href="https://github.com/simstudioai/sim"
|
||||
className="flex items-center gap-1 text-foreground/70 hover:text-foreground transition-colors duration-200 rounded-md px-1.5 py-1 hover:bg-foreground/5"
|
||||
aria-label="GitHub"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
<GithubIcon className="w-[18px] h-[18px]" />
|
||||
<span className="text-xs font-medium hidden sm:inline-block">{starCount}</span>
|
||||
</motion.a>
|
||||
<a
|
||||
href="https://simstudio.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center text-foreground/80 hover:text-foreground/100 p-1 rounded-md transition-colors duration-200"
|
||||
>
|
||||
<div
|
||||
className="h-6 w-6 rounded-md flex items-center justify-center"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 50 50"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M34.1455 20.0728H16.0364C12.7026 20.0728 10 22.7753 10 26.1091V35.1637C10 38.4975 12.7026 41.2 16.0364 41.2H34.1455C37.4792 41.2 40.1818 38.4975 40.1818 35.1637V26.1091C40.1818 22.7753 37.4792 20.0728 34.1455 20.0728Z"
|
||||
fill={primaryColor}
|
||||
stroke="white"
|
||||
strokeWidth="3.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M25.0919 14.0364C26.7588 14.0364 28.1101 12.6851 28.1101 11.0182C28.1101 9.35129 26.7588 8 25.0919 8C23.425 8 22.0737 9.35129 22.0737 11.0182C22.0737 12.6851 23.425 14.0364 25.0919 14.0364Z"
|
||||
fill={primaryColor}
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M25.0915 14.856V19.0277M20.5645 32.1398V29.1216M29.619 29.1216V32.1398"
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="25" cy="11" r="2" fill={primaryColor} />
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
209
apps/sim/app/chat/[subdomain]/components/input/input.tsx
Normal file
209
apps/sim/app/chat/[subdomain]/components/input/input.tsx
Normal file
@@ -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<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex justify-center items-center text-black fixed bottom-0 left-0 right-0 pb-4 bg-gradient-to-t from-white to-transparent">
|
||||
<motion.div
|
||||
ref={wrapperRef}
|
||||
className="w-full max-w-3xl px-4"
|
||||
variants={containerVariants}
|
||||
animate={'expanded'}
|
||||
initial="collapsed"
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
borderRadius: 32,
|
||||
background: '#fff',
|
||||
border: '1px solid rgba(0,0,0,0.1)',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
}}
|
||||
onClick={handleActivate}
|
||||
>
|
||||
<div className="flex items-center w-full h-full p-2 rounded-full">
|
||||
{/* Text Input & Placeholder */}
|
||||
<div className="relative flex-1 mx-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Submit on Enter without Shift
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
// Submit on Cmd/Ctrl + Enter for consistency with other chat apps
|
||||
else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
// Allow Enter with Shift for newline by not preventing default
|
||||
}}
|
||||
className="flex-1 border-0 outline-0 rounded-md py-3 text-base bg-transparent w-full font-normal resize-none transition-height duration-100 ease-out"
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
lineHeight: '1.5',
|
||||
minHeight: '44px', // Set a fixed min-height for consistent text alignment
|
||||
verticalAlign: 'middle',
|
||||
paddingLeft: '12px', // Add left padding to move cursor to the right
|
||||
}}
|
||||
onFocus={handleActivate}
|
||||
onBlur={() => {
|
||||
if (!inputValue) {
|
||||
setIsActive(false)
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
textareaRef.current.style.overflowY = 'hidden'
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder=" " /* keep native placeholder empty – we draw ours */
|
||||
/>
|
||||
<div className="absolute left-0 top-0 w-full h-full pointer-events-none flex items-center">
|
||||
{!isActive && !inputValue && (
|
||||
<div
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 select-none"
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
zIndex: 0,
|
||||
background:
|
||||
'linear-gradient(90deg, rgba(150,150,150,0.2) 0%, rgba(150,150,150,0.8) 50%, rgba(150,150,150,0.2) 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
animation: 'shimmer 10s infinite linear',
|
||||
}}
|
||||
>
|
||||
{PLACEHOLDER}
|
||||
<style jsx global>{`
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="flex items-center justify-center bg-black hover:bg-zinc-700 text-white p-3 rounded-full"
|
||||
title={isStreaming ? 'Stop' : 'Send'}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (isStreaming) {
|
||||
onStopStreaming?.()
|
||||
} else {
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isStreaming ? <Square size={18} /> : <Send size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export function ChatLoadingState() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50">
|
||||
<div className="animate-pulse text-center">
|
||||
<div className="h-8 w-48 bg-gray-200 rounded mx-auto mb-4"></div>
|
||||
<div className="h-4 w-64 bg-gray-200 rounded mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import React, { HTMLAttributes, ReactNode } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
export function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={href}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" sideOffset={5} className="p-3 max-w-sm">
|
||||
<span className="font-medium text-xs truncate">{href}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MarkdownRenderer({
|
||||
content,
|
||||
customLinkComponent,
|
||||
}: {
|
||||
content: string
|
||||
customLinkComponent?: typeof LinkWithPreview
|
||||
}) {
|
||||
const LinkComponent = customLinkComponent || LinkWithPreview
|
||||
|
||||
const customComponents = {
|
||||
// Paragraph
|
||||
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
|
||||
<p className="text-base text-gray-800 dark:text-gray-200 leading-relaxed font-geist-sans mb-1 last:mb-0">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
|
||||
// Headings
|
||||
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h1 className="text-2xl font-semibold mt-8 mb-4 text-gray-900 dark:text-gray-100 font-geist-sans">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h2 className="text-xl font-semibold mt-7 mb-3 text-gray-900 dark:text-gray-100 font-geist-sans">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2.5 text-gray-900 dark:text-gray-100 font-geist-sans">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h4 className="text-base font-semibold mt-5 mb-2 text-gray-900 dark:text-gray-100 font-geist-sans">
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
|
||||
// Lists
|
||||
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
|
||||
<ul
|
||||
className="pl-6 mt-1 mb-1 space-y-1 text-gray-800 dark:text-gray-200 font-geist-sans"
|
||||
style={{ listStyleType: 'disc' }}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
|
||||
<ol
|
||||
className="pl-6 mt-1 mb-1 space-y-1 text-gray-800 dark:text-gray-200 font-geist-sans"
|
||||
style={{ listStyleType: 'decimal' }}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({
|
||||
children,
|
||||
ordered,
|
||||
...props
|
||||
}: React.LiHTMLAttributes<HTMLLIElement> & { ordered?: boolean }) => (
|
||||
<li
|
||||
className="text-gray-800 dark:text-gray-200 font-geist-sans"
|
||||
style={{ display: 'list-item' }}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
|
||||
// Code blocks
|
||||
pre: ({ children }: HTMLAttributes<HTMLPreElement>) => {
|
||||
let codeProps: HTMLAttributes<HTMLElement> = {}
|
||||
let codeContent: ReactNode = children
|
||||
|
||||
if (
|
||||
React.isValidElement<{ className?: string; children?: ReactNode }>(children) &&
|
||||
children.type === 'code'
|
||||
) {
|
||||
const childElement = children as React.ReactElement<{
|
||||
className?: string
|
||||
children?: ReactNode
|
||||
}>
|
||||
codeProps = { className: childElement.props.className }
|
||||
codeContent = childElement.props.children
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 dark:bg-black rounded-md my-6 text-sm">
|
||||
<div className="flex items-center justify-between px-4 py-1.5 border-b border-gray-700 dark:border-gray-800">
|
||||
<span className="text-xs text-gray-400 font-geist-sans">
|
||||
{codeProps.className?.replace('language-', '') || 'code'}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="p-4 overflow-x-auto font-mono text-gray-200 dark:text-gray-100">
|
||||
{codeContent}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
// Inline code
|
||||
code: ({
|
||||
inline,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLElement> & { className?: string; inline?: boolean }) => {
|
||||
if (inline) {
|
||||
return (
|
||||
<code
|
||||
className="text-[0.9em] bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-1 py-0.5 rounded font-mono"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
|
||||
// Blockquotes
|
||||
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
|
||||
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 py-1 my-4 italic text-gray-700 dark:text-gray-300 font-geist-sans">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
|
||||
// Horizontal rule
|
||||
hr: () => <hr className="my-8 border-t border-gray-500/[.07] dark:border-gray-400/[.07]" />,
|
||||
|
||||
// Links
|
||||
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
||||
<LinkComponent href={href || '#'} {...props}>
|
||||
{children}
|
||||
</LinkComponent>
|
||||
),
|
||||
|
||||
// Tables
|
||||
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
|
||||
<div className="my-4 overflow-x-auto w-full">
|
||||
<table className="min-w-full border border-gray-300 dark:border-gray-700 text-sm font-geist-sans table-auto">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
||||
<thead className="bg-gray-100 dark:bg-gray-800 text-left">{children}</thead>
|
||||
),
|
||||
tbody: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{children}
|
||||
</tbody>
|
||||
),
|
||||
tr: ({ children }: React.HTMLAttributes<HTMLTableRowElement>) => (
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/60 transition-colors">
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
th: ({ children }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
|
||||
<th className="px-4 py-2 font-medium text-gray-700 dark:text-gray-300 border-r border-gray-300 dark:border-gray-700 last:border-r-0">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
|
||||
<td className="px-4 py-2 text-gray-800 dark:text-gray-200 border-r border-gray-300 dark:border-gray-700 last:border-r-0 break-words">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
|
||||
// Images
|
||||
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || 'Image'}
|
||||
className="max-w-full h-auto my-3 rounded-md"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
// Pre-process content to fix common issues
|
||||
const processedContent = content.trim()
|
||||
|
||||
return (
|
||||
<div className="text-base text-[#0D0D0D] dark:text-gray-100 leading-relaxed break-words font-geist-sans space-y-4">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={customComponents}>
|
||||
{processedContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import React, { RefObject } from 'react'
|
||||
import { ArrowDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ChatMessage, ClientChatMessage } from '../message/message'
|
||||
|
||||
interface ChatMessageContainerProps {
|
||||
messages: ChatMessage[]
|
||||
isLoading: boolean
|
||||
showScrollButton: boolean
|
||||
messagesContainerRef: RefObject<HTMLDivElement>
|
||||
messagesEndRef: RefObject<HTMLDivElement>
|
||||
scrollToBottom: () => void
|
||||
scrollToMessage?: (messageId: string) => void
|
||||
chatConfig: {
|
||||
description?: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export function ChatMessageContainer({
|
||||
messages,
|
||||
isLoading,
|
||||
showScrollButton,
|
||||
messagesContainerRef,
|
||||
messagesEndRef,
|
||||
scrollToBottom,
|
||||
scrollToMessage,
|
||||
chatConfig,
|
||||
}: ChatMessageContainerProps) {
|
||||
return (
|
||||
<div className="relative flex-1 overflow-hidden bg-white flex flex-col">
|
||||
{/* Scrollable Messages Area */}
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="absolute inset-0 overflow-y-auto scroll-smooth overscroll-auto touch-pan-y"
|
||||
>
|
||||
<div className="max-w-3xl mx-auto px-4 pt-10 pb-20">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10">
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-lg font-medium">How can I help you today?</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{chatConfig?.description || 'Ask me anything.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => <ClientChatMessage key={message.id} message={message} />)
|
||||
)}
|
||||
|
||||
{/* Loading indicator (shows only when executing) */}
|
||||
{isLoading && (
|
||||
<div className="py-5 px-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="flex">
|
||||
<div className="max-w-[80%]">
|
||||
<div className="flex items-center h-6">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-800 dark:bg-gray-300 loading-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End of messages marker for scrolling */}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll to bottom button - appears when user scrolls up */}
|
||||
{showScrollButton && (
|
||||
<div className="absolute bottom-16 left-1/2 transform -translate-x-1/2 z-20">
|
||||
<Button
|
||||
onClick={scrollToBottom}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full py-1 px-3 border border-gray-200 bg-white shadow-lg hover:bg-gray-50 transition-all flex items-center gap-1"
|
||||
>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Scroll to bottom</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
109
apps/sim/app/chat/[subdomain]/components/message/message.tsx
Normal file
109
apps/sim/app/chat/[subdomain]/components/message/message.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import MarkdownRenderer from '../markdown-renderer/markdown-renderer'
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
content: string | Record<string, unknown>
|
||||
type: 'user' | 'assistant'
|
||||
timestamp: Date
|
||||
isInitialMessage?: boolean
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
function EnhancedMarkdownRenderer({ content }: { content: string }) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<MarkdownRenderer content={content} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function ClientChatMessage({ message }: { message: ChatMessage }) {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const isJsonObject = useMemo(() => {
|
||||
return typeof message.content === 'object' && message.content !== null
|
||||
}, [message.content])
|
||||
|
||||
// For user messages (on the right)
|
||||
if (message.type === 'user') {
|
||||
return (
|
||||
<div className="py-5 px-4" data-message-id={message.id}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="flex justify-end">
|
||||
<div className="bg-[#F4F4F4] dark:bg-gray-600 rounded-3xl max-w-[80%] py-3 px-4">
|
||||
<div className="whitespace-pre-wrap break-words text-base leading-relaxed text-gray-800 dark:text-gray-100">
|
||||
{isJsonObject ? (
|
||||
<pre>{JSON.stringify(message.content, null, 2)}</pre>
|
||||
) : (
|
||||
<span>{message.content as string}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// For assistant messages (on the left)
|
||||
return (
|
||||
<div className="pt-5 pb-2 px-4" data-message-id={message.id}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="flex flex-col">
|
||||
<div>
|
||||
<div className="break-words text-base">
|
||||
{isJsonObject ? (
|
||||
<pre className="text-gray-800 dark:text-gray-100">
|
||||
{JSON.stringify(message.content, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<EnhancedMarkdownRenderer content={message.content as string} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{message.type === 'assistant' &&
|
||||
!isJsonObject &&
|
||||
!message.isInitialMessage &&
|
||||
!message.isStreaming && (
|
||||
<div className="flex justify-start">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-1.5 px-2 py-1"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(message.content as string)
|
||||
setIsCopied(true)
|
||||
setTimeout(() => setIsCopied(false), 2000)
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5 text-green-500" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" sideOffset={5}>
|
||||
{isCopied ? 'Copied!' : 'Copy to clipboard'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
158
apps/sim/app/chat/[subdomain]/hooks/use-chat-streaming.ts
Normal file
158
apps/sim/app/chat/[subdomain]/hooks/use-chat-streaming.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { ChatMessage } from '../components/message/message'
|
||||
|
||||
export function useChatStreaming() {
|
||||
const [isStreamingResponse, setIsStreamingResponse] = useState(false)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const stopStreaming = (setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>) => {
|
||||
if (abortControllerRef.current) {
|
||||
// Abort the fetch request
|
||||
abortControllerRef.current.abort()
|
||||
abortControllerRef.current = null
|
||||
|
||||
// Add a message indicating the response was stopped
|
||||
setMessages((prev) => {
|
||||
const lastMessage = prev[prev.length - 1]
|
||||
|
||||
// Only modify if the last message is from the assistant (as expected)
|
||||
if (lastMessage && lastMessage.type === 'assistant') {
|
||||
// Append a note that the response was stopped
|
||||
const updatedContent =
|
||||
lastMessage.content +
|
||||
(lastMessage.content
|
||||
? '\n\n_Response stopped by user._'
|
||||
: '_Response stopped by user._')
|
||||
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ ...lastMessage, content: updatedContent, isStreaming: false },
|
||||
]
|
||||
}
|
||||
|
||||
return prev
|
||||
})
|
||||
|
||||
// Reset streaming state
|
||||
setIsStreamingResponse(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStreamedResponse = async (
|
||||
response: Response,
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>,
|
||||
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
scrollToBottom: () => void,
|
||||
userHasScrolled?: boolean
|
||||
) => {
|
||||
const messageId = crypto.randomUUID()
|
||||
|
||||
// Set streaming state before adding the assistant message
|
||||
setIsStreamingResponse(true)
|
||||
|
||||
// Add placeholder message
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: messageId,
|
||||
content: '',
|
||||
type: 'assistant',
|
||||
timestamp: new Date(),
|
||||
isStreaming: true,
|
||||
},
|
||||
])
|
||||
|
||||
// Stop showing loading indicator once streaming begins
|
||||
setIsLoading(false)
|
||||
|
||||
// Helper function to clean up after streaming ends (success or error)
|
||||
const cleanupStreaming = (messageContent?: string, appendContent = false) => {
|
||||
// Reset streaming state and controller
|
||||
setIsStreamingResponse(false)
|
||||
abortControllerRef.current = null
|
||||
|
||||
// Update message content and remove isStreaming flag
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (msg.id === messageId) {
|
||||
return {
|
||||
...msg,
|
||||
content: appendContent
|
||||
? msg.content + (messageContent || '')
|
||||
: messageContent || msg.content,
|
||||
isStreaming: false,
|
||||
}
|
||||
}
|
||||
return msg
|
||||
})
|
||||
)
|
||||
|
||||
// Only scroll to bottom if user hasn't manually scrolled
|
||||
if (!userHasScrolled) {
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if response body exists and is a ReadableStream
|
||||
if (!response.body) {
|
||||
cleanupStreaming("Error: Couldn't receive streaming response from server.")
|
||||
return
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
if (reader) {
|
||||
const decoder = new TextDecoder()
|
||||
let done = false
|
||||
|
||||
try {
|
||||
while (!done) {
|
||||
// Check if aborted before awaiting reader.read()
|
||||
if (abortControllerRef.current === null) {
|
||||
console.log('Stream reading aborted')
|
||||
break
|
||||
}
|
||||
|
||||
const { value, done: readerDone } = await reader.read()
|
||||
done = readerDone
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Show error to user in the message
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error during streaming'
|
||||
console.error('Error reading stream:', error)
|
||||
cleanupStreaming(`\n\n_Error: ${errorMessage}_`, true)
|
||||
return // Skip the finally block's cleanupStreaming call
|
||||
} finally {
|
||||
// Don't call cleanupStreaming here if we already called it in the catch block
|
||||
if (abortControllerRef.current !== null) {
|
||||
cleanupStreaming()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cleanupStreaming("Error: Couldn't process streaming response.")
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isStreamingResponse,
|
||||
setIsStreamingResponse,
|
||||
abortControllerRef,
|
||||
stopStreaming,
|
||||
handleStreamedResponse,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import ChatClient from './components/chat-client'
|
||||
|
||||
const logger = createLogger('ChatPage')
|
||||
import ChatClient from './chat-client'
|
||||
|
||||
export default async function ChatPage({ params }: { params: Promise<{ subdomain: string }> }) {
|
||||
const { subdomain } = await params
|
||||
logger.info(`[ChatPage] subdomain: ${subdomain}`)
|
||||
return <ChatClient subdomain={subdomain} />
|
||||
}
|
||||
|
||||
@@ -52,9 +52,17 @@ export function TelemetryConsentDialog() {
|
||||
const hasShownDialogThisSession = useRef(false)
|
||||
const isDevelopment = getNodeEnv() === 'development'
|
||||
|
||||
const isChatSubdomainOrPath =
|
||||
typeof window !== 'undefined' &&
|
||||
(window.location.pathname.startsWith('/chat/') ||
|
||||
(window.location.hostname !== 'simstudio.ai' &&
|
||||
window.location.hostname !== 'localhost' &&
|
||||
window.location.hostname !== '127.0.0.1' &&
|
||||
!window.location.hostname.startsWith('www.')))
|
||||
|
||||
// Check localStorage for saved preferences
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
if (typeof window === 'undefined' || isChatSubdomainOrPath) return
|
||||
|
||||
try {
|
||||
const notified = localStorage.getItem(TELEMETRY_NOTIFIED_KEY) === 'true'
|
||||
@@ -70,9 +78,15 @@ export function TelemetryConsentDialog() {
|
||||
} catch (error) {
|
||||
logger.error('Error reading telemetry preferences from localStorage:', error)
|
||||
}
|
||||
}, [setTelemetryNotifiedUser, setTelemetryEnabled])
|
||||
}, [setTelemetryNotifiedUser, setTelemetryEnabled, isChatSubdomainOrPath])
|
||||
|
||||
useEffect(() => {
|
||||
// Skip settings loading on chat subdomain pages
|
||||
if (isChatSubdomainOrPath) {
|
||||
setSettingsLoaded(true)
|
||||
return
|
||||
}
|
||||
|
||||
let isMounted = true
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
@@ -93,10 +107,10 @@ export function TelemetryConsentDialog() {
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [loadSettings])
|
||||
}, [loadSettings, isChatSubdomainOrPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return
|
||||
if (!settingsLoaded || isChatSubdomainOrPath) return
|
||||
|
||||
logger.debug('Settings loaded state:', {
|
||||
telemetryNotifiedUser,
|
||||
@@ -135,7 +149,13 @@ export function TelemetryConsentDialog() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [settingsLoaded, telemetryNotifiedUser, telemetryEnabled, setTelemetryNotifiedUser])
|
||||
}, [
|
||||
settingsLoaded,
|
||||
telemetryNotifiedUser,
|
||||
telemetryEnabled,
|
||||
setTelemetryNotifiedUser,
|
||||
isChatSubdomainOrPath,
|
||||
])
|
||||
|
||||
const handleAccept = () => {
|
||||
trackEvent('telemetry_consent_accepted', {
|
||||
|
||||
@@ -109,7 +109,6 @@
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
|
||||
@@ -61,6 +61,19 @@ export const useGeneralStore = create<GeneralStore>()(
|
||||
|
||||
// API Actions
|
||||
loadSettings: async (force = false) => {
|
||||
// Skip loading if on a subdomain or chat path
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
(window.location.pathname.startsWith('/chat/') ||
|
||||
(window.location.hostname !== 'simstudio.ai' &&
|
||||
window.location.hostname !== 'localhost' &&
|
||||
window.location.hostname !== '127.0.0.1' &&
|
||||
!window.location.hostname.startsWith('www.')))
|
||||
) {
|
||||
logger.debug('Skipping settings load - on chat or subdomain page')
|
||||
return
|
||||
}
|
||||
|
||||
// Skip loading if settings were recently loaded (within 5 seconds)
|
||||
const now = Date.now()
|
||||
if (!force && now - lastLoadTime < CACHE_TIMEOUT) {
|
||||
@@ -101,6 +114,18 @@ export const useGeneralStore = create<GeneralStore>()(
|
||||
},
|
||||
|
||||
updateSetting: async (key, value) => {
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
(window.location.pathname.startsWith('/chat/') ||
|
||||
(window.location.hostname !== 'simstudio.ai' &&
|
||||
window.location.hostname !== 'localhost' &&
|
||||
window.location.hostname !== '127.0.0.1' &&
|
||||
!window.location.hostname.startsWith('www.')))
|
||||
) {
|
||||
logger.debug(`Skipping setting update for ${key} on chat or subdomain page`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/settings', {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -167,5 +167,5 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
} satisfies Config
|
||||
|
||||
10
bun.lock
10
bun.lock
@@ -6,6 +6,7 @@
|
||||
"dependencies": {
|
||||
"@t3-oss/env-nextjs": "0.13.4",
|
||||
"@vercel/analytics": "1.5.0",
|
||||
"remark-gfm": "4.0.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/env": "^15.3.2",
|
||||
@@ -131,7 +132,6 @@
|
||||
"zod": "^3.24.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -1122,8 +1122,6 @@
|
||||
|
||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.7", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.7", "@tailwindcss/oxide": "4.1.7", "postcss": "^8.4.41", "tailwindcss": "4.1.7" } }, "sha512-88g3qmNZn7jDgrrcp3ZXEQfp9CVox7xjP1HN2TFKI03CltPVd/c61ydn5qJJL8FYunn0OqBaW5HNUga0kmPVvw=="],
|
||||
|
||||
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.16", "", { "dependencies": { "lodash.castarray": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA=="],
|
||||
|
||||
"@testing-library/dom": ["@testing-library/dom@10.4.0", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" } }, "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ=="],
|
||||
|
||||
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.6.3", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "chalk": "^3.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "lodash": "^4.17.21", "redent": "^3.0.0" } }, "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA=="],
|
||||
@@ -2068,14 +2066,10 @@
|
||||
|
||||
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
|
||||
|
||||
"lodash.castarray": ["lodash.castarray@4.4.0", "", {}, "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q=="],
|
||||
|
||||
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
||||
|
||||
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
|
||||
|
||||
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"log-symbols": ["log-symbols@7.0.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-zrc91EDk2M+2AXo/9BTvK91pqb7qrPg2nX/Hy+u8a5qQlbaOflCKO+6SqgZ+M+xUFxGdKTgwnGiL96b1W3ikRA=="],
|
||||
@@ -3186,8 +3180,6 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@tailwindcss/typography/postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
||||
|
||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||
|
||||
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@t3-oss/env-nextjs": "0.13.4",
|
||||
"@vercel/analytics": "1.5.0"
|
||||
"@vercel/analytics": "1.5.0",
|
||||
"remark-gfm": "4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/env": "^15.3.2",
|
||||
|
||||
Reference in New Issue
Block a user