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:
Waleed Latif
2025-05-23 00:25:47 -07:00
committed by GitHub
parent 5664890065
commit c77a21e9b4
21 changed files with 1934 additions and 1179 deletions

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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,
}
}

View File

@@ -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} />
}

View File

@@ -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', {

View File

@@ -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",

View File

@@ -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',

View File

@@ -167,5 +167,5 @@ export default {
},
},
},
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
plugins: [require('tailwindcss-animate')],
} satisfies Config

View File

@@ -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=="],

View File

@@ -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",