226 lines
6.8 KiB
TypeScript
226 lines
6.8 KiB
TypeScript
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
import { ThreadMessage } from '@janhq/core'
|
|
import { ScrollArea } from '@janhq/joi'
|
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
|
|
|
import { useAtomValue, useSetAtom } from 'jotai'
|
|
|
|
import { loadModelErrorAtom } from '@/hooks/useActiveModel'
|
|
|
|
import ChatItem from '../ChatItem'
|
|
|
|
import LoadModelError from '../LoadModelError'
|
|
|
|
import { toolCallBlockStateAtom } from '../TextMessage/ToolCallBlock'
|
|
|
|
import EmptyThread from './EmptyThread'
|
|
|
|
import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
|
|
import { showScrollBarAtom } from '@/helpers/atoms/Setting.atom'
|
|
import {
|
|
activeThreadAtom,
|
|
isBlockingSendAtom,
|
|
} from '@/helpers/atoms/Thread.atom'
|
|
|
|
const ChatConfigurator = memo(() => {
|
|
const messages = useAtomValue(getCurrentChatMessagesAtom)
|
|
const currentThread = useAtomValue(activeThreadAtom)
|
|
|
|
const [current, setCurrent] = useState<ThreadMessage[]>([])
|
|
const loadModelError = useAtomValue(loadModelErrorAtom)
|
|
|
|
const isMessagesIdentificial = (
|
|
arr1: ThreadMessage[],
|
|
arr2: ThreadMessage[]
|
|
): boolean => {
|
|
if (arr1.length !== arr2.length) return false
|
|
return arr1.every((item, index) => item.id === arr2[index].id)
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!isMessagesIdentificial(messages, current) ||
|
|
messages.some((e) => e.thread_id !== currentThread?.id)
|
|
) {
|
|
setCurrent(messages)
|
|
}
|
|
}, [messages, current, loadModelError, currentThread])
|
|
|
|
if (!messages.length) return <EmptyThread />
|
|
return (
|
|
<div className="flex h-full w-full flex-col">
|
|
<ChatBody loadModelError={loadModelError} messages={current} />
|
|
</div>
|
|
)
|
|
})
|
|
|
|
const ChatBody = memo(
|
|
({
|
|
messages,
|
|
loadModelError,
|
|
}: {
|
|
messages: ThreadMessage[]
|
|
loadModelError?: string
|
|
}) => {
|
|
// The scrollable element for your list
|
|
const parentRef = useRef<HTMLDivElement>(null)
|
|
const prevScrollTop = useRef(0)
|
|
const isUserManuallyScrollingUp = useRef(false)
|
|
const isNestedScrollviewExpanding = useRef(false)
|
|
const currentThread = useAtomValue(activeThreadAtom)
|
|
const isBlockingSend = useAtomValue(isBlockingSendAtom)
|
|
const showScrollBar = useAtomValue(showScrollBarAtom)
|
|
const setToolCallExpanded = useSetAtom(toolCallBlockStateAtom)
|
|
|
|
const count = useMemo(
|
|
() => (messages?.length ?? 0) + (loadModelError ? 1 : 0),
|
|
[messages, loadModelError]
|
|
)
|
|
|
|
// The virtualizer
|
|
const virtualizer = useVirtualizer({
|
|
count,
|
|
getScrollElement: () => parentRef.current,
|
|
estimateSize: () => 35,
|
|
overscan: 5,
|
|
})
|
|
|
|
useEffect(() => {
|
|
isUserManuallyScrollingUp.current = false
|
|
if (parentRef.current && isBlockingSend) {
|
|
parentRef.current.scrollTo({ top: parentRef.current.scrollHeight })
|
|
virtualizer.scrollToIndex(count - 1)
|
|
}
|
|
}, [count, virtualizer, isBlockingSend, currentThread?.id])
|
|
|
|
const items = virtualizer.getVirtualItems()
|
|
|
|
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (
|
|
item,
|
|
_,
|
|
instance
|
|
) => {
|
|
if (
|
|
isNestedScrollviewExpanding ||
|
|
(isUserManuallyScrollingUp.current === true && isBlockingSend)
|
|
)
|
|
return false
|
|
return (
|
|
// item.start < (instance.scrollOffset ?? 0) &&
|
|
instance.scrollDirection !== 'backward'
|
|
)
|
|
}
|
|
|
|
const handleScroll = useCallback(
|
|
(event: React.UIEvent<HTMLElement>) => {
|
|
const currentScrollTop = event.currentTarget.scrollTop
|
|
|
|
if (prevScrollTop.current > currentScrollTop && isBlockingSend) {
|
|
isUserManuallyScrollingUp.current = true
|
|
} else {
|
|
const currentScrollTop = event.currentTarget.scrollTop
|
|
const scrollHeight = event.currentTarget.scrollHeight
|
|
const clientHeight = event.currentTarget.clientHeight
|
|
|
|
if (currentScrollTop + clientHeight >= scrollHeight) {
|
|
isUserManuallyScrollingUp.current = false
|
|
}
|
|
}
|
|
|
|
if (isUserManuallyScrollingUp.current === true) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
}
|
|
prevScrollTop.current = currentScrollTop
|
|
},
|
|
[isBlockingSend]
|
|
)
|
|
|
|
const preserveScrollOnExpand = (callback: () => void) => {
|
|
isNestedScrollviewExpanding.current = true
|
|
const scrollEl = parentRef.current
|
|
const prevScrollTop = scrollEl?.scrollTop ?? 0
|
|
const prevScrollHeight = scrollEl?.scrollHeight ?? 0
|
|
|
|
callback() // Expand content (e.g. setIsExpanded(true))
|
|
|
|
if (scrollEl)
|
|
requestAnimationFrame(() => {
|
|
const newScrollHeight = scrollEl?.scrollHeight ?? 0
|
|
scrollEl.scrollTop =
|
|
prevScrollTop + (newScrollHeight - prevScrollHeight)
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full w-full flex-col overflow-x-hidden">
|
|
<ScrollArea
|
|
type={showScrollBar ? 'always' : 'scroll'}
|
|
ref={parentRef}
|
|
onScroll={handleScroll}
|
|
className="List"
|
|
style={{
|
|
flex: 1,
|
|
height: '100%',
|
|
width: '100%',
|
|
overflowY: 'auto',
|
|
overflowX: 'hidden',
|
|
contain: 'strict',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
height: virtualizer.getTotalSize(),
|
|
width: '100%',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
transform: `translateY(${items[0]?.start ?? 0}px)`,
|
|
}}
|
|
>
|
|
{items.map((virtualRow) => (
|
|
<div
|
|
key={messages[virtualRow.index]?.id ?? virtualRow.index}
|
|
data-index={virtualRow.index}
|
|
ref={virtualizer.measureElement}
|
|
className="first:mt-4"
|
|
>
|
|
{loadModelError && virtualRow.index === count - 1 ? (
|
|
<LoadModelError />
|
|
) : (
|
|
<ChatItem
|
|
{...messages[virtualRow.index]}
|
|
loadModelError={loadModelError}
|
|
index={virtualRow.index}
|
|
isCurrentMessage={
|
|
virtualRow.index === messages?.length - 1
|
|
}
|
|
onExpand={(props) =>
|
|
preserveScrollOnExpand(() => {
|
|
setToolCallExpanded((prev) => ({
|
|
...prev,
|
|
...props,
|
|
}))
|
|
})
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
)
|
|
}
|
|
)
|
|
|
|
export default memo(ChatConfigurator)
|