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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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} />
</>
)
}