mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
improvement(chat): optimized chat performance & pass/email flow for chat (#406)
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user