jan/web-app/src/routes/threads/$threadId.tsx
2025-10-30 00:59:50 +07:00

302 lines
11 KiB
TypeScript

import { useEffect, useMemo, useRef } from 'react'
import { createFileRoute, useParams, redirect, useNavigate } from '@tanstack/react-router'
import cloneDeep from 'lodash.clonedeep'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { useTranslation } from '@/i18n/react-i18next-compat'
import HeaderPage from '@/containers/HeaderPage'
import { useThreads } from '@/hooks/useThreads'
import ChatInput from '@/containers/ChatInput'
import { useShallow } from 'zustand/react/shallow'
import { ThreadContent } from '@/containers/ThreadContent'
import { StreamingContent } from '@/containers/StreamingContent'
import { useMessages } from '@/hooks/useMessages'
import { useServiceHub } from '@/hooks/useServiceHub'
import DropdownAssistant from '@/containers/DropdownAssistant'
import { useAssistant } from '@/hooks/useAssistant'
import { useInterfaceSettings } from '@/hooks/useInterfaceSettings'
import { ContentType, ThreadMessage } from '@janhq/core'
import { useSmallScreen, useMobileScreen } from '@/hooks/useMediaQuery'
import { useTools } from '@/hooks/useTools'
import { PlatformFeatures } from '@/lib/platform/const'
import { PlatformFeature } from '@/lib/platform/types'
import ScrollToBottom from '@/containers/ScrollToBottom'
import { PromptProgress } from '@/components/PromptProgress'
import { ThreadPadding } from '@/containers/ThreadPadding'
import { TEMPORARY_CHAT_ID, TEMPORARY_CHAT_QUERY_ID } from '@/constants/chat'
import { IconInfoCircle } from '@tabler/icons-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const CONVERSATION_NOT_FOUND_EVENT = 'conversation-not-found'
const TemporaryChatIndicator = ({ t }: { t: (key: string) => string }) => {
return (
<div className="flex items-center gap-1.5 px-3 py-1 rounded-md bg-main-view-fg/5 text-main-view-fg/70 text-sm">
<span>{t('common:temporaryChat')}</span>
<Tooltip>
<TooltipTrigger asChild>
<div className="relative z-20">
<IconInfoCircle
size={14}
className="text-main-view-fg/50 hover:text-main-view-fg/70 transition-colors cursor-pointer"
/>
</div>
</TooltipTrigger>
<TooltipContent className="z-[9999]">
<p>{t('common:temporaryChatTooltip')}</p>
</TooltipContent>
</Tooltip>
</div>
)
}
// as route.threadsDetail
export const Route = createFileRoute('/threads/$threadId')({
beforeLoad: ({ params }) => {
// Check if this is the temporary chat being accessed directly
if (params.threadId === TEMPORARY_CHAT_ID) {
// Check if we have the navigation flag in sessionStorage
const hasNavigationFlag = sessionStorage.getItem('temp-chat-nav')
if (!hasNavigationFlag) {
// Direct access - redirect to home with query parameter
throw redirect({
to: '/',
search: { [TEMPORARY_CHAT_QUERY_ID]: true },
replace: true,
})
}
// Clear the flag immediately after checking
sessionStorage.removeItem('temp-chat-nav')
}
},
component: ThreadDetail,
})
function ThreadDetail() {
const serviceHub = useServiceHub()
const { threadId } = useParams({ from: Route.id })
const navigate = useNavigate()
const { t } = useTranslation()
const setCurrentThreadId = useThreads((state) => state.setCurrentThreadId)
const setCurrentAssistant = useAssistant((state) => state.setCurrentAssistant)
const assistants = useAssistant((state) => state.assistants)
const setMessages = useMessages((state) => state.setMessages)
const chatWidth = useInterfaceSettings((state) => state.chatWidth)
const isSmallScreen = useSmallScreen()
const isMobile = useMobileScreen()
useTools()
const { messages } = useMessages(
useShallow((state) => ({
messages: state.messages[threadId],
}))
)
// Subscribe directly to the thread data to ensure updates when model changes
const thread = useThreads(useShallow((state) => state.threads[threadId]))
const scrollContainerRef = useRef<HTMLDivElement>(null)
// Listen for conversation not found events
useEffect(() => {
const handleConversationNotFound = (event: CustomEvent) => {
const { threadId: notFoundThreadId } = event.detail
if (notFoundThreadId === threadId) {
// Skip error handling for temporary chat - it's expected to not exist on server
if (threadId === TEMPORARY_CHAT_ID) {
return
}
toast.error(t('common:conversationNotAvailable'), {
description: t('common:conversationNotAvailableDescription')
})
navigate({ to: '/', replace: true })
}
}
window.addEventListener(CONVERSATION_NOT_FOUND_EVENT, handleConversationNotFound as EventListener)
return () => {
window.removeEventListener(CONVERSATION_NOT_FOUND_EVENT, handleConversationNotFound as EventListener)
}
}, [threadId, navigate, t])
useEffect(() => {
setCurrentThreadId(threadId)
const assistant = assistants.find(
(assistant) => assistant.id === thread?.assistants?.[0]?.id
)
if (assistant) setCurrentAssistant(assistant)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [threadId, assistants])
useEffect(() => {
serviceHub
.messages()
.fetchMessages(threadId)
.then((fetchedMessages) => {
if (fetchedMessages) {
// For web platform: preserve local messages if server fetch is empty but we have local messages
if (PlatformFeatures[PlatformFeature.FIRST_MESSAGE_PERSISTED_THREAD] &&
fetchedMessages.length === 0 &&
messages &&
messages.length > 0) {
console.log('!!!Preserving local messages as server fetch is empty:', messages.length)
// Don't override local messages with empty server response
return
}
// Update the messages in the store
setMessages(threadId, fetchedMessages)
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [threadId, serviceHub])
useEffect(() => {
return () => {
// Clear the current thread ID when the component unmounts
setCurrentThreadId(undefined)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const updateMessage = (
item: ThreadMessage,
message: string,
imageUrls?: string[]
) => {
const newMessages: ThreadMessage[] = messages.map((m) => {
if (m.id === item.id) {
const msg: ThreadMessage = cloneDeep(m)
const newContent = [
{
type: ContentType.Text,
text: {
value: message,
annotations: m.content[0].text?.annotations ?? [],
},
},
]
// Add image content if imageUrls are provided
if (imageUrls && imageUrls.length > 0) {
imageUrls.forEach((url) => {
newContent.push({
type: 'image_url' as ContentType,
image_url: {
url: url,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
})
}
msg.content = newContent
return msg
}
return m
})
setMessages(threadId, newMessages)
}
const threadModel = useMemo(() => thread?.model, [thread])
if (!messages || !threadModel) return null
return (
<div className="flex flex-col h-[calc(100dvh-(env(safe-area-inset-bottom)+env(safe-area-inset-top)))]">
<HeaderPage>
<div className="flex items-center justify-between w-full pr-2">
<div>
{PlatformFeatures[PlatformFeature.ASSISTANTS] && (
<DropdownAssistant />
)}
</div>
<div className="flex-1 flex justify-center">
{threadId === TEMPORARY_CHAT_ID && <TemporaryChatIndicator t={t} />}
</div>
<div></div>
</div>
</HeaderPage>
<div className="flex flex-col h-[calc(100%-40px)]">
<div
ref={scrollContainerRef}
className={cn(
'flex flex-col h-full w-full overflow-auto pt-4 pb-3',
// Mobile-first responsive padding
isMobile ? 'px-3' : 'px-4'
)}
>
<div
className={cn(
'mx-auto flex max-w-full flex-col grow',
// Mobile-first width constraints
// Mobile and small screens always use full width, otherwise compact chat uses constrained width
isMobile || isSmallScreen || chatWidth !== 'compact'
? 'w-full'
: 'w-full md:w-4/6'
)}
>
{messages &&
messages.map((item, index) => {
// Only pass isLastMessage to the last message in the array
const isLastMessage = index === messages.length - 1
return (
<div
key={item.id}
data-test-id={`message-${item.role}-${item.id}`}
data-message-author-role={item.role}
className="mb-4"
>
<ThreadContent
{...item}
isLastMessage={isLastMessage}
showAssistant={
item.role === 'assistant' &&
(index === 0 ||
messages[index - 1]?.role !== 'assistant' ||
!(
messages[index - 1]?.metadata &&
'tool_calls' in (messages[index - 1].metadata ?? {})
))
}
index={index}
updateMessage={updateMessage}
/>
</div>
)
})}
<PromptProgress />
<StreamingContent
threadId={threadId}
data-test-id="thread-content-text"
/>
{/* Persistent padding element for ChatGPT-style message positioning */}
<ThreadPadding threadId={threadId} scrollContainerRef={scrollContainerRef} />
</div>
</div>
<div
className={cn(
'mx-auto pt-2 pb-3 shrink-0 relative',
// Responsive padding and width
isMobile ? 'px-3' : 'px-2',
// Width: mobile/small screens or non-compact always full, compact desktop uses constrained
isMobile || isSmallScreen || chatWidth !== 'compact'
? 'w-full'
: 'w-full md:w-4/6'
)}
>
<ScrollToBottom
threadId={threadId}
scrollContainerRef={scrollContainerRef}
/>
<ChatInput model={threadModel} />
</div>
</div>
</div>
)
}