mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -05:00
* fix(agent-tool): fix workflow tool in agent to respect user-provided params, added badge for deployment status * ack PR comment * updated gh stars
588 lines
17 KiB
TypeScript
588 lines
17 KiB
TypeScript
'use client'
|
|
|
|
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
|
import { createLogger } from '@sim/logger'
|
|
import { v4 as uuidv4 } from 'uuid'
|
|
import { noop } from '@/lib/core/utils/request'
|
|
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
|
import {
|
|
ChatErrorState,
|
|
ChatHeader,
|
|
ChatInput,
|
|
ChatLoadingState,
|
|
type ChatMessage,
|
|
ChatMessageContainer,
|
|
EmailAuth,
|
|
PasswordAuth,
|
|
SSOAuth,
|
|
VoiceInterface,
|
|
} from '@/app/chat/components'
|
|
import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/chat/constants'
|
|
import { useAudioStreaming, useChatStreaming } from '@/app/chat/hooks'
|
|
|
|
const logger = createLogger('ChatClient')
|
|
|
|
interface ChatConfig {
|
|
id: string
|
|
title: string
|
|
description: string
|
|
customizations: {
|
|
primaryColor?: string
|
|
logoUrl?: string
|
|
imageUrl?: string
|
|
welcomeMessage?: string
|
|
headerText?: string
|
|
}
|
|
authType?: 'public' | 'password' | 'email' | 'sso'
|
|
outputConfigs?: Array<{ blockId: string; path?: string }>
|
|
}
|
|
|
|
interface AudioStreamingOptions {
|
|
voiceId: string
|
|
chatId?: string
|
|
onError: (error: Error) => void
|
|
}
|
|
|
|
const DEFAULT_VOICE_SETTINGS = {
|
|
voiceId: 'EXAVITQu4vr4xnSDxMaL', // Default ElevenLabs voice (Bella)
|
|
}
|
|
|
|
/**
|
|
* Converts a File object to a base64 data URL
|
|
*/
|
|
function fileToBase64(file: File): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader()
|
|
reader.onload = () => resolve(reader.result as string)
|
|
reader.onerror = reject
|
|
reader.readAsDataURL(file)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Creates an audio stream handler for text-to-speech conversion
|
|
* @param streamTextToAudio - Function to stream text to audio
|
|
* @param voiceId - The voice ID to use for TTS
|
|
* @param chatId - Optional chat ID for deployed chat authentication
|
|
* @returns Audio stream handler function or undefined
|
|
*/
|
|
function createAudioStreamHandler(
|
|
streamTextToAudio: (text: string, options: AudioStreamingOptions) => Promise<void>,
|
|
voiceId: string,
|
|
chatId?: string
|
|
) {
|
|
return async (text: string) => {
|
|
try {
|
|
await streamTextToAudio(text, {
|
|
voiceId,
|
|
chatId,
|
|
onError: (error: Error) => {
|
|
logger.error('Audio streaming error:', error)
|
|
},
|
|
})
|
|
} catch (error) {
|
|
logger.error('TTS error:', error)
|
|
}
|
|
}
|
|
}
|
|
|
|
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({ identifier }: { identifier: 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('25.1k')
|
|
const [conversationId, setConversationId] = useState('')
|
|
|
|
const [showScrollButton, setShowScrollButton] = useState(false)
|
|
const [userHasScrolled, setUserHasScrolled] = useState(false)
|
|
const isUserScrollingRef = useRef(false)
|
|
|
|
const [authRequired, setAuthRequired] = useState<'password' | 'email' | 'sso' | null>(null)
|
|
|
|
const [isVoiceFirstMode, setIsVoiceFirstMode] = useState(false)
|
|
const { isStreamingResponse, abortControllerRef, stopStreaming, handleStreamedResponse } =
|
|
useChatStreaming()
|
|
const audioContextRef = useRef<AudioContext | null>(null)
|
|
const { isPlayingAudio, streamTextToAudio, stopAudio } = useAudioStreaming(audioContextRef)
|
|
|
|
const scrollToBottom = useCallback(() => {
|
|
if (messagesEndRef.current) {
|
|
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
|
}
|
|
}, [])
|
|
|
|
const scrollToMessage = useCallback(
|
|
(messageId: string, scrollToShowOnlyMessage = 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) {
|
|
const scrollTop = container.scrollTop + messageRect.top - containerRect.top
|
|
|
|
container.scrollTo({
|
|
top: scrollTop,
|
|
behavior: 'smooth',
|
|
})
|
|
} else {
|
|
const scrollTop = container.scrollTop + messageRect.top - containerRect.top - 80
|
|
|
|
container.scrollTo({
|
|
top: scrollTop,
|
|
behavior: 'smooth',
|
|
})
|
|
}
|
|
}
|
|
},
|
|
[messagesContainerRef]
|
|
)
|
|
|
|
const handleScroll = useCallback(
|
|
throttle(() => {
|
|
const container = messagesContainerRef.current
|
|
if (!container) return
|
|
|
|
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)
|
|
}
|
|
}, 100),
|
|
[isStreamingResponse]
|
|
)
|
|
|
|
useEffect(() => {
|
|
const container = messagesContainerRef.current
|
|
if (!container) return
|
|
|
|
container.addEventListener('scroll', handleScroll, { passive: true })
|
|
return () => container.removeEventListener('scroll', handleScroll)
|
|
}, [handleScroll])
|
|
|
|
// 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/${identifier}`, {
|
|
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
|
|
}
|
|
if (errorData.error === 'auth_required_email') {
|
|
setAuthRequired('email')
|
|
return
|
|
}
|
|
if (errorData.error === 'auth_required_sso') {
|
|
setAuthRequired('sso')
|
|
return
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
if (data?.customizations?.welcomeMessage) {
|
|
setMessages([
|
|
{
|
|
id: 'welcome',
|
|
content: data.customizations.welcomeMessage,
|
|
type: 'assistant',
|
|
timestamp: new Date(),
|
|
isInitialMessage: true,
|
|
},
|
|
])
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error fetching chat config:', error)
|
|
setError(CHAT_ERROR_MESSAGES.CHAT_UNAVAILABLE)
|
|
}
|
|
}
|
|
|
|
// Fetch chat config on mount and generate new conversation ID
|
|
useEffect(() => {
|
|
fetchChatConfig()
|
|
setConversationId(uuidv4())
|
|
|
|
getFormattedGitHubStars()
|
|
.then((formattedStars) => {
|
|
setStarCount(formattedStars)
|
|
})
|
|
.catch((err) => {
|
|
logger.error('Failed to fetch GitHub stars:', err)
|
|
})
|
|
}, [identifier])
|
|
|
|
const refreshChat = () => {
|
|
fetchChatConfig()
|
|
}
|
|
|
|
const handleAuthSuccess = () => {
|
|
setAuthRequired(null)
|
|
setTimeout(() => {
|
|
refreshChat()
|
|
}, 800)
|
|
}
|
|
|
|
// Handle sending a message
|
|
const handleSendMessage = async (
|
|
messageParam?: string,
|
|
isVoiceInput = false,
|
|
files?: Array<{
|
|
id: string
|
|
name: string
|
|
size: number
|
|
type: string
|
|
file: File
|
|
dataUrl?: string
|
|
}>
|
|
) => {
|
|
const messageToSend = messageParam ?? inputValue
|
|
if ((!messageToSend.trim() && (!files || files.length === 0)) || isLoading) return
|
|
|
|
logger.info('Sending message:', {
|
|
messageToSend,
|
|
isVoiceInput,
|
|
conversationId,
|
|
filesCount: files?.length,
|
|
})
|
|
|
|
// Reset userHasScrolled when sending a new message
|
|
setUserHasScrolled(false)
|
|
|
|
const userMessage: ChatMessage = {
|
|
id: crypto.randomUUID(),
|
|
content: messageToSend || (files && files.length > 0 ? `Sent ${files.length} file(s)` : ''),
|
|
type: 'user',
|
|
timestamp: new Date(),
|
|
attachments: files?.map((file) => ({
|
|
id: file.id,
|
|
name: file.name,
|
|
type: file.type,
|
|
size: file.size,
|
|
dataUrl: file.dataUrl || '',
|
|
})),
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Create abort controller for request cancellation
|
|
const abortController = new AbortController()
|
|
const timeoutId = setTimeout(() => {
|
|
abortController.abort()
|
|
}, CHAT_REQUEST_TIMEOUT_MS)
|
|
|
|
try {
|
|
// Send structured payload to maintain chat context
|
|
const payload: any = {
|
|
input:
|
|
typeof userMessage.content === 'string'
|
|
? userMessage.content
|
|
: JSON.stringify(userMessage.content),
|
|
conversationId,
|
|
}
|
|
|
|
// Add files if present (convert to base64 for JSON transmission)
|
|
if (files && files.length > 0) {
|
|
payload.files = await Promise.all(
|
|
files.map(async (file) => ({
|
|
name: file.name,
|
|
size: file.size,
|
|
type: file.type,
|
|
data: file.dataUrl || (await fileToBase64(file.file)),
|
|
}))
|
|
)
|
|
}
|
|
|
|
logger.info('API payload:', {
|
|
...payload,
|
|
files: payload.files ? `${payload.files.length} files` : undefined,
|
|
})
|
|
|
|
const response = await fetch(`/api/chat/${identifier}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
body: JSON.stringify(payload),
|
|
credentials: 'same-origin',
|
|
signal: abortController.signal,
|
|
})
|
|
|
|
// Clear timeout since request succeeded
|
|
clearTimeout(timeoutId)
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json()
|
|
logger.error('API error response:', errorData)
|
|
throw new Error(errorData.error || 'Failed to get response')
|
|
}
|
|
|
|
if (!response.body) {
|
|
throw new Error('Response body is missing')
|
|
}
|
|
|
|
// Use the streaming hook with audio support
|
|
const shouldPlayAudio = isVoiceInput || isVoiceFirstMode
|
|
const audioHandler = shouldPlayAudio
|
|
? createAudioStreamHandler(
|
|
streamTextToAudio,
|
|
DEFAULT_VOICE_SETTINGS.voiceId,
|
|
chatConfig?.id
|
|
)
|
|
: undefined
|
|
|
|
logger.info('Starting to handle streamed response:', { shouldPlayAudio })
|
|
|
|
await handleStreamedResponse(
|
|
response,
|
|
setMessages,
|
|
setIsLoading,
|
|
scrollToBottom,
|
|
userHasScrolled,
|
|
{
|
|
voiceSettings: {
|
|
isVoiceEnabled: shouldPlayAudio,
|
|
voiceId: DEFAULT_VOICE_SETTINGS.voiceId,
|
|
autoPlayResponses: shouldPlayAudio,
|
|
},
|
|
audioStreamHandler: audioHandler,
|
|
outputConfigs: chatConfig?.outputConfigs,
|
|
}
|
|
)
|
|
} catch (error: any) {
|
|
// Clear timeout in case of error
|
|
clearTimeout(timeoutId)
|
|
|
|
if (error.name === 'AbortError') {
|
|
logger.info('Request aborted by user or timeout')
|
|
setIsLoading(false)
|
|
return
|
|
}
|
|
|
|
logger.error('Error sending message:', error)
|
|
setIsLoading(false)
|
|
const errorMessage: ChatMessage = {
|
|
id: crypto.randomUUID(),
|
|
content: CHAT_ERROR_MESSAGES.GENERIC_ERROR,
|
|
type: 'assistant',
|
|
timestamp: new Date(),
|
|
}
|
|
setMessages((prev) => [...prev, errorMessage])
|
|
}
|
|
}
|
|
|
|
// Stop audio when component unmounts or when streaming is stopped
|
|
useEffect(() => {
|
|
return () => {
|
|
stopAudio()
|
|
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
|
|
audioContextRef.current.close()
|
|
}
|
|
}
|
|
}, [stopAudio])
|
|
|
|
// Voice interruption - stop audio when user starts speaking
|
|
const handleVoiceInterruption = useCallback(() => {
|
|
stopAudio()
|
|
|
|
// Stop any ongoing streaming response
|
|
if (isStreamingResponse) {
|
|
stopStreaming(setMessages)
|
|
}
|
|
}, [isStreamingResponse, stopStreaming, setMessages, stopAudio])
|
|
|
|
// Handle voice mode activation
|
|
const handleVoiceStart = useCallback(() => {
|
|
setIsVoiceFirstMode(true)
|
|
}, [])
|
|
|
|
// Handle exiting voice mode
|
|
const handleExitVoiceMode = useCallback(() => {
|
|
setIsVoiceFirstMode(false)
|
|
stopAudio() // Stop any playing audio when exiting
|
|
}, [stopAudio])
|
|
|
|
// Handle voice transcript from voice-first interface
|
|
const handleVoiceTranscript = useCallback(
|
|
(transcript: string) => {
|
|
logger.info('Received voice transcript:', transcript)
|
|
handleSendMessage(transcript, true)
|
|
},
|
|
[handleSendMessage]
|
|
)
|
|
|
|
// 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') || 'var(--brand-primary-hover-hex)'
|
|
|
|
if (authRequired === 'password') {
|
|
return (
|
|
<PasswordAuth
|
|
identifier={identifier}
|
|
onAuthSuccess={handleAuthSuccess}
|
|
title={title}
|
|
primaryColor={primaryColor}
|
|
/>
|
|
)
|
|
}
|
|
if (authRequired === 'email') {
|
|
return (
|
|
<EmailAuth
|
|
identifier={identifier}
|
|
onAuthSuccess={handleAuthSuccess}
|
|
title={title}
|
|
primaryColor={primaryColor}
|
|
/>
|
|
)
|
|
}
|
|
if (authRequired === 'sso') {
|
|
return (
|
|
<SSOAuth
|
|
identifier={identifier}
|
|
onAuthSuccess={handleAuthSuccess}
|
|
title={title}
|
|
primaryColor={primaryColor}
|
|
/>
|
|
)
|
|
}
|
|
}
|
|
|
|
// Loading state while fetching config using the extracted component
|
|
if (!chatConfig) {
|
|
return <ChatLoadingState />
|
|
}
|
|
|
|
// Voice-first mode interface
|
|
if (isVoiceFirstMode) {
|
|
return (
|
|
<VoiceInterface
|
|
onCallEnd={handleExitVoiceMode}
|
|
onVoiceTranscript={handleVoiceTranscript}
|
|
onVoiceStart={noop}
|
|
onVoiceEnd={noop}
|
|
onInterrupt={handleVoiceInterruption}
|
|
isStreaming={isStreamingResponse}
|
|
isPlayingAudio={isPlayingAudio}
|
|
audioContextRef={audioContextRef}
|
|
messages={messages.map((msg) => ({
|
|
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
type: msg.type,
|
|
}))}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Standard text-based chat interface
|
|
return (
|
|
<div className='fixed inset-0 z-[100] flex flex-col bg-white text-foreground'>
|
|
{/* 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='relative p-3 pb-4 md:p-4 md:pb-6'>
|
|
<div className='relative mx-auto max-w-3xl md:max-w-[748px]'>
|
|
<ChatInput
|
|
onSubmit={(value, isVoiceInput, files) => {
|
|
void handleSendMessage(value, isVoiceInput, files)
|
|
}}
|
|
isStreaming={isStreamingResponse}
|
|
onStopStreaming={() => stopStreaming(setMessages)}
|
|
onVoiceStart={handleVoiceStart}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|