Merge branch 'main' into feat/rag-2

This commit is contained in:
twwu
2025-08-18 11:16:18 +08:00
99 changed files with 3421 additions and 1810 deletions

View File

@ -8,7 +8,6 @@ import {
} from '@heroicons/react/24/outline'
import { RiCloseLine, RiEditFill } from '@remixicon/react'
import { get } from 'lodash-es'
import InfiniteScroll from 'react-infinite-scroll-component'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
@ -111,7 +110,8 @@ const statusTdRender = (statusCount: StatusCount) => {
const getFormattedChatList = (messages: ChatMessage[], conversationId: string, timezone: string, format: string) => {
const newChatList: IChatItem[] = []
messages.forEach((item: ChatMessage) => {
try {
messages.forEach((item: ChatMessage) => {
const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || []
newChatList.push({
id: `question-${item.id}`,
@ -178,7 +178,13 @@ const getFormattedChatList = (messages: ChatMessage[], conversationId: string, t
parentMessageId: `question-${item.id}`,
})
})
return newChatList
return newChatList
}
catch (error) {
console.error('getFormattedChatList processing failed:', error)
throw error
}
}
type IDetailPanel = {
@ -188,6 +194,9 @@ type IDetailPanel = {
}
function DetailPanel({ detail, onFeedback }: IDetailPanel) {
const MIN_ITEMS_FOR_SCROLL_LOADING = 8
const SCROLL_THRESHOLD_PX = 50
const SCROLL_DEBOUNCE_MS = 200
const { userProfile: { timezone } } = useAppContext()
const { formatTime } = useTimestamp()
const { onClose, appDetail } = useContext(DrawerContext)
@ -204,13 +213,19 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
const { t } = useTranslation()
const [hasMore, setHasMore] = useState(true)
const [varValues, setVarValues] = useState<Record<string, string>>({})
const isLoadingRef = useRef(false)
const [allChatItems, setAllChatItems] = useState<IChatItem[]>([])
const [chatItemTree, setChatItemTree] = useState<ChatItemInTree[]>([])
const [threadChatItems, setThreadChatItems] = useState<IChatItem[]>([])
const fetchData = useCallback(async () => {
if (isLoadingRef.current)
return
try {
isLoadingRef.current = true
if (!hasMore)
return
@ -218,8 +233,11 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
conversation_id: detail.id,
limit: 10,
}
if (allChatItems[0]?.id)
params.first_id = allChatItems[0]?.id.replace('question-', '')
// Use the oldest answer item ID for pagination
const answerItems = allChatItems.filter(item => item.isAnswer)
const oldestAnswerItem = answerItems[answerItems.length - 1]
if (oldestAnswerItem?.id)
params.first_id = oldestAnswerItem.id
const messageRes = await fetchChatMessages({
url: `/apps/${appDetail?.id}/chat-messages`,
params,
@ -249,15 +267,20 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
}
setChatItemTree(tree)
setThreadChatItems(getThreadMessages(tree, newAllChatItems.at(-1)?.id))
const lastMessageId = newAllChatItems.length > 0 ? newAllChatItems[newAllChatItems.length - 1].id : undefined
setThreadChatItems(getThreadMessages(tree, lastMessageId))
}
catch (err) {
console.error(err)
console.error('fetchData execution failed:', err)
}
finally {
isLoadingRef.current = false
}
}, [allChatItems, detail.id, hasMore, timezone, t, appDetail, detail?.model_config?.configs?.introduction])
const switchSibling = useCallback((siblingMessageId: string) => {
setThreadChatItems(getThreadMessages(chatItemTree, siblingMessageId))
const newThreadChatItems = getThreadMessages(chatItemTree, siblingMessageId)
setThreadChatItems(newThreadChatItems)
}, [chatItemTree])
const handleAnnotationEdited = useCallback((query: string, answer: string, index: number) => {
@ -344,13 +367,217 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
const fetchInitiated = useRef(false)
// Only load initial messages, don't auto-load more
useEffect(() => {
if (appDetail?.id && detail.id && appDetail?.mode !== 'completion' && !fetchInitiated.current) {
// Mark as initialized, but don't auto-load more messages
fetchInitiated.current = true
// Still call fetchData to get initial messages
fetchData()
}
}, [appDetail?.id, detail.id, appDetail?.mode, fetchData])
const [isLoading, setIsLoading] = useState(false)
const loadMoreMessages = useCallback(async () => {
if (isLoading || !hasMore || !appDetail?.id || !detail.id)
return
setIsLoading(true)
try {
const params: ChatMessagesRequest = {
conversation_id: detail.id,
limit: 10,
}
// Use the earliest response item as the first_id
const answerItems = allChatItems.filter(item => item.isAnswer)
const oldestAnswerItem = answerItems[answerItems.length - 1]
if (oldestAnswerItem?.id) {
params.first_id = oldestAnswerItem.id
}
else if (allChatItems.length > 0 && allChatItems[0]?.id) {
const firstId = allChatItems[0].id.replace('question-', '').replace('answer-', '')
params.first_id = firstId
}
const messageRes = await fetchChatMessages({
url: `/apps/${appDetail.id}/chat-messages`,
params,
})
if (!messageRes.data || messageRes.data.length === 0) {
setHasMore(false)
return
}
if (messageRes.data.length > 0) {
const varValues = messageRes.data.at(-1)!.inputs
setVarValues(varValues)
}
setHasMore(messageRes.has_more)
const newItems = getFormattedChatList(
messageRes.data,
detail.id,
timezone!,
t('appLog.dateTimeFormat') as string,
)
// Check for duplicate messages
const existingIds = new Set(allChatItems.map(item => item.id))
const uniqueNewItems = newItems.filter(item => !existingIds.has(item.id))
if (uniqueNewItems.length === 0) {
if (allChatItems.length > 1) {
const nextId = allChatItems[1].id.replace('question-', '').replace('answer-', '')
const retryParams = {
...params,
first_id: nextId,
}
const retryRes = await fetchChatMessages({
url: `/apps/${appDetail.id}/chat-messages`,
params: retryParams,
})
if (retryRes.data && retryRes.data.length > 0) {
const retryItems = getFormattedChatList(
retryRes.data,
detail.id,
timezone!,
t('appLog.dateTimeFormat') as string,
)
const retryUniqueItems = retryItems.filter(item => !existingIds.has(item.id))
if (retryUniqueItems.length > 0) {
const newAllChatItems = [
...retryUniqueItems,
...allChatItems,
]
setAllChatItems(newAllChatItems)
let tree = buildChatItemTree(newAllChatItems)
if (retryRes.has_more === false && detail?.model_config?.configs?.introduction) {
tree = [{
id: 'introduction',
isAnswer: true,
isOpeningStatement: true,
content: detail?.model_config?.configs?.introduction ?? 'hello',
feedbackDisabled: true,
children: tree,
}]
}
setChatItemTree(tree)
setHasMore(retryRes.has_more)
setThreadChatItems(getThreadMessages(tree, newAllChatItems.at(-1)?.id))
return
}
}
}
}
const newAllChatItems = [
...uniqueNewItems,
...allChatItems,
]
setAllChatItems(newAllChatItems)
let tree = buildChatItemTree(newAllChatItems)
if (messageRes.has_more === false && detail?.model_config?.configs?.introduction) {
tree = [{
id: 'introduction',
isAnswer: true,
isOpeningStatement: true,
content: detail?.model_config?.configs?.introduction ?? 'hello',
feedbackDisabled: true,
children: tree,
}]
}
setChatItemTree(tree)
setThreadChatItems(getThreadMessages(tree, newAllChatItems.at(-1)?.id))
}
catch (error) {
console.error(error)
setHasMore(false)
}
finally {
setIsLoading(false)
}
}, [allChatItems, detail.id, hasMore, isLoading, timezone, t, appDetail])
useEffect(() => {
const scrollableDiv = document.getElementById('scrollableDiv')
const outerDiv = scrollableDiv?.parentElement
const chatContainer = document.querySelector('.mx-1.mb-1.grow.overflow-auto') as HTMLElement
let scrollContainer: HTMLElement | null = null
if (outerDiv && outerDiv.scrollHeight > outerDiv.clientHeight) {
scrollContainer = outerDiv
}
else if (scrollableDiv && scrollableDiv.scrollHeight > scrollableDiv.clientHeight) {
scrollContainer = scrollableDiv
}
else if (chatContainer && chatContainer.scrollHeight > chatContainer.clientHeight) {
scrollContainer = chatContainer
}
else {
const possibleContainers = document.querySelectorAll('.overflow-auto, .overflow-y-auto')
for (let i = 0; i < possibleContainers.length; i++) {
const container = possibleContainers[i] as HTMLElement
if (container.scrollHeight > container.clientHeight) {
scrollContainer = container
break
}
}
}
if (!scrollContainer)
return
let lastLoadTime = 0
const throttleDelay = 200
const handleScroll = () => {
const currentScrollTop = scrollContainer!.scrollTop
const scrollHeight = scrollContainer!.scrollHeight
const clientHeight = scrollContainer!.clientHeight
const distanceFromTop = currentScrollTop
const distanceFromBottom = scrollHeight - currentScrollTop - clientHeight
const now = Date.now()
const isNearTop = distanceFromTop < 30
// eslint-disable-next-line sonarjs/no-unused-vars
const _distanceFromBottom = distanceFromBottom < 30
if (isNearTop && hasMore && !isLoading && (now - lastLoadTime > throttleDelay)) {
lastLoadTime = now
loadMoreMessages()
}
}
scrollContainer.addEventListener('scroll', handleScroll, { passive: true })
const handleWheel = (e: WheelEvent) => {
if (e.deltaY < 0)
handleScroll()
}
scrollContainer.addEventListener('wheel', handleWheel, { passive: true })
return () => {
scrollContainer!.removeEventListener('scroll', handleScroll)
scrollContainer!.removeEventListener('wheel', handleWheel)
}
}, [hasMore, isLoading, loadMoreMessages])
const isChatMode = appDetail?.mode !== 'completion'
const isAdvanced = appDetail?.mode === 'advanced-chat'
@ -378,6 +605,36 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
return () => cancelAnimationFrame(raf)
}, [])
// Add scroll listener to ensure loading is triggered
useEffect(() => {
if (threadChatItems.length >= MIN_ITEMS_FOR_SCROLL_LOADING && hasMore) {
const scrollableDiv = document.getElementById('scrollableDiv')
if (scrollableDiv) {
let loadingTimeout: NodeJS.Timeout | null = null
const handleScroll = () => {
const { scrollTop } = scrollableDiv
// Trigger loading when scrolling near the top
if (scrollTop < SCROLL_THRESHOLD_PX && !isLoadingRef.current) {
if (loadingTimeout)
clearTimeout(loadingTimeout)
loadingTimeout = setTimeout(fetchData, SCROLL_DEBOUNCE_MS) // 200ms debounce
}
}
scrollableDiv.addEventListener('scroll', handleScroll)
return () => {
scrollableDiv.removeEventListener('scroll', handleScroll)
if (loadingTimeout)
clearTimeout(loadingTimeout)
}
}
}
}, [threadChatItems.length, hasMore, fetchData])
return (
<div ref={ref} className='flex h-full flex-col rounded-xl border-[0.5px] border-components-panel-border'>
{/* Panel Header */}
@ -439,8 +696,8 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
siteInfo={null}
/>
</div>
: threadChatItems.length < 8
? <div className="mb-4 pt-4">
: threadChatItems.length < MIN_ITEMS_FOR_SCROLL_LOADING ? (
<div className="mb-4 pt-4">
<Chat
config={{
appId: appDetail?.id,
@ -466,35 +723,27 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
switchSibling={switchSibling}
/>
</div>
: <div
) : (
<div
className="py-4"
id="scrollableDiv"
style={{
display: 'flex',
flexDirection: 'column-reverse',
height: '100%',
overflow: 'auto',
}}>
{/* Put the scroll bar always on the bottom */}
<InfiniteScroll
scrollableTarget="scrollableDiv"
dataLength={threadChatItems.length}
next={fetchData}
hasMore={hasMore}
loader={<div className='system-xs-regular text-center text-text-tertiary'>{t('appLog.detail.loading')}...</div>}
// endMessage={<div className='text-center'>Nothing more to show</div>}
// below props only if you need pull down functionality
refreshFunction={fetchData}
pullDownToRefresh
pullDownToRefreshThreshold={50}
// pullDownToRefreshContent={
// <div className='text-center'>Pull down to refresh</div>
// }
// releaseToRefreshContent={
// <div className='text-center'>Release to refresh</div>
// }
// To put endMessage and loader to the top.
style={{ display: 'flex', flexDirection: 'column-reverse' }}
inverse={true}
>
<div className="flex w-full flex-col-reverse" style={{ position: 'relative' }}>
{/* Loading state indicator - only shown when loading */}
{hasMore && isLoading && (
<div className="sticky left-0 right-0 top-0 z-10 bg-primary-50/40 py-3 text-center">
<div className='system-xs-regular text-text-tertiary'>
{t('appLog.detail.loading')}...
</div>
</div>
)}
<Chat
config={{
appId: appDetail?.id,
@ -519,8 +768,9 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
chatContainerInnerClassName='px-3'
switchSibling={switchSibling}
/>
</InfiniteScroll>
</div>
</div>
)
}
</div>
{showMessageLogModal && (

View File

@ -407,8 +407,8 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
}
btnClassName={open =>
cn(
open ? '!bg-black/5 !shadow-none' : '!bg-transparent',
'h-8 w-8 rounded-md border-none !p-2 hover:!bg-black/5',
open ? '!bg-state-base-hover !shadow-none' : '!bg-transparent',
'h-8 w-8 rounded-md border-none !p-2 hover:!bg-state-base-hover',
)
}
popupClassName={

View File

@ -2,6 +2,7 @@
import type { FC } from 'react'
import React from 'react'
import s from './style.module.css'
import cn from '@/utils/classnames'
export type ILoadingAnimProps = {
type: 'text' | 'avatar'
@ -11,7 +12,7 @@ const LoadingAnim: FC<ILoadingAnimProps> = ({
type,
}) => {
return (
<div className={`${s['dot-flashing']} ${s[type]}`}></div>
<div className={cn(s['dot-flashing'], s[type])} />
)
}
export default React.memo(LoadingAnim)

View File

@ -8,6 +8,7 @@ import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import ConfirmAddVar from '@/app/components/app/configuration/config-prompt/confirm-add-var'
import PromptEditor from '@/app/components/base/prompt-editor'
import type { OpeningStatement } from '@/app/components/base/features/types'
import { getInputKeys } from '@/app/components/base/block-input'
import type { PromptVariable } from '@/models/debug'
@ -101,7 +102,7 @@ const OpeningSettingModal = ({
<div>·</div>
<div>{tempSuggestedQuestions.length}/{MAX_QUESTION_NUM}</div>
</div>
<Divider bgStyle='gradient' className='ml-3 h-px w-0 grow'/>
<Divider bgStyle='gradient' className='ml-3 h-px w-0 grow' />
</div>
<ReactSortable
className="space-y-1"
@ -178,19 +179,32 @@ const OpeningSettingModal = ({
>
<div className='mb-6 flex items-center justify-between'>
<div className='title-2xl-semi-bold text-text-primary'>{t('appDebug.feature.conversationOpener.title')}</div>
<div className='cursor-pointer p-1' onClick={onCancel}><RiCloseLine className='h-4 w-4 text-text-tertiary'/></div>
<div className='cursor-pointer p-1' onClick={onCancel}><RiCloseLine className='h-4 w-4 text-text-tertiary' /></div>
</div>
<div className='mb-8 flex gap-2'>
<div className='mt-1.5 h-8 w-8 shrink-0 rounded-lg border-components-panel-border bg-util-colors-orange-dark-orange-dark-500 p-1.5'>
<RiAsterisk className='h-5 w-5 text-text-primary-on-surface' />
</div>
<div className='grow rounded-2xl border-t border-divider-subtle bg-chat-bubble-bg p-3 shadow-xs'>
<textarea
<PromptEditor
value={tempValue}
rows={3}
onChange={e => setTempValue(e.target.value)}
className="system-md-regular w-full border-0 bg-transparent px-0 text-text-secondary focus:outline-none"
onChange={setTempValue}
placeholder={t('appDebug.openingStatement.placeholder') as string}
variableBlock={{
show: true,
variables: [
// Prompt variables
...promptVariables.map(item => ({
name: item.name || item.key,
value: item.key,
})),
// Workflow variables
...workflowVariables.map(item => ({
name: item.variable,
value: item.variable,
})),
],
}}
/>
{renderQuestions()}
</div>

View File

@ -137,7 +137,7 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
<>
<div className='grow overflow-y-auto'>
<table className={'w-full border-collapse border-0 text-[13px] leading-4 text-text-secondary '}>
<thead className='sticky top-0 h-7 text-xs font-medium uppercase leading-7 text-text-tertiary'>
<thead className='sticky top-0 h-7 text-xs font-medium uppercase leading-7 text-text-tertiary backdrop-blur-[5px]'>
<tr>
<td className='w-[128px] rounded-l-lg bg-background-section-burn pl-3'>{t('datasetHitTesting.table.header.source')}</td>
<td className='bg-background-section-burn'>{t('datasetHitTesting.table.header.text')}</td>

View File

@ -5,7 +5,7 @@ import type { GithubRepo } from '@/models/common'
import { RiLoader2Line } from '@remixicon/react'
const defaultData = {
stargazers_count: 98570,
stargazers_count: 110918,
}
const getStar = async () => {

View File

@ -79,6 +79,9 @@ const SwrInitializer = ({
<SWRConfig value={{
shouldRetryOnError: false,
revalidateOnFocus: false,
dedupingInterval: 60000,
focusThrottleInterval: 5000,
provider: () => new Map(),
}}>
{children}
</SWRConfig>

View File

@ -0,0 +1,15 @@
import { create } from 'zustand'
import type { Label } from './constant'
type State = {
labelList: Label[]
}
type Action = {
setLabelList: (labelList?: Label[]) => void
}
export const useStore = create<State & Action>(set => ({
labelList: [],
setLabelList: labelList => set(() => ({ labelList })),
}))

View File

@ -246,11 +246,11 @@ export const useWorkflow = () => {
const handleOutVarRenameChange = useCallback((nodeId: string, oldValeSelector: ValueSelector, newVarSelector: ValueSelector) => {
const { getNodes, setNodes } = store.getState()
const afterNodes = getAfterNodesInSameBranch(nodeId)
const effectNodes = findUsedVarNodes(oldValeSelector, afterNodes)
if (effectNodes.length > 0) {
const newNodes = getNodes().map((node) => {
if (effectNodes.find(n => n.id === node.id))
const allNodes = getNodes()
const affectedNodes = findUsedVarNodes(oldValeSelector, allNodes)
if (affectedNodes.length > 0) {
const newNodes = allNodes.map((node) => {
if (affectedNodes.find(n => n.id === node.id))
return updateNodeVars(node, oldValeSelector, newVarSelector)
return node

View File

@ -1101,7 +1101,15 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => {
res = (data as IfElseNodeType).conditions?.map((c) => {
return c.variable_selector || []
}) || []
res.push(...((data as IfElseNodeType).cases || []).flatMap(c => (c.conditions || [])).map(c => c.variable_selector || []))
res.push(...((data as IfElseNodeType).cases || []).flatMap(c => (c.conditions || [])).flatMap((c) => {
const selectors: ValueSelector[] = []
if (c.variable_selector)
selectors.push(c.variable_selector)
// Handle sub-variable conditions
if (c.sub_variable_condition && c.sub_variable_condition.conditions)
selectors.push(...c.sub_variable_condition.conditions.map(subC => subC.variable_selector || []).filter(sel => sel.length > 0))
return selectors
}))
break
}
case BlockEnum.Code: {
@ -1345,6 +1353,26 @@ export const updateNodeVars = (oldNode: Node, oldVarSelector: ValueSelector, new
return c
})
}
if (payload.cases) {
payload.cases = payload.cases.map((caseItem) => {
if (caseItem.conditions) {
caseItem.conditions = caseItem.conditions.map((c) => {
if (c.variable_selector?.join('.') === oldVarSelector.join('.'))
c.variable_selector = newVarSelector
// Handle sub-variable conditions
if (c.sub_variable_condition && c.sub_variable_condition.conditions) {
c.sub_variable_condition.conditions = c.sub_variable_condition.conditions.map((subC) => {
if (subC.variable_selector?.join('.') === oldVarSelector.join('.'))
subC.variable_selector = newVarSelector
return subC
})
}
return c
})
}
return caseItem
})
}
break
}
case BlockEnum.Code: {

View File

@ -90,7 +90,7 @@ const DebugAndPreview = () => {
<div
ref={containerRef}
className={cn(
'relative flex h-full flex-col rounded-l-2xl border border-r-0 border-components-panel-border bg-components-panel-bg shadow-xl',
'relative flex h-full flex-col rounded-l-2xl border border-r-0 border-components-panel-border bg-chatbot-bg shadow-xl',
)}
style={{ width: `${panelWidth}px` }}
>