d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
462 lines
14 KiB
TypeScript
462 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { createLogger } from '@sim/logger'
|
|
import { generateId } from '@sim/utils/id'
|
|
import { noop } from '@/lib/core/utils/request'
|
|
import {
|
|
ChatErrorState,
|
|
ChatHeader,
|
|
ChatInput,
|
|
ChatLoadingState,
|
|
type ChatMessage,
|
|
ChatMessageContainer,
|
|
EmailAuth,
|
|
PasswordAuth,
|
|
VoiceInterface,
|
|
} from '@/app/(interfaces)/chat/components'
|
|
import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/(interfaces)/chat/constants'
|
|
import { useAudioStreaming, useChatStreaming } from '@/app/(interfaces)/chat/hooks'
|
|
import SSOAuth from '@/ee/sso/components/sso-auth'
|
|
import { useDeployedChatConfig } from '@/hooks/queries/chats'
|
|
import { useGitHubStars } from '@/hooks/queries/github-stars'
|
|
import { useVoiceSettings } from '@/hooks/queries/voice-settings'
|
|
|
|
const logger = createLogger('ChatClient')
|
|
|
|
interface AudioStreamingOptions {
|
|
voiceId: string
|
|
chatId: string
|
|
onError: (error: Error) => void
|
|
}
|
|
|
|
interface ChatRequestFile {
|
|
name: string
|
|
size: number
|
|
type: string
|
|
data: string
|
|
}
|
|
|
|
interface ChatRequestPayload {
|
|
input: string
|
|
conversationId: string
|
|
files?: ChatRequestFile[]
|
|
}
|
|
|
|
const DEFAULT_VOICE_SETTINGS = {
|
|
voiceId: 'cgSgspJ2msm6clMCkdW9', // Default ElevenLabs voice (Jessica) — Flash v2.5-optimized
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
export default function ChatClient({ identifier }: { identifier: string }) {
|
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
|
const [inputValue, setInputValue] = useState('')
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
|
const [conversationId] = useState(() => generateId())
|
|
|
|
const [showScrollButton, setShowScrollButton] = useState(false)
|
|
const [userHasScrolled, setUserHasScrolled] = useState(false)
|
|
const isUserScrollingRef = useRef(false)
|
|
|
|
const [isVoiceFirstMode, setIsVoiceFirstMode] = useState(false)
|
|
|
|
const { data: chatConfigResult, error: chatConfigError } = useDeployedChatConfig(identifier)
|
|
const { data: voiceSettings } = useVoiceSettings()
|
|
const { data: starCount } = useGitHubStars()
|
|
|
|
const sttAvailable = voiceSettings?.sttAvailable === true
|
|
const authRequired = chatConfigResult?.kind === 'auth' ? chatConfigResult.authType : null
|
|
const chatConfig = chatConfigResult?.kind === 'config' ? chatConfigResult.config : null
|
|
|
|
const welcomeMessage = chatConfig?.customizations?.welcomeMessage
|
|
const welcomeChatMessage = useMemo<ChatMessage | null>(
|
|
() =>
|
|
welcomeMessage
|
|
? {
|
|
id: 'welcome',
|
|
content: welcomeMessage,
|
|
type: 'assistant',
|
|
timestamp: new Date(),
|
|
isInitialMessage: true,
|
|
}
|
|
: null,
|
|
[welcomeMessage]
|
|
)
|
|
const displayMessages: ChatMessage[] = welcomeChatMessage
|
|
? [welcomeChatMessage, ...messages]
|
|
: messages
|
|
|
|
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 isStreamingResponseRef = useRef(isStreamingResponse)
|
|
isStreamingResponseRef.current = isStreamingResponse
|
|
|
|
useEffect(() => {
|
|
const container = messagesContainerRef.current
|
|
if (!container) return
|
|
|
|
const handleScroll = () => {
|
|
const { scrollTop, scrollHeight, clientHeight } = container
|
|
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
|
setShowScrollButton(distanceFromBottom > 100)
|
|
|
|
if (isStreamingResponseRef.current && !isUserScrollingRef.current) {
|
|
setUserHasScrolled(true)
|
|
}
|
|
}
|
|
|
|
container.addEventListener('scroll', handleScroll, { passive: true })
|
|
return () => container.removeEventListener('scroll', handleScroll)
|
|
}, [chatConfig, isVoiceFirstMode, authRequired])
|
|
|
|
useEffect(() => {
|
|
if (isStreamingResponse) {
|
|
setUserHasScrolled(false)
|
|
|
|
isUserScrollingRef.current = true
|
|
const timeoutId = setTimeout(() => {
|
|
isUserScrollingRef.current = false
|
|
}, 1000)
|
|
return () => clearTimeout(timeoutId)
|
|
}
|
|
}, [isStreamingResponse])
|
|
|
|
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,
|
|
})
|
|
|
|
setUserHasScrolled(false)
|
|
|
|
const userMessage: ChatMessage = {
|
|
id: generateId(),
|
|
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 || '',
|
|
})),
|
|
}
|
|
|
|
setMessages((prev) => [...prev, userMessage])
|
|
setInputValue('')
|
|
setIsLoading(true)
|
|
|
|
setTimeout(() => {
|
|
scrollToMessage(userMessage.id, true)
|
|
}, 100)
|
|
|
|
const abortController = new AbortController()
|
|
const timeoutId = setTimeout(() => {
|
|
abortController.abort()
|
|
}, CHAT_REQUEST_TIMEOUT_MS)
|
|
|
|
try {
|
|
const payloadFiles =
|
|
files && files.length > 0
|
|
? await Promise.all(
|
|
files.map(async (file) => ({
|
|
name: file.name,
|
|
size: file.size,
|
|
type: file.type,
|
|
data: file.dataUrl || (await fileToBase64(file.file)),
|
|
}))
|
|
)
|
|
: undefined
|
|
|
|
const payload: ChatRequestPayload = {
|
|
input:
|
|
typeof userMessage.content === 'string'
|
|
? userMessage.content
|
|
: JSON.stringify(userMessage.content),
|
|
conversationId,
|
|
...(payloadFiles ? { files: payloadFiles } : {}),
|
|
}
|
|
|
|
logger.info('API payload:', {
|
|
...payload,
|
|
files: payload.files ? `${payload.files.length} files` : undefined,
|
|
})
|
|
|
|
// boundary-raw-fetch: deployed chat endpoint returns an SSE stream consumed by handleStreamedResponse via response.body.getReader()
|
|
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,
|
|
})
|
|
|
|
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')
|
|
}
|
|
|
|
const shouldPlayAudio = isVoiceInput || isVoiceFirstMode
|
|
const audioHandler =
|
|
shouldPlayAudio && chatConfig?.id
|
|
? 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) {
|
|
clearTimeout(timeoutId)
|
|
|
|
if (error instanceof Error && 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: generateId(),
|
|
content: CHAT_ERROR_MESSAGES.GENERIC_ERROR,
|
|
type: 'assistant',
|
|
timestamp: new Date(),
|
|
}
|
|
setMessages((prev) => [...prev, errorMessage])
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
stopAudio()
|
|
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
|
|
audioContextRef.current.close()
|
|
}
|
|
}
|
|
}, [stopAudio])
|
|
|
|
const handleVoiceInterruption = useCallback(() => {
|
|
stopAudio()
|
|
|
|
if (isStreamingResponse) {
|
|
stopStreaming(setMessages)
|
|
}
|
|
}, [isStreamingResponse, stopStreaming, setMessages, stopAudio])
|
|
|
|
const handleVoiceStart = useCallback(() => {
|
|
if (!sttAvailable) return
|
|
setIsVoiceFirstMode(true)
|
|
}, [sttAvailable])
|
|
|
|
const handleExitVoiceMode = useCallback(() => {
|
|
setIsVoiceFirstMode(false)
|
|
stopAudio()
|
|
}, [stopAudio])
|
|
|
|
const handleVoiceTranscript = useCallback(
|
|
(transcript: string) => {
|
|
logger.info('Received voice transcript:', transcript)
|
|
handleSendMessage(transcript, true)
|
|
},
|
|
[handleSendMessage]
|
|
)
|
|
|
|
if (chatConfigError) {
|
|
logger.error('Error fetching chat config:', chatConfigError)
|
|
return <ChatErrorState error={CHAT_ERROR_MESSAGES.CHAT_UNAVAILABLE} />
|
|
}
|
|
|
|
if (authRequired) {
|
|
if (authRequired === 'password') {
|
|
return <PasswordAuth identifier={identifier} />
|
|
}
|
|
if (authRequired === 'email') {
|
|
return <EmailAuth identifier={identifier} />
|
|
}
|
|
if (authRequired === 'sso') {
|
|
return <SSOAuth identifier={identifier} />
|
|
}
|
|
}
|
|
|
|
if (!chatConfig) {
|
|
return <ChatLoadingState />
|
|
}
|
|
|
|
if (isVoiceFirstMode) {
|
|
return (
|
|
<VoiceInterface
|
|
onCallEnd={handleExitVoiceMode}
|
|
onVoiceTranscript={handleVoiceTranscript}
|
|
onVoiceStart={noop}
|
|
onVoiceEnd={noop}
|
|
onInterrupt={handleVoiceInterruption}
|
|
isStreaming={isStreamingResponse}
|
|
isPlayingAudio={isPlayingAudio}
|
|
audioContextRef={audioContextRef}
|
|
chatId={chatConfig?.id}
|
|
messages={displayMessages.map((msg) => ({
|
|
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
type: msg.type,
|
|
}))}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className='light fixed inset-0 z-[100] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
|
|
{/* Header component */}
|
|
<ChatHeader chatConfig={chatConfig} starCount={starCount} />
|
|
|
|
{/* Message Container component */}
|
|
<ChatMessageContainer
|
|
messages={displayMessages}
|
|
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}
|
|
sttAvailable={sttAvailable}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|