fix: thread rerender issue
This commit is contained in:
parent
0f85fce6ef
commit
241a90492e
@ -33,6 +33,7 @@ import DropdownModelProvider from '@/containers/DropdownModelProvider'
|
|||||||
import { ModelLoader } from '@/containers/loaders/ModelLoader'
|
import { ModelLoader } from '@/containers/loaders/ModelLoader'
|
||||||
import DropdownToolsAvailable from '@/containers/DropdownToolsAvailable'
|
import DropdownToolsAvailable from '@/containers/DropdownToolsAvailable'
|
||||||
import { useServiceHub } from '@/hooks/useServiceHub'
|
import { useServiceHub } from '@/hooks/useServiceHub'
|
||||||
|
import { useTools } from '@/hooks/useTools'
|
||||||
|
|
||||||
type ChatInputProps = {
|
type ChatInputProps = {
|
||||||
className?: string
|
className?: string
|
||||||
@ -57,6 +58,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
|
|||||||
const { currentThreadId } = useThreads()
|
const { currentThreadId } = useThreads()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { spellCheckChatInput } = useGeneralSetting()
|
const { spellCheckChatInput } = useGeneralSetting()
|
||||||
|
useTools()
|
||||||
|
|
||||||
const maxRows = 10
|
const maxRows = 10
|
||||||
|
|
||||||
|
|||||||
@ -34,7 +34,7 @@ export default function DropdownToolsAvailable({
|
|||||||
initialMessage = false,
|
initialMessage = false,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: DropdownToolsAvailableProps) {
|
}: DropdownToolsAvailableProps) {
|
||||||
const { tools } = useAppState()
|
const tools = useAppState((state) => state.tools)
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
|||||||
46
web-app/src/containers/GenerateResponseButton.tsx
Normal file
46
web-app/src/containers/GenerateResponseButton.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { useChat } from '@/hooks/useChat'
|
||||||
|
import { useMessages } from '@/hooks/useMessages'
|
||||||
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||||
|
import { Play } from 'lucide-react'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
|
||||||
|
export const GenerateResponseButton = ({ threadId }: { threadId: string }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const deleteMessage = useMessages((state) => state.deleteMessage)
|
||||||
|
const { messages } = useMessages(
|
||||||
|
useShallow((state) => ({
|
||||||
|
messages: state.messages[threadId],
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
const { sendMessage } = useChat()
|
||||||
|
const generateAIResponse = () => {
|
||||||
|
const latestUserMessage = messages[messages.length - 1]
|
||||||
|
if (
|
||||||
|
latestUserMessage?.content?.[0]?.text?.value &&
|
||||||
|
latestUserMessage.role === 'user'
|
||||||
|
) {
|
||||||
|
sendMessage(latestUserMessage.content[0].text.value, false)
|
||||||
|
} else if (latestUserMessage?.metadata?.tool_calls) {
|
||||||
|
// Only regenerate assistant message is allowed
|
||||||
|
const threadMessages = [...messages]
|
||||||
|
let toSendMessage = threadMessages.pop()
|
||||||
|
while (toSendMessage && toSendMessage?.role !== 'user') {
|
||||||
|
deleteMessage(toSendMessage.thread_id, toSendMessage.id ?? '')
|
||||||
|
toSendMessage = threadMessages.pop()
|
||||||
|
}
|
||||||
|
if (toSendMessage) {
|
||||||
|
deleteMessage(toSendMessage.thread_id, toSendMessage.id ?? '')
|
||||||
|
sendMessage(toSendMessage.content?.[0]?.text?.value || '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mx-2 bg-main-view-fg/10 px-2 border border-main-view-fg/5 flex items-center justify-center rounded-xl gap-x-2 cursor-pointer pointer-events-auto"
|
||||||
|
onClick={generateAIResponse}
|
||||||
|
>
|
||||||
|
<p className="text-xs">{t('common:generateAiResponse')}</p>
|
||||||
|
<Play size={12} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -72,7 +72,8 @@ const mainMenus = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const LeftPanel = () => {
|
const LeftPanel = () => {
|
||||||
const { open, setLeftPanel } = useLeftPanel()
|
const open = useLeftPanel((state) => state.open)
|
||||||
|
const setLeftPanel = useLeftPanel((state) => state.setLeftPanel)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const { isAuthenticated } = useAuth()
|
const { isAuthenticated } = useAuth()
|
||||||
@ -119,9 +120,9 @@ const LeftPanel = () => {
|
|||||||
prevScreenSizeRef.current !== null &&
|
prevScreenSizeRef.current !== null &&
|
||||||
prevScreenSizeRef.current !== currentIsSmallScreen
|
prevScreenSizeRef.current !== currentIsSmallScreen
|
||||||
) {
|
) {
|
||||||
if (currentIsSmallScreen) {
|
if (currentIsSmallScreen && open) {
|
||||||
setLeftPanel(false)
|
setLeftPanel(false)
|
||||||
} else {
|
} else if(!open) {
|
||||||
setLeftPanel(true)
|
setLeftPanel(true)
|
||||||
}
|
}
|
||||||
prevScreenSizeRef.current = currentIsSmallScreen
|
prevScreenSizeRef.current = currentIsSmallScreen
|
||||||
@ -146,8 +147,10 @@ const LeftPanel = () => {
|
|||||||
select: (state) => state.location.pathname,
|
select: (state) => state.location.pathname,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { deleteAllThreads, unstarAllThreads, getFilteredThreads, threads } =
|
const deleteAllThreads = useThreads((state) => state.deleteAllThreads)
|
||||||
useThreads()
|
const unstarAllThreads = useThreads((state) => state.unstarAllThreads)
|
||||||
|
const getFilteredThreads = useThreads((state) => state.getFilteredThreads)
|
||||||
|
const threads = useThreads((state) => state.threads)
|
||||||
|
|
||||||
const filteredThreads = useMemo(() => {
|
const filteredThreads = useMemo(() => {
|
||||||
return getFilteredThreads(searchTerm)
|
return getFilteredThreads(searchTerm)
|
||||||
|
|||||||
67
web-app/src/containers/ScrollToBottom.tsx
Normal file
67
web-app/src/containers/ScrollToBottom.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { useThreadScrolling } from '@/hooks/useThreadScrolling'
|
||||||
|
import { memo } from 'react'
|
||||||
|
import { GenerateResponseButton } from './GenerateResponseButton'
|
||||||
|
import { useMessages } from '@/hooks/useMessages'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import { useAppearance } from '@/hooks/useAppearance'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ArrowDown } from 'lucide-react'
|
||||||
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||||
|
import { useAppState } from '@/hooks/useAppState'
|
||||||
|
|
||||||
|
const ScrollToBottom = ({
|
||||||
|
threadId,
|
||||||
|
scrollContainerRef,
|
||||||
|
}: {
|
||||||
|
threadId: string
|
||||||
|
scrollContainerRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const appMainViewBgColor = useAppearance((state) => state.appMainViewBgColor)
|
||||||
|
const { showScrollToBottomBtn, scrollToBottom, setIsUserScrolling } =
|
||||||
|
useThreadScrolling(threadId, scrollContainerRef)
|
||||||
|
const { messages } = useMessages(
|
||||||
|
useShallow((state) => ({
|
||||||
|
messages: state.messages[threadId],
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const streamingContent = useAppState((state) => state.streamingContent)
|
||||||
|
|
||||||
|
const showGenerateAIResponseBtn =
|
||||||
|
(messages[messages.length - 1]?.role === 'user' ||
|
||||||
|
(messages[messages.length - 1]?.metadata &&
|
||||||
|
'tool_calls' in (messages[messages.length - 1].metadata ?? {}))) &&
|
||||||
|
!streamingContent
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute z-0 -top-6 h-8 py-1 flex w-full justify-center pointer-events-none opacity-0 visibility-hidden',
|
||||||
|
appMainViewBgColor.a === 1
|
||||||
|
? 'from-main-view/20 bg-gradient-to-b to-main-view backdrop-blur'
|
||||||
|
: 'bg-transparent',
|
||||||
|
(showScrollToBottomBtn || showGenerateAIResponseBtn) &&
|
||||||
|
'visibility-visible opacity-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showScrollToBottomBtn && (
|
||||||
|
<div
|
||||||
|
className="bg-main-view-fg/10 px-2 border border-main-view-fg/5 flex items-center justify-center rounded-xl gap-x-2 cursor-pointer pointer-events-auto"
|
||||||
|
onClick={() => {
|
||||||
|
scrollToBottom(true)
|
||||||
|
setIsUserScrolling(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-xs">{t('scrollToBottom')}</p>
|
||||||
|
<ArrowDown size={12} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showGenerateAIResponseBtn && (
|
||||||
|
<GenerateResponseButton threadId={threadId} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ScrollToBottom)
|
||||||
@ -33,35 +33,44 @@ import {
|
|||||||
} from '@/utils/reasoning'
|
} from '@/utils/reasoning'
|
||||||
|
|
||||||
export const useChat = () => {
|
export const useChat = () => {
|
||||||
const { prompt, setPrompt } = usePrompt()
|
const prompt = usePrompt((state) => state.prompt)
|
||||||
const {
|
const setPrompt = usePrompt((state) => state.setPrompt)
|
||||||
tools,
|
const tools = useAppState((state) => state.tools)
|
||||||
updateTokenSpeed,
|
const updateTokenSpeed = useAppState((state) => state.updateTokenSpeed)
|
||||||
resetTokenSpeed,
|
const resetTokenSpeed = useAppState((state) => state.resetTokenSpeed)
|
||||||
updateStreamingContent,
|
const updateStreamingContent = useAppState(
|
||||||
updateLoadingModel,
|
(state) => state.updateStreamingContent
|
||||||
setAbortController,
|
)
|
||||||
} = useAppState()
|
const updateLoadingModel = useAppState((state) => state.updateLoadingModel)
|
||||||
const { assistants, currentAssistant } = useAssistant()
|
const setAbortController = useAppState((state) => state.setAbortController)
|
||||||
const { updateProvider } = useModelProvider()
|
const assistants = useAssistant((state) => state.assistants)
|
||||||
|
const currentAssistant = useAssistant((state) => state.currentAssistant)
|
||||||
|
const updateProvider = useModelProvider((state) => state.updateProvider)
|
||||||
const serviceHub = useServiceHub()
|
const serviceHub = useServiceHub()
|
||||||
|
|
||||||
const { approvedTools, showApprovalModal, allowAllMCPPermissions } =
|
const approvedTools = useToolApproval((state) => state.approvedTools)
|
||||||
useToolApproval()
|
const showApprovalModal = useToolApproval((state) => state.showApprovalModal)
|
||||||
const { showApprovalModal: showIncreaseContextSizeModal } =
|
const allowAllMCPPermissions = useToolApproval(
|
||||||
useContextSizeApproval()
|
(state) => state.allowAllMCPPermissions
|
||||||
const { getDisabledToolsForThread } = useToolAvailable()
|
)
|
||||||
|
const showIncreaseContextSizeModal = useContextSizeApproval(
|
||||||
|
(state) => state.showApprovalModal
|
||||||
|
)
|
||||||
|
const getDisabledToolsForThread = useToolAvailable((state) => state.getDisabledToolsForThread)
|
||||||
|
|
||||||
const { getProviderByName, selectedModel, selectedProvider } =
|
const getProviderByName = useModelProvider((state) => state.getProviderByName)
|
||||||
useModelProvider()
|
const selectedModel = useModelProvider((state) => state.selectedModel)
|
||||||
|
const selectedProvider = useModelProvider((state) => state.selectedProvider)
|
||||||
|
|
||||||
const {
|
const createThread = useThreads((state) => state.createThread)
|
||||||
getCurrentThread: retrieveThread,
|
const retrieveThread = useThreads((state) => state.getCurrentThread)
|
||||||
createThread,
|
const updateThreadTimestamp = useThreads(
|
||||||
updateThreadTimestamp,
|
(state) => state.updateThreadTimestamp
|
||||||
} = useThreads()
|
)
|
||||||
const { getMessages, addMessage } = useMessages()
|
|
||||||
const { setModelLoadError } = useModelLoad()
|
const getMessages = useMessages((state) => state.getMessages)
|
||||||
|
const addMessage = useMessages((state) => state.addMessage)
|
||||||
|
const setModelLoadError = useModelLoad((state) => state.setModelLoadError)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const provider = useMemo(() => {
|
const provider = useMemo(() => {
|
||||||
@ -94,13 +103,13 @@ export const useChat = () => {
|
|||||||
}
|
}
|
||||||
return currentThread
|
return currentThread
|
||||||
}, [
|
}, [
|
||||||
createThread,
|
// createThread,
|
||||||
prompt,
|
// prompt,
|
||||||
retrieveThread,
|
// retrieveThread,
|
||||||
router,
|
// router,
|
||||||
selectedModel?.id,
|
// selectedModel?.id,
|
||||||
selectedProvider,
|
// selectedProvider,
|
||||||
selectedAssistant,
|
// selectedAssistant,
|
||||||
])
|
])
|
||||||
|
|
||||||
const restartModel = useCallback(
|
const restartModel = useCallback(
|
||||||
@ -108,7 +117,10 @@ export const useChat = () => {
|
|||||||
await serviceHub.models().stopAllModels()
|
await serviceHub.models().stopAllModels()
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
updateLoadingModel(true)
|
updateLoadingModel(true)
|
||||||
await serviceHub.models().startModel(provider, modelId).catch(console.error)
|
await serviceHub
|
||||||
|
.models()
|
||||||
|
.startModel(provider, modelId)
|
||||||
|
.catch(console.error)
|
||||||
updateLoadingModel(false)
|
updateLoadingModel(false)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
},
|
},
|
||||||
@ -188,7 +200,9 @@ export const useChat = () => {
|
|||||||
settings: newSettings,
|
settings: newSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
await serviceHub.providers().updateSettings(providerName, updateObj.settings ?? [])
|
await serviceHub
|
||||||
|
.providers()
|
||||||
|
.updateSettings(providerName, updateObj.settings ?? [])
|
||||||
updateProvider(providerName, {
|
updateProvider(providerName, {
|
||||||
...provider,
|
...provider,
|
||||||
...updateObj,
|
...updateObj,
|
||||||
@ -237,7 +251,9 @@ export const useChat = () => {
|
|||||||
|
|
||||||
const builder = new CompletionMessagesBuilder(
|
const builder = new CompletionMessagesBuilder(
|
||||||
messages,
|
messages,
|
||||||
currentAssistant ? renderInstructions(currentAssistant.instructions) : undefined
|
currentAssistant
|
||||||
|
? renderInstructions(currentAssistant.instructions)
|
||||||
|
: undefined
|
||||||
)
|
)
|
||||||
if (troubleshooting) builder.addUserMessage(message, attachments)
|
if (troubleshooting) builder.addUserMessage(message, attachments)
|
||||||
|
|
||||||
@ -476,7 +492,9 @@ export const useChat = () => {
|
|||||||
activeThread.model?.id &&
|
activeThread.model?.id &&
|
||||||
provider?.provider === 'llamacpp'
|
provider?.provider === 'llamacpp'
|
||||||
) {
|
) {
|
||||||
await serviceHub.models().stopModel(activeThread.model.id, 'llamacpp')
|
await serviceHub
|
||||||
|
.models()
|
||||||
|
.stopModel(activeThread.model.id, 'llamacpp')
|
||||||
throw new Error('No response received from the model')
|
throw new Error('No response received from the model')
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -554,5 +572,5 @@ export const useChat = () => {
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
return { sendMessage }
|
return useMemo(() => ({ sendMessage }), [sendMessage])
|
||||||
}
|
}
|
||||||
|
|||||||
231
web-app/src/hooks/useThreadScrolling.tsx
Normal file
231
web-app/src/hooks/useThreadScrolling.tsx
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
import { UIEventHandler, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useAppState } from './useAppState'
|
||||||
|
import { useMessages } from './useMessages'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import debounce from 'lodash.debounce'
|
||||||
|
|
||||||
|
export const useThreadScrolling = (
|
||||||
|
threadId: string,
|
||||||
|
scrollContainerRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
) => {
|
||||||
|
const streamingContent = useAppState((state) => state.streamingContent)
|
||||||
|
const isFirstRender = useRef(true)
|
||||||
|
const { messages } = useMessages(
|
||||||
|
useShallow((state) => ({
|
||||||
|
messages: state.messages[threadId],
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
const wasStreamingRef = useRef(false)
|
||||||
|
const userIntendedPositionRef = useRef<number | null>(null)
|
||||||
|
const [isUserScrolling, setIsUserScrolling] = useState(false)
|
||||||
|
const [isAtBottom, setIsAtBottom] = useState(true)
|
||||||
|
const [hasScrollbar, setHasScrollbar] = useState(false)
|
||||||
|
const lastScrollTopRef = useRef(0)
|
||||||
|
const messagesCount = useMemo(() => messages?.length ?? 0, [messages])
|
||||||
|
|
||||||
|
const showScrollToBottomBtn = !isAtBottom && hasScrollbar
|
||||||
|
|
||||||
|
const scrollToBottom = (smooth = false) => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.scrollTo({
|
||||||
|
top: scrollContainerRef.current.scrollHeight,
|
||||||
|
...(smooth ? { behavior: 'smooth' } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = (e: Event) => {
|
||||||
|
const target = e.target as HTMLDivElement
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = target
|
||||||
|
// Use a small tolerance to better detect when we're at the bottom
|
||||||
|
const isBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10
|
||||||
|
const hasScroll = scrollHeight > clientHeight
|
||||||
|
|
||||||
|
// Detect if this is a user-initiated scroll
|
||||||
|
if (Math.abs(scrollTop - lastScrollTopRef.current) > 10) {
|
||||||
|
setIsUserScrolling(!isBottom)
|
||||||
|
|
||||||
|
// If user scrolls during streaming and moves away from bottom, record their intended position
|
||||||
|
if (streamingContent && !isBottom) {
|
||||||
|
userIntendedPositionRef.current = scrollTop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsAtBottom(isBottom)
|
||||||
|
setHasScrollbar(hasScroll)
|
||||||
|
lastScrollTopRef.current = scrollTop
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.addEventListener('scroll', handleScroll)
|
||||||
|
return () =>
|
||||||
|
scrollContainerRef.current?.removeEventListener('scroll', handleScroll)
|
||||||
|
}
|
||||||
|
}, [scrollContainerRef])
|
||||||
|
|
||||||
|
const checkScrollState = () => {
|
||||||
|
const scrollContainer = scrollContainerRef.current
|
||||||
|
if (!scrollContainer) return
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
|
||||||
|
const isBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10
|
||||||
|
const hasScroll = scrollHeight > clientHeight
|
||||||
|
|
||||||
|
setIsAtBottom(isBottom)
|
||||||
|
setHasScrollbar(hasScroll)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single useEffect for all auto-scrolling logic
|
||||||
|
useEffect(() => {
|
||||||
|
// Track streaming state changes
|
||||||
|
const isCurrentlyStreaming = !!streamingContent
|
||||||
|
const justFinishedStreaming =
|
||||||
|
wasStreamingRef.current && !isCurrentlyStreaming
|
||||||
|
wasStreamingRef.current = isCurrentlyStreaming
|
||||||
|
|
||||||
|
// If streaming just finished and user had an intended position, restore it
|
||||||
|
if (justFinishedStreaming && userIntendedPositionRef.current !== null) {
|
||||||
|
// Small delay to ensure DOM has updated
|
||||||
|
setTimeout(() => {
|
||||||
|
if (
|
||||||
|
scrollContainerRef.current &&
|
||||||
|
userIntendedPositionRef.current !== null
|
||||||
|
) {
|
||||||
|
scrollContainerRef.current.scrollTo({
|
||||||
|
top: userIntendedPositionRef.current,
|
||||||
|
behavior: 'smooth',
|
||||||
|
})
|
||||||
|
userIntendedPositionRef.current = null
|
||||||
|
setIsUserScrolling(false)
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear intended position when streaming starts fresh
|
||||||
|
if (isCurrentlyStreaming && !wasStreamingRef.current) {
|
||||||
|
userIntendedPositionRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only auto-scroll when the user is not actively scrolling
|
||||||
|
// AND either at the bottom OR there's streaming content
|
||||||
|
if (!isUserScrolling && (streamingContent || isAtBottom) && messagesCount) {
|
||||||
|
// Use non-smooth scrolling for auto-scroll to prevent jank
|
||||||
|
scrollToBottom(false)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [streamingContent, isUserScrolling, messagesCount])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Track streaming state changes
|
||||||
|
const isCurrentlyStreaming = !!streamingContent
|
||||||
|
const justFinishedStreaming =
|
||||||
|
wasStreamingRef.current && !isCurrentlyStreaming
|
||||||
|
wasStreamingRef.current = isCurrentlyStreaming
|
||||||
|
|
||||||
|
// If streaming just finished and user had an intended position, restore it
|
||||||
|
if (justFinishedStreaming && userIntendedPositionRef.current !== null) {
|
||||||
|
// Small delay to ensure DOM has updated
|
||||||
|
setTimeout(() => {
|
||||||
|
if (
|
||||||
|
scrollContainerRef.current &&
|
||||||
|
userIntendedPositionRef.current !== null
|
||||||
|
) {
|
||||||
|
scrollContainerRef.current.scrollTo({
|
||||||
|
top: userIntendedPositionRef.current,
|
||||||
|
behavior: 'smooth',
|
||||||
|
})
|
||||||
|
userIntendedPositionRef.current = null
|
||||||
|
setIsUserScrolling(false)
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Clear intended position when streaming starts fresh
|
||||||
|
if (isCurrentlyStreaming && !wasStreamingRef.current) {
|
||||||
|
userIntendedPositionRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only auto-scroll when the user is not actively scrolling
|
||||||
|
// AND either at the bottom OR there's streaming content
|
||||||
|
if (!isUserScrolling && (streamingContent || isAtBottom) && messagesCount) {
|
||||||
|
// Use non-smooth scrolling for auto-scroll to prevent jank
|
||||||
|
scrollToBottom(false)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [streamingContent, isUserScrolling, messagesCount])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (streamingContent) {
|
||||||
|
const interval = setInterval(checkScrollState, 100)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [streamingContent])
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when component mounts or thread content changes
|
||||||
|
useEffect(() => {
|
||||||
|
const scrollContainer = scrollContainerRef.current
|
||||||
|
if (!scrollContainer) return
|
||||||
|
|
||||||
|
// Always scroll to bottom on first render or when thread changes
|
||||||
|
if (isFirstRender.current) {
|
||||||
|
isFirstRender.current = false
|
||||||
|
scrollToBottom()
|
||||||
|
setIsAtBottom(true)
|
||||||
|
setIsUserScrolling(false)
|
||||||
|
userIntendedPositionRef.current = null
|
||||||
|
wasStreamingRef.current = false
|
||||||
|
checkScrollState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDOMScroll = (e: Event) => {
|
||||||
|
const target = e.target as HTMLDivElement
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = target
|
||||||
|
// Use a small tolerance to better detect when we're at the bottom
|
||||||
|
const isBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10
|
||||||
|
const hasScroll = scrollHeight > clientHeight
|
||||||
|
|
||||||
|
// Detect if this is a user-initiated scroll
|
||||||
|
if (Math.abs(scrollTop - lastScrollTopRef.current) > 10) {
|
||||||
|
setIsUserScrolling(!isBottom)
|
||||||
|
|
||||||
|
// If user scrolls during streaming and moves away from bottom, record their intended position
|
||||||
|
if (streamingContent && !isBottom) {
|
||||||
|
userIntendedPositionRef.current = scrollTop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsAtBottom(isBottom)
|
||||||
|
setHasScrollbar(hasScroll)
|
||||||
|
lastScrollTopRef.current = scrollTop
|
||||||
|
}
|
||||||
|
// Use a shorter debounce time for more responsive scrolling
|
||||||
|
const debouncedScroll = debounce(handleDOMScroll)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const chatHistoryElement = scrollContainerRef.current
|
||||||
|
if (chatHistoryElement) {
|
||||||
|
chatHistoryElement.addEventListener('scroll', debouncedScroll)
|
||||||
|
return () =>
|
||||||
|
chatHistoryElement.removeEventListener('scroll', debouncedScroll)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Reset scroll state when thread changes
|
||||||
|
useEffect(() => {
|
||||||
|
isFirstRender.current = true
|
||||||
|
scrollToBottom()
|
||||||
|
setIsAtBottom(true)
|
||||||
|
setIsUserScrolling(false)
|
||||||
|
userIntendedPositionRef.current = null
|
||||||
|
wasStreamingRef.current = false
|
||||||
|
checkScrollState()
|
||||||
|
}, [threadId])
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({ showScrollToBottomBtn, scrollToBottom, setIsUserScrolling }),
|
||||||
|
[showScrollToBottomBtn, scrollToBottom, setIsUserScrolling]
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@ import { SystemEvent } from '@/types/events'
|
|||||||
import { useAppState } from './useAppState'
|
import { useAppState } from './useAppState'
|
||||||
|
|
||||||
export const useTools = () => {
|
export const useTools = () => {
|
||||||
const { updateTools } = useAppState()
|
const updateTools = useAppState((state) => state.updateTools)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function setTools() {
|
function setTools() {
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { createFileRoute, useSearch } from '@tanstack/react-router'
|
|||||||
import ChatInput from '@/containers/ChatInput'
|
import ChatInput from '@/containers/ChatInput'
|
||||||
import HeaderPage from '@/containers/HeaderPage'
|
import HeaderPage from '@/containers/HeaderPage'
|
||||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||||
import { useTools } from '@/hooks/useTools'
|
|
||||||
|
|
||||||
import { useModelProvider } from '@/hooks/useModelProvider'
|
import { useModelProvider } from '@/hooks/useModelProvider'
|
||||||
import SetupScreen from '@/containers/SetupScreen'
|
import SetupScreen from '@/containers/SetupScreen'
|
||||||
@ -34,7 +33,6 @@ function Index() {
|
|||||||
const search = useSearch({ from: route.home as any })
|
const search = useSearch({ from: route.home as any })
|
||||||
const selectedModel = search.model
|
const selectedModel = search.model
|
||||||
const { setCurrentThreadId } = useThreads()
|
const { setCurrentThreadId } = useThreads()
|
||||||
useTools()
|
|
||||||
|
|
||||||
// Conditional to check if there are any valid providers
|
// Conditional to check if there are any valid providers
|
||||||
// required min 1 api_key or 1 model in llama.cpp or jan provider
|
// required min 1 api_key or 1 model in llama.cpp or jan provider
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import { createFileRoute, useParams } from '@tanstack/react-router'
|
import { createFileRoute, useParams } from '@tanstack/react-router'
|
||||||
import { UIEventHandler } from 'react'
|
|
||||||
import debounce from 'lodash.debounce'
|
|
||||||
import cloneDeep from 'lodash.clonedeep'
|
import cloneDeep from 'lodash.clonedeep'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { ArrowDown, Play } from 'lucide-react'
|
|
||||||
|
|
||||||
import HeaderPage from '@/containers/HeaderPage'
|
import HeaderPage from '@/containers/HeaderPage'
|
||||||
import { useThreads } from '@/hooks/useThreads'
|
import { useThreads } from '@/hooks/useThreads'
|
||||||
@ -15,17 +12,14 @@ import { StreamingContent } from '@/containers/StreamingContent'
|
|||||||
|
|
||||||
import { useMessages } from '@/hooks/useMessages'
|
import { useMessages } from '@/hooks/useMessages'
|
||||||
import { useServiceHub } from '@/hooks/useServiceHub'
|
import { useServiceHub } from '@/hooks/useServiceHub'
|
||||||
import { useAppState } from '@/hooks/useAppState'
|
|
||||||
import DropdownAssistant from '@/containers/DropdownAssistant'
|
import DropdownAssistant from '@/containers/DropdownAssistant'
|
||||||
import { useAssistant } from '@/hooks/useAssistant'
|
import { useAssistant } from '@/hooks/useAssistant'
|
||||||
import { useAppearance } from '@/hooks/useAppearance'
|
import { useAppearance } from '@/hooks/useAppearance'
|
||||||
import { ContentType, ThreadMessage } from '@janhq/core'
|
import { ContentType, ThreadMessage } from '@janhq/core'
|
||||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
|
||||||
import { useChat } from '@/hooks/useChat'
|
|
||||||
import { useSmallScreen } from '@/hooks/useMediaQuery'
|
import { useSmallScreen } from '@/hooks/useMediaQuery'
|
||||||
import { useTools } from '@/hooks/useTools'
|
|
||||||
import { PlatformFeatures } from '@/lib/platform/const'
|
import { PlatformFeatures } from '@/lib/platform/const'
|
||||||
import { PlatformFeature } from '@/lib/platform/types'
|
import { PlatformFeature } from '@/lib/platform/types'
|
||||||
|
import ScrollToBottom from '@/containers/ScrollToBottom'
|
||||||
|
|
||||||
// as route.threadsDetail
|
// as route.threadsDetail
|
||||||
export const Route = createFileRoute('/threads/$threadId')({
|
export const Route = createFileRoute('/threads/$threadId')({
|
||||||
@ -33,23 +27,18 @@ export const Route = createFileRoute('/threads/$threadId')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function ThreadDetail() {
|
function ThreadDetail() {
|
||||||
const { t } = useTranslation()
|
|
||||||
const serviceHub = useServiceHub()
|
const serviceHub = useServiceHub()
|
||||||
const { threadId } = useParams({ from: Route.id })
|
const { threadId } = useParams({ from: Route.id })
|
||||||
const [isUserScrolling, setIsUserScrolling] = useState(false)
|
const setCurrentThreadId = useThreads((state) => state.setCurrentThreadId)
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true)
|
const currentThreadId = useThreads((state) => state.currentThreadId)
|
||||||
const [hasScrollbar, setHasScrollbar] = useState(false)
|
const setCurrentAssistant = useAssistant((state) => state.setCurrentAssistant)
|
||||||
const lastScrollTopRef = useRef(0)
|
const assistants = useAssistant((state) => state.assistants)
|
||||||
const userIntendedPositionRef = useRef<number | null>(null)
|
const setMessages = useMessages((state) => state.setMessages)
|
||||||
const wasStreamingRef = useRef(false)
|
|
||||||
const { currentThreadId, setCurrentThreadId } = useThreads()
|
const chatWidth = useAppearance((state) => state.chatWidth)
|
||||||
const { setCurrentAssistant, assistants } = useAssistant()
|
|
||||||
const { setMessages, deleteMessage } = useMessages()
|
|
||||||
const { streamingContent } = useAppState()
|
|
||||||
const { appMainViewBgColor, chatWidth } = useAppearance()
|
|
||||||
const { sendMessage } = useChat()
|
|
||||||
const isSmallScreen = useSmallScreen()
|
const isSmallScreen = useSmallScreen()
|
||||||
useTools()
|
|
||||||
|
// useTools()
|
||||||
|
|
||||||
const { messages } = useMessages(
|
const { messages } = useMessages(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
@ -60,21 +49,8 @@ function ThreadDetail() {
|
|||||||
// Subscribe directly to the thread data to ensure updates when model changes
|
// Subscribe directly to the thread data to ensure updates when model changes
|
||||||
const thread = useThreads(useShallow((state) => state.threads[threadId]))
|
const thread = useThreads(useShallow((state) => state.threads[threadId]))
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const isFirstRender = useRef(true)
|
|
||||||
const messagesCount = useMemo(() => messages?.length ?? 0, [messages])
|
|
||||||
|
|
||||||
// Function to check scroll position and scrollbar presence
|
console.log('rerender')
|
||||||
const checkScrollState = () => {
|
|
||||||
const scrollContainer = scrollContainerRef.current
|
|
||||||
if (!scrollContainer) return
|
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
|
|
||||||
const isBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10
|
|
||||||
const hasScroll = scrollHeight > clientHeight
|
|
||||||
|
|
||||||
setIsAtBottom(isBottom)
|
|
||||||
setHasScrollbar(hasScroll)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentThreadId !== threadId) {
|
if (currentThreadId !== threadId) {
|
||||||
@ -89,7 +65,10 @@ function ThreadDetail() {
|
|||||||
}, [threadId, currentThreadId, assistants])
|
}, [threadId, currentThreadId, assistants])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
serviceHub.messages().fetchMessages(threadId).then((fetchedMessages) => {
|
serviceHub
|
||||||
|
.messages()
|
||||||
|
.fetchMessages(threadId)
|
||||||
|
.then((fetchedMessages) => {
|
||||||
if (fetchedMessages) {
|
if (fetchedMessages) {
|
||||||
// Update the messages in the store
|
// Update the messages in the store
|
||||||
setMessages(threadId, fetchedMessages)
|
setMessages(threadId, fetchedMessages)
|
||||||
@ -106,131 +85,6 @@ function ThreadDetail() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Auto-scroll to bottom when component mounts or thread content changes
|
|
||||||
useEffect(() => {
|
|
||||||
const scrollContainer = scrollContainerRef.current
|
|
||||||
if (!scrollContainer) return
|
|
||||||
|
|
||||||
// Always scroll to bottom on first render or when thread changes
|
|
||||||
if (isFirstRender.current) {
|
|
||||||
isFirstRender.current = false
|
|
||||||
scrollToBottom()
|
|
||||||
setIsAtBottom(true)
|
|
||||||
setIsUserScrolling(false)
|
|
||||||
userIntendedPositionRef.current = null
|
|
||||||
wasStreamingRef.current = false
|
|
||||||
checkScrollState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Reset scroll state when thread changes
|
|
||||||
useEffect(() => {
|
|
||||||
isFirstRender.current = true
|
|
||||||
scrollToBottom()
|
|
||||||
setIsAtBottom(true)
|
|
||||||
setIsUserScrolling(false)
|
|
||||||
userIntendedPositionRef.current = null
|
|
||||||
wasStreamingRef.current = false
|
|
||||||
checkScrollState()
|
|
||||||
}, [threadId])
|
|
||||||
|
|
||||||
// Single useEffect for all auto-scrolling logic
|
|
||||||
useEffect(() => {
|
|
||||||
// Track streaming state changes
|
|
||||||
const isCurrentlyStreaming = !!streamingContent
|
|
||||||
const justFinishedStreaming = wasStreamingRef.current && !isCurrentlyStreaming
|
|
||||||
wasStreamingRef.current = isCurrentlyStreaming
|
|
||||||
|
|
||||||
// If streaming just finished and user had an intended position, restore it
|
|
||||||
if (justFinishedStreaming && userIntendedPositionRef.current !== null) {
|
|
||||||
// Small delay to ensure DOM has updated
|
|
||||||
setTimeout(() => {
|
|
||||||
if (scrollContainerRef.current && userIntendedPositionRef.current !== null) {
|
|
||||||
scrollContainerRef.current.scrollTo({
|
|
||||||
top: userIntendedPositionRef.current,
|
|
||||||
behavior: 'smooth'
|
|
||||||
})
|
|
||||||
userIntendedPositionRef.current = null
|
|
||||||
setIsUserScrolling(false)
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear intended position when streaming starts fresh
|
|
||||||
if (isCurrentlyStreaming && !wasStreamingRef.current) {
|
|
||||||
userIntendedPositionRef.current = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only auto-scroll when the user is not actively scrolling
|
|
||||||
// AND either at the bottom OR there's streaming content
|
|
||||||
if (!isUserScrolling && (streamingContent || isAtBottom) && messagesCount) {
|
|
||||||
// Use non-smooth scrolling for auto-scroll to prevent jank
|
|
||||||
scrollToBottom(false)
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [streamingContent, isUserScrolling, messagesCount])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (streamingContent) {
|
|
||||||
const interval = setInterval(checkScrollState, 100)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}
|
|
||||||
}, [streamingContent])
|
|
||||||
|
|
||||||
const scrollToBottom = (smooth = false) => {
|
|
||||||
if (scrollContainerRef.current) {
|
|
||||||
scrollContainerRef.current.scrollTo({
|
|
||||||
top: scrollContainerRef.current.scrollHeight,
|
|
||||||
...(smooth ? { behavior: 'smooth' } : {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScroll: UIEventHandler<HTMLDivElement> = (e) => {
|
|
||||||
const target = e.target as HTMLDivElement
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = target
|
|
||||||
// Use a small tolerance to better detect when we're at the bottom
|
|
||||||
const isBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10
|
|
||||||
const hasScroll = scrollHeight > clientHeight
|
|
||||||
|
|
||||||
// Detect if this is a user-initiated scroll
|
|
||||||
if (Math.abs(scrollTop - lastScrollTopRef.current) > 10) {
|
|
||||||
setIsUserScrolling(!isBottom)
|
|
||||||
|
|
||||||
// If user scrolls during streaming and moves away from bottom, record their intended position
|
|
||||||
if (streamingContent && !isBottom) {
|
|
||||||
userIntendedPositionRef.current = scrollTop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setIsAtBottom(isBottom)
|
|
||||||
setHasScrollbar(hasScroll)
|
|
||||||
lastScrollTopRef.current = scrollTop
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separate handler for DOM events
|
|
||||||
const handleDOMScroll = (e: Event) => {
|
|
||||||
const target = e.target as HTMLDivElement
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = target
|
|
||||||
// Use a small tolerance to better detect when we're at the bottom
|
|
||||||
const isBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10
|
|
||||||
const hasScroll = scrollHeight > clientHeight
|
|
||||||
|
|
||||||
// Detect if this is a user-initiated scroll
|
|
||||||
if (Math.abs(scrollTop - lastScrollTopRef.current) > 10) {
|
|
||||||
setIsUserScrolling(!isBottom)
|
|
||||||
|
|
||||||
// If user scrolls during streaming and moves away from bottom, record their intended position
|
|
||||||
if (streamingContent && !isBottom) {
|
|
||||||
userIntendedPositionRef.current = scrollTop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setIsAtBottom(isBottom)
|
|
||||||
setHasScrollbar(hasScroll)
|
|
||||||
lastScrollTopRef.current = scrollTop
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateMessage = (item: ThreadMessage, message: string) => {
|
const updateMessage = (item: ThreadMessage, message: string) => {
|
||||||
const newMessages: ThreadMessage[] = messages.map((m) => {
|
const newMessages: ThreadMessage[] = messages.map((m) => {
|
||||||
if (m.id === item.id) {
|
if (m.id === item.id) {
|
||||||
@ -251,64 +105,22 @@ function ThreadDetail() {
|
|||||||
setMessages(threadId, newMessages)
|
setMessages(threadId, newMessages)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use a shorter debounce time for more responsive scrolling
|
|
||||||
const debouncedScroll = debounce(handleDOMScroll)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const chatHistoryElement = scrollContainerRef.current
|
|
||||||
if (chatHistoryElement) {
|
|
||||||
chatHistoryElement.addEventListener('scroll', debouncedScroll)
|
|
||||||
return () =>
|
|
||||||
chatHistoryElement.removeEventListener('scroll', debouncedScroll)
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// used when there is a sent/added user message and no assistant message (error or manual deletion)
|
|
||||||
const generateAIResponse = () => {
|
|
||||||
const latestUserMessage = messages[messages.length - 1]
|
|
||||||
if (
|
|
||||||
latestUserMessage?.content?.[0]?.text?.value &&
|
|
||||||
latestUserMessage.role === 'user'
|
|
||||||
) {
|
|
||||||
sendMessage(latestUserMessage.content[0].text.value, false)
|
|
||||||
} else if (latestUserMessage?.metadata?.tool_calls) {
|
|
||||||
// Only regenerate assistant message is allowed
|
|
||||||
const threadMessages = [...messages]
|
|
||||||
let toSendMessage = threadMessages.pop()
|
|
||||||
while (toSendMessage && toSendMessage?.role !== 'user') {
|
|
||||||
deleteMessage(toSendMessage.thread_id, toSendMessage.id ?? '')
|
|
||||||
toSendMessage = threadMessages.pop()
|
|
||||||
}
|
|
||||||
if (toSendMessage) {
|
|
||||||
deleteMessage(toSendMessage.thread_id, toSendMessage.id ?? '')
|
|
||||||
sendMessage(toSendMessage.content?.[0]?.text?.value || '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const threadModel = useMemo(() => thread?.model, [thread])
|
const threadModel = useMemo(() => thread?.model, [thread])
|
||||||
|
|
||||||
if (!messages || !threadModel) return null
|
if (!messages || !threadModel) return null
|
||||||
|
|
||||||
const showScrollToBottomBtn = !isAtBottom && hasScrollbar
|
|
||||||
const showGenerateAIResponseBtn =
|
|
||||||
(messages[messages.length - 1]?.role === 'user' ||
|
|
||||||
(messages[messages.length - 1]?.metadata &&
|
|
||||||
'tool_calls' in (messages[messages.length - 1].metadata ?? {}))) &&
|
|
||||||
!streamingContent
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<HeaderPage>
|
<HeaderPage>
|
||||||
<div className="flex items-center justify-between w-full pr-2">
|
<div className="flex items-center justify-between w-full pr-2">
|
||||||
{PlatformFeatures[PlatformFeature.ASSISTANTS] && <DropdownAssistant />}
|
{PlatformFeatures[PlatformFeature.ASSISTANTS] && (
|
||||||
|
<DropdownAssistant />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</HeaderPage>
|
</HeaderPage>
|
||||||
<div className="flex flex-col h-[calc(100%-40px)]">
|
<div className="flex flex-col h-[calc(100%-40px)]">
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
onScroll={handleScroll}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col h-full w-full overflow-auto px-4 pt-4 pb-3'
|
'flex flex-col h-full w-full overflow-auto px-4 pt-4 pb-3'
|
||||||
)}
|
)}
|
||||||
@ -362,38 +174,10 @@ function ThreadDetail() {
|
|||||||
isSmallScreen && 'w-full'
|
isSmallScreen && 'w-full'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<ScrollToBottom
|
||||||
className={cn(
|
threadId={threadId}
|
||||||
'absolute z-0 -top-6 h-8 py-1 flex w-full justify-center pointer-events-none opacity-0 visibility-hidden',
|
scrollContainerRef={scrollContainerRef}
|
||||||
appMainViewBgColor.a === 1
|
/>
|
||||||
? 'from-main-view/20 bg-gradient-to-b to-main-view backdrop-blur'
|
|
||||||
: 'bg-transparent',
|
|
||||||
(showScrollToBottomBtn || showGenerateAIResponseBtn) &&
|
|
||||||
'visibility-visible opacity-100'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{showScrollToBottomBtn && (
|
|
||||||
<div
|
|
||||||
className="bg-main-view-fg/10 px-2 border border-main-view-fg/5 flex items-center justify-center rounded-xl gap-x-2 cursor-pointer pointer-events-auto"
|
|
||||||
onClick={() => {
|
|
||||||
scrollToBottom(true)
|
|
||||||
setIsUserScrolling(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="text-xs">{t('scrollToBottom')}</p>
|
|
||||||
<ArrowDown size={12} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showGenerateAIResponseBtn && (
|
|
||||||
<div
|
|
||||||
className="mx-2 bg-main-view-fg/10 px-2 border border-main-view-fg/5 flex items-center justify-center rounded-xl gap-x-2 cursor-pointer pointer-events-auto"
|
|
||||||
onClick={generateAIResponse}
|
|
||||||
>
|
|
||||||
<p className="text-xs">{t('common:generateAiResponse')}</p>
|
|
||||||
<Play size={12} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ChatInput model={threadModel} />
|
<ChatInput model={threadModel} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user