chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Skeleton } from '@sim/emcn'
|
||||
|
||||
export default function ChatLoading() {
|
||||
return (
|
||||
<div className='light fixed inset-0 z-[100] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
|
||||
<div className='border-[var(--border-1)] border-b px-4 py-3'>
|
||||
<div className='mx-auto flex max-w-3xl items-center justify-between'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='size-[28px] rounded-[6px]' />
|
||||
<Skeleton className='h-[18px] w-[120px] rounded-[4px]' />
|
||||
</div>
|
||||
<Skeleton className='h-[28px] w-[80px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex min-h-0 flex-1 items-center justify-center px-4'>
|
||||
<div className='w-full max-w-[410px]'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-2 text-center'>
|
||||
<Skeleton className='mx-auto h-8 w-32' />
|
||||
<Skeleton className='mx-auto h-4 w-48' />
|
||||
</div>
|
||||
<div className='mt-8 w-full space-y-8'>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-4 w-16' />
|
||||
<Skeleton className='h-10 w-full rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='h-10 w-full rounded-[10px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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]'>
|
||||
<Skeleton className='h-[48px] w-full rounded-[12px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import Script from 'next/script'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Office?: {
|
||||
onReady: () => Promise<{ host: string | null; platform: string | null }>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Office.js nullifies window.history.replaceState and pushState (a legacy
|
||||
* IE10 workaround inside the library) which breaks Next.js's client-side
|
||||
* router. Cache the originals at module load — before <Script> renders
|
||||
* Office.js into the DOM — so we can restore them after it loads.
|
||||
*
|
||||
* See https://learn.microsoft.com/en-us/answers/questions/1070090/using-office-javascript-api-in-next-js.
|
||||
*/
|
||||
const cachedHistory =
|
||||
typeof window !== 'undefined'
|
||||
? {
|
||||
replaceState: window.history.replaceState.bind(window.history),
|
||||
pushState: window.history.pushState.bind(window.history),
|
||||
}
|
||||
: null
|
||||
|
||||
/**
|
||||
* Loads Office.js and signals readiness so Office host applications
|
||||
* (Excel, Word, PowerPoint, Outlook) recognize this page as a valid add-in.
|
||||
*
|
||||
* Office.onReady() must be called once Office.js is loaded — see
|
||||
* https://learn.microsoft.com/en-us/javascript/api/office#office-office-onready-function(1).
|
||||
*/
|
||||
export function OfficeEmbedInit() {
|
||||
return (
|
||||
<Script
|
||||
src='https://appsforoffice.microsoft.com/lib/1/hosted/office.js'
|
||||
strategy='afterInteractive'
|
||||
onReady={() => {
|
||||
if (cachedHistory) {
|
||||
window.history.replaceState = cachedHistory.replaceState
|
||||
window.history.pushState = cachedHistory.pushState
|
||||
}
|
||||
void window.Office?.onReady()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { db } from '@sim/db'
|
||||
import { chat } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { Metadata } from 'next'
|
||||
import ChatClient from '@/app/(interfaces)/chat/[identifier]/chat'
|
||||
import { OfficeEmbedInit } from '@/app/(interfaces)/chat/[identifier]/office-embed-init'
|
||||
|
||||
const logger = createLogger('ChatMetadata')
|
||||
|
||||
/**
|
||||
* Only fully public, active deployments are indexable. Auth-gated (password,
|
||||
* email, SSO) and inactive/nonexistent chats are noindexed at the page level
|
||||
* so Google never indexes an auth wall — narrower than blocking `/chat/`
|
||||
* entirely in robots.ts, which would also hide genuinely public deployments.
|
||||
*
|
||||
* Errors from the lookup fail toward noindex rather than throwing: unlike
|
||||
* the identical query in app/api/chat/[identifier]/route.ts (which must
|
||||
* surface failures to the caller), a metadata resolution error has no
|
||||
* error.tsx boundary in this route to catch it — throwing here would take
|
||||
* the whole page down instead of just skipping indexability, and "can't
|
||||
* confirm this is safe to index" should default to not indexing it anyway.
|
||||
*/
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ identifier: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { identifier } = await params
|
||||
|
||||
let isIndexable = false
|
||||
try {
|
||||
const [deployment] = await db
|
||||
.select({ authType: chat.authType, isActive: chat.isActive })
|
||||
.from(chat)
|
||||
.where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
isIndexable = Boolean(deployment?.isActive && deployment.authType === 'public')
|
||||
} catch (error) {
|
||||
logger.error('Failed to resolve chat deployment for metadata', {
|
||||
identifier,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Chat',
|
||||
...(!isIndexable && { robots: { index: false, follow: false } }),
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function ChatPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ identifier: string }>
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const { identifier } = await params
|
||||
const { embed } = await searchParams
|
||||
const isOfficeEmbed = embed === 'office' || (Array.isArray(embed) && embed.includes('office'))
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOfficeEmbed && <OfficeEmbedInit />}
|
||||
<ChatClient key={identifier} identifier={identifier} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user