Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 4 additions & 128 deletions apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { DEFAULT_TTS_VOICE_ID } from '@/lib/api/contracts/media/tts-stream'
import { noop } from '@/lib/core/utils/request'
import {
AGENT_STREAM_PROTOCOL_HEADER,
AGENT_STREAM_PROTOCOL_V1,
Expand All @@ -19,23 +17,15 @@ import {
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 { 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
Expand All @@ -49,10 +39,6 @@ interface ChatRequestPayload {
files?: ChatRequestFile[]
}

const DEFAULT_VOICE_SETTINGS = {
voiceId: DEFAULT_TTS_VOICE_ID,
}

/**
* Converts a File object to a base64 data URL
*/
Expand All @@ -65,33 +51,6 @@ function fileToBase64(file: File): Promise<string> {
})
}

/**
* 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('')
Expand All @@ -105,13 +64,9 @@ export default function ChatClient({ identifier }: { identifier: string }) {
const stickToBottomRef = useRef(true)
const ignoreScrollRef = 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

Expand All @@ -135,8 +90,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {

const { isStreamingResponse, abortControllerRef, stopStreaming, handleStreamedResponse } =
useChatStreaming()
const audioContextRef = useRef<AudioContext | null>(null)
const { isPlayingAudio, streamTextToAudio, stopAudio } = useAudioStreaming(audioContextRef)

const NEAR_BOTTOM_THRESHOLD_PX = 100

Expand Down Expand Up @@ -208,11 +161,10 @@ export default function ChatClient({ identifier }: { identifier: string }) {

container.addEventListener('scroll', handleScroll, { passive: true })
return () => container.removeEventListener('scroll', handleScroll)
}, [chatConfig, isVoiceFirstMode, authRequired])
}, [chatConfig, authRequired])

const handleSendMessage = async (
messageParam?: string,
isVoiceInput = false,
files?: Array<{
id: string
name: string
Expand All @@ -227,7 +179,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {

logger.info('Sending message:', {
messageToSend,
isVoiceInput,
conversationId,
filesCount: files?.length,
})
Expand Down Expand Up @@ -316,30 +267,12 @@ export default function ChatClient({ identifier }: { identifier: string }) {
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({ behavior: 'auto' }),
{
voiceSettings: {
isVoiceEnabled: shouldPlayAudio,
voiceId: DEFAULT_VOICE_SETTINGS.voiceId,
autoPlayResponses: shouldPlayAudio,
},
audioStreamHandler: audioHandler,
outputConfigs: chatConfig?.outputConfigs,
abortController,
}
Expand All @@ -365,41 +298,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
}
}

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} />
Expand All @@ -421,26 +319,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
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 desktop-title-bar-page fixed inset-0 z-[100] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
<DesktopTitleBarLane />
Expand All @@ -463,13 +341,11 @@ export default function ChatClient({ identifier }: { identifier: string }) {
<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)
onSubmit={(value, files) => {
void handleSendMessage(value, files)
}}
isStreaming={isStreamingResponse}
onStopStreaming={() => stopStreaming(setMessages)}
onVoiceStart={handleVoiceStart}
sttAvailable={sttAvailable}
/>
</div>
</div>
Expand Down
1 change: 0 additions & 1 deletion apps/sim/app/(interfaces)/chat/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,3 @@ export { ChatInput } from './input/input'
export { ChatLoadingState } from './loading-state/loading-state'
export type { ChatMessage } from './message/message'
export { ChatMessageContainer } from './message-container/message-container'
export { VoiceInterface } from './voice-interface/voice-interface'
64 changes: 5 additions & 59 deletions apps/sim/app/(interfaces)/chat/components/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@ import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { Badge, Button, cn, handleKeyboardActivation, Tooltip } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { ArrowUp, Mic, Paperclip, X } from 'lucide-react'
import { ArrowUp, Paperclip, X } from 'lucide-react'
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { VoiceInput } from '@/app/(interfaces)/chat/components/input/voice-input'

const logger = createLogger('ChatInput')

Expand All @@ -23,20 +22,10 @@ interface AttachedFile {
}

export const ChatInput: React.FC<{
onSubmit?: (value: string, isVoiceInput?: boolean, files?: AttachedFile[]) => void
onSubmit?: (value: string, files?: AttachedFile[]) => void
isStreaming?: boolean
onStopStreaming?: () => void
onVoiceStart?: () => void
voiceOnly?: boolean
sttAvailable?: boolean
}> = ({
onSubmit,
isStreaming = false,
onStopStreaming,
onVoiceStart,
voiceOnly = false,
sttAvailable = false,
}) => {
}> = ({ onSubmit, isStreaming = false, onStopStreaming }) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [inputValue, setInputValue] = useState('')
Expand Down Expand Up @@ -114,7 +103,7 @@ export const ChatInput: React.FC<{
const handleSubmit = useCallback(() => {
if (isStreaming) return
if (!inputValue.trim() && attachedFiles.length === 0) return
onSubmit?.(inputValue.trim(), false, attachedFiles)
onSubmit?.(inputValue.trim(), attachedFiles)
setInputValue('')
setAttachedFiles([])
setUploadErrors([])
Expand All @@ -141,31 +130,6 @@ export const ChatInput: React.FC<{

const canSubmit = (inputValue.trim().length > 0 || attachedFiles.length > 0) && !isStreaming

if (voiceOnly) {
return (
<Tooltip.Provider>
<div className='flex items-center justify-center'>
{sttAvailable && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div>
<VoiceInput
onVoiceStart={onVoiceStart ?? (() => {})}
disabled={isStreaming}
large={true}
/>
</div>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>Start voice conversation</p>
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
</Tooltip.Provider>
)
}

return (
<Tooltip.Provider>
<div className='fixed right-0 bottom-0 left-0 flex w-full items-center justify-center bg-gradient-to-t from-[var(--bg)] to-transparent px-4 pb-4 md:px-0 md:pb-4'>
Expand Down Expand Up @@ -302,26 +266,8 @@ export const ChatInput: React.FC<{
/>
</div>

{/* Right: mic + send */}
{/* Right: send */}
<div className='flex items-center gap-1.5'>
{sttAvailable && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='quiet'
onClick={onVoiceStart}
disabled={isStreaming}
className='size-[28px] rounded-full p-0'
>
<Mic className='size-[16px]' strokeWidth={2} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>Start voice conversation</p>
</Tooltip.Content>
</Tooltip.Root>
)}

{isStreaming ? (
<Button
variant='primary'
Expand Down
Loading
Loading