improvement(chat): optimized chat performance & pass/email flow for chat (#406)

This commit is contained in:
Waleed Latif
2025-05-23 12:23:50 -07:00
committed by GitHub
parent c77a21e9b4
commit 0c8a773e56
6 changed files with 430 additions and 400 deletions

View File

@@ -1,6 +1,6 @@
'use client'
import { KeyboardEvent, RefObject, useEffect, useRef, useState } from 'react'
import { KeyboardEvent, RefObject, useCallback, useEffect, useMemo, 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'
@@ -26,6 +26,29 @@ interface ChatConfig {
authType?: 'public' | 'password' | 'email'
}
function throttle<T extends (...args: any[]) => any>(func: T, delay: number): T {
let timeoutId: NodeJS.Timeout | null = null
let lastExecTime = 0
return ((...args: Parameters<T>) => {
const currentTime = Date.now()
if (currentTime - lastExecTime > delay) {
func(...args)
lastExecTime = currentTime
} else {
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(
() => {
func(...args)
lastExecTime = Date.now()
},
delay - (currentTime - lastExecTime)
)
}
}) as T
}
export default function ChatClient({ subdomain }: { subdomain: string }) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [inputValue, setInputValue] = useState('')
@@ -51,45 +74,45 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
const { isStreamingResponse, abortControllerRef, stopStreaming, handleStreamedResponse } =
useChatStreaming()
const scrollToBottom = () => {
const scrollToBottom = useCallback(() => {
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()
const scrollToMessage = useCallback(
(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
if (scrollToShowOnlyMessage) {
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',
})
} else {
const scrollTop = container.scrollTop + messageRect.top - containerRect.top - 80
container.scrollTo({
top: scrollTop,
behavior: 'smooth',
})
container.scrollTo({
top: scrollTop,
behavior: 'smooth',
})
}
}
}
}
},
[messagesContainerRef]
)
useEffect(() => {
const container = messagesContainerRef.current
if (!container) return
const handleScroll = useCallback(
throttle(() => {
const container = messagesContainerRef.current
if (!container) return
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
setShowScrollButton(distanceFromBottom > 100)
@@ -98,11 +121,17 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
if (isStreamingResponse && !isUserScrollingRef.current) {
setUserHasScrolled(true)
}
}
}, 100),
[isStreamingResponse]
)
useEffect(() => {
const container = messagesContainerRef.current
if (!container) return
container.addEventListener('scroll', handleScroll, { passive: true })
return () => container.removeEventListener('scroll', handleScroll)
}, [isStreamingResponse])
}, [handleScroll])
// Reset user scroll tracking when streaming starts
useEffect(() => {
@@ -144,6 +173,9 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
throw new Error(`Failed to load chat configuration: ${response.status}`)
}
// Reset auth required state when authentication is successful
setAuthRequired(null)
const data = await response.json()
setChatConfig(data)
@@ -168,10 +200,8 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
// 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)
@@ -181,12 +211,15 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
})
}, [subdomain])
// Handle keyboard input for message sending
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
const refreshChat = () => {
fetchChatConfig()
}
const handleAuthSuccess = () => {
setAuthRequired(null)
setTimeout(() => {
refreshChat()
}, 800)
}
// Handle sending a message
@@ -348,8 +381,7 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
return (
<PasswordAuth
subdomain={subdomain}
starCount={starCount}
onAuthSuccess={fetchChatConfig}
onAuthSuccess={handleAuthSuccess}
title={title}
primaryColor={primaryColor}
/>
@@ -358,8 +390,7 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
return (
<EmailAuth
subdomain={subdomain}
starCount={starCount}
onAuthSuccess={fetchChatConfig}
onAuthSuccess={handleAuthSuccess}
title={title}
primaryColor={primaryColor}
/>

View File

@@ -1,15 +1,14 @@
'use client'
import { KeyboardEvent, useState } from 'react'
import { Loader2, Mail } from 'lucide-react'
import { Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
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
@@ -17,7 +16,6 @@ interface EmailAuthProps {
export default function EmailAuth({
subdomain,
starCount,
onAuthSuccess,
title = 'chat',
primaryColor = '#802FFF',
@@ -40,14 +38,6 @@ export default function EmailAuth({
}
}
// 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)
@@ -69,7 +59,6 @@ export default function EmailAuth({
return
}
// OTP sent successfully, show OTP input
setShowOtpVerification(true)
} catch (error) {
console.error('Error sending OTP:', error)
@@ -79,8 +68,13 @@ export default function EmailAuth({
}
}
// Handle verifying OTP
const handleVerifyOtp = async () => {
const handleVerifyOtp = async (otp?: string) => {
const codeToVerify = otp || otpValue
if (!codeToVerify || codeToVerify.length !== 6) {
return
}
setAuthError(null)
setIsVerifyingOtp(true)
@@ -91,7 +85,7 @@ export default function EmailAuth({
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ email, otp: otpValue }),
body: JSON.stringify({ email, otp: codeToVerify }),
})
if (!response.ok) {
@@ -100,7 +94,6 @@ export default function EmailAuth({
return
}
// Reset auth state and notify parent
onAuthSuccess()
} catch (error) {
console.error('Error verifying OTP:', error)
@@ -110,7 +103,6 @@ export default function EmailAuth({
}
}
// Handle resending OTP
const handleResendOtp = async () => {
setAuthError(null)
setIsSendingOtp(true)
@@ -131,7 +123,6 @@ export default function EmailAuth({
return
}
// Show a message that OTP was sent
setAuthError('Verification code sent. Please check your email.')
} catch (error) {
console.error('Error resending OTP:', error)
@@ -142,163 +133,167 @@ export default function EmailAuth({
}
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]"
<Dialog open={true} onOpenChange={() => {}}>
<DialogContent
className="sm:max-w-[450px] flex flex-col p-0 gap-0 overflow-hidden"
hideCloseButton
>
<DialogHeader className="px-6 py-4 border-b">
<div className="flex items-center justify-center">
<a
href="https://simstudio.ai"
target="_blank"
rel="noopener noreferrer"
className="mb-2"
>
<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}
<svg
width="40"
height="40"
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>
)}
<DialogTitle className="text-lg font-medium text-center">{title}</DialogTitle>
</DialogHeader>
<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
<div className="p-6">
{!showOtpVerification ? (
<>
<div className="mb-4 text-center">
<p className="text-muted-foreground">
This chat requires email verification. Please enter your email to continue.
</p>
</div>
<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>
{authError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-600 rounded-md text-sm">
{authError}
</div>
</>
) : (
// Step 2: OTP Verification with OTPInputForm
<>
<p className="text-neutral-500 dark:text-neutral-400 text-sm text-center">
)}
<form
onSubmit={(e) => {
e.preventDefault()
handleSendOtp()
}}
className="space-y-4"
>
<div className="space-y-2">
<Input
id="email"
type="email"
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={handleEmailKeyDown}
disabled={isSendingOtp}
className="w-full"
autoFocus
autoComplete="off"
/>
</div>
<Button
type="submit"
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>
</form>
</>
) : (
<div className="space-y-4">
<div className="text-center">
<p className="text-muted-foreground text-sm mb-1">
Enter the verification code sent to
</p>
<p className="text-center font-medium text-sm break-all mb-3">{email}</p>
<p className="font-medium text-sm break-all">{email}</p>
</div>
<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>
{authError && (
<div className="p-3 bg-red-50 border border-red-200 text-red-600 rounded-md text-sm">
{authError}
</div>
</>
)}
</div>
)}
<OTPInputForm
onSubmit={(value) => {
setOtpValue(value)
handleVerifyOtp(value)
}}
isLoading={isVerifyingOtp}
error={null}
/>
<div className="flex items-center justify-center pt-2">
<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>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,14 +1,13 @@
'use client'
import { KeyboardEvent, useState } from 'react'
import { Loader2, Lock } from 'lucide-react'
import { Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ChatHeader } from '../../header/header'
interface PasswordAuthProps {
subdomain: string
starCount: string
onAuthSuccess: () => void
title?: string
primaryColor?: string
@@ -16,7 +15,6 @@ interface PasswordAuthProps {
export default function PasswordAuth({
subdomain,
starCount,
onAuthSuccess,
title = 'chat',
primaryColor = '#802FFF',
@@ -77,119 +75,115 @@ export default function PasswordAuth({
}
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]"
<Dialog open={true} onOpenChange={() => {}}>
<DialogContent
className="sm:max-w-[450px] flex flex-col p-0 gap-0 overflow-hidden"
hideCloseButton
>
<DialogHeader className="px-6 py-4 border-b">
<div className="flex items-center justify-center">
<a
href="https://simstudio.ai"
target="_blank"
rel="noopener noreferrer"
className="mb-2"
>
<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>
<svg
width="40"
height="40"
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>
<DialogTitle className="text-lg font-medium text-center">{title}</DialogTitle>
</DialogHeader>
<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 className="p-6">
<div className="mb-4 text-center">
<p className="text-muted-foreground">
This chat is password-protected. Please enter the password to continue.
</p>
</div>
{authError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-600 rounded-md text-sm">
{authError}
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault()
handleAuthenticate()
}}
className="space-y-4"
>
<div className="space-y-2">
<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"
autoFocus
/>
</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()
}}
<Button
type="submit"
disabled={!password || isAuthenticating}
className="w-full"
style={{ backgroundColor: primaryColor }}
>
<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"
/>
{isAuthenticating ? (
<div className="flex items-center justify-center">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Authenticating...
</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>
) : (
'Continue'
)}
</Button>
</form>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import React, { RefObject } from 'react'
import React, { memo, RefObject } from 'react'
import { ArrowDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ChatMessage, ClientChatMessage } from '../message/message'
@@ -18,7 +18,7 @@ interface ChatMessageContainerProps {
} | null
}
export function ChatMessageContainer({
export const ChatMessageContainer = memo(function ChatMessageContainer({
messages,
isLoading,
showScrollButton,
@@ -85,4 +85,4 @@ export function ChatMessageContainer({
)}
</div>
)
}
})

View File

@@ -42,17 +42,17 @@ export default function MarkdownRenderer({
// 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">
<h1 className="text-2xl font-semibold mt-10 mb-5 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">
<h2 className="text-xl font-semibold mt-8 mb-4 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">
<h3 className="text-lg font-semibold mt-7 mb-3 text-gray-900 dark:text-gray-100 font-geist-sans">
{children}
</h3>
),

View File

@@ -1,10 +1,10 @@
'use client'
import { useMemo, useState } from 'react'
import { memo, 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'
import MarkdownRenderer from './components/markdown-renderer'
export interface ChatMessage {
id: string
@@ -23,87 +23,97 @@ function EnhancedMarkdownRenderer({ content }: { content: string }) {
)
}
export function ClientChatMessage({ message }: { message: ChatMessage }) {
const [isCopied, setIsCopied] = useState(false)
const isJsonObject = useMemo(() => {
return typeof message.content === 'object' && message.content !== null
}, [message.content])
export const ClientChatMessage = memo(
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') {
// 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="py-5 px-4" data-message-id={message.id}>
<div className="pt-5 pb-2 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">
<div className="flex flex-col">
<div>
<div className="break-words text-base">
{isJsonObject ? (
<pre>{JSON.stringify(message.content, null, 2)}</pre>
<pre className="text-gray-800 dark:text-gray-100">
{JSON.stringify(message.content, null, 2)}
</pre>
) : (
<span>{message.content as string}</span>
<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>
)
},
(prevProps, nextProps) => {
return (
prevProps.message.id === nextProps.message.id &&
prevProps.message.content === nextProps.message.content &&
prevProps.message.isStreaming === nextProps.message.isStreaming &&
prevProps.message.isInitialMessage === nextProps.message.isInitialMessage
)
}
// 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>
)
}
)