263 lines
9.1 KiB
TypeScript
263 lines
9.1 KiB
TypeScript
/**
|
|
* @Description: Scroll List
|
|
* @Version: 0.0.1
|
|
* @Author: yujiangping
|
|
* @Date: 2025-11-18 16:19:58
|
|
* @LastEditors: yujiangping
|
|
* @LastEditTime: 2025-12-22 13:47:53
|
|
*/
|
|
import { FileOutlined, FieldTimeOutlined, EditOutlined } from '@ant-design/icons';
|
|
import { Skeleton } from 'antd';
|
|
import { useTranslation } from 'react-i18next';
|
|
import type { RecallTestData } from '@/views/KnowledgeBase/types';
|
|
import { NoData } from './noData';
|
|
import { formatDateTime } from '@/utils/format';
|
|
import InfiniteScroll from 'react-infinite-scroll-component';
|
|
import RbMarkdown from '@/components/Markdown';
|
|
import { useMemo } from 'react';
|
|
|
|
interface RecallTestResultProps {
|
|
data: RecallTestData[];
|
|
showEmpty?: boolean;
|
|
hasMore?: boolean;
|
|
loadMore?: () => void;
|
|
loading?: boolean;
|
|
scrollableTarget?: string;
|
|
editable?: boolean; // Whether editable
|
|
onItemClick?: (item: RecallTestData, index: number) => void; // Click item callback
|
|
parserMode?: number; // Parser mode, 1 means QA format
|
|
}
|
|
|
|
const RecallTestResult = ({
|
|
data,
|
|
showEmpty = true,
|
|
hasMore = false,
|
|
loadMore,
|
|
loading = false,
|
|
scrollableTarget,
|
|
editable = false,
|
|
onItemClick,
|
|
parserMode = 0,
|
|
}: RecallTestResultProps) => {
|
|
const { t } = useTranslation();
|
|
|
|
// Parse QA format content
|
|
const parseQAContent = (content: string) => {
|
|
if (!content || parserMode !== 1) return null;
|
|
|
|
const qaRegex = /question:\s*(.*?)\s*answer:\s*(.*?)$/s;
|
|
const match = content.match(qaRegex);
|
|
|
|
if (match) {
|
|
const question = match[1]?.trim() || '';
|
|
const answer = match[2]?.trim() || '';
|
|
return { question, answer };
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
// Format QA content for display
|
|
const formatQAContent = (question: string, answer: string) => {
|
|
return `**${t('knowledgeBase.question')}:** ${question}\n**${t('knowledgeBase.answer')}:** ${answer}`;
|
|
};
|
|
|
|
// Check if content is valid HTML
|
|
const isValidHTML = (content: string): boolean => {
|
|
if (!content) return false;
|
|
// Check if content contains HTML tags
|
|
const htmlTagPattern = /<[^>]+>/;
|
|
return htmlTagPattern.test(content);
|
|
};
|
|
|
|
// Render content with HTML or Markdown fallback
|
|
const renderTextContent = useMemo(() => {
|
|
return (content: string) => {
|
|
// Try to render as HTML first
|
|
if (isValidHTML(content)) {
|
|
try {
|
|
return (
|
|
<div
|
|
className='rb:prose rb:prose-sm rb:max-w-none'
|
|
dangerouslySetInnerHTML={{ __html: content }}
|
|
/>
|
|
);
|
|
} catch (error) {
|
|
console.warn('HTML parsing failed, falling back to Markdown:', error);
|
|
}
|
|
}
|
|
|
|
// Fallback to Markdown rendering
|
|
return <RbMarkdown content={content} showHtmlComments={true} />;
|
|
};
|
|
}, []);
|
|
|
|
const handleItemClick = (e: React.MouseEvent, item: RecallTestData, index: number) => {
|
|
// Check if the click is on an image or image-related element
|
|
const target = e.target as HTMLElement;
|
|
|
|
// Check if clicked on image itself, image container, preview layer, close button or SVG icon
|
|
if (
|
|
target.tagName === 'IMG' ||
|
|
target.tagName === 'SVG' || // SVG icon
|
|
target.tagName === 'PATH' || // SVG path
|
|
target.closest('.ant-image') ||
|
|
target.closest('.ant-image-preview') ||
|
|
target.closest('.ant-image-preview-wrap') ||
|
|
target.closest('.ant-image-preview-operations') ||
|
|
target.closest('.anticon') || // Ant Design icon
|
|
target.classList.contains('ant-image-img') ||
|
|
target.classList.contains('ant-image-mask') ||
|
|
target.classList.contains('ant-image-preview-close') ||
|
|
target.classList.contains('anticon')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (editable && onItemClick) {
|
|
onItemClick(item, index);
|
|
}
|
|
};
|
|
|
|
// Get color class based on score
|
|
const getScoreColorClass = (score: number): string => {
|
|
const percentage = score * 100;
|
|
if (percentage >= 90) {
|
|
return 'rb:text-[#155EEF]';
|
|
} else if (percentage >= 80) {
|
|
return 'rb:text-[#369F21]';
|
|
} else {
|
|
return 'rb:text-[#FF5D34]';
|
|
}
|
|
};
|
|
|
|
// Show skeleton when initial loading
|
|
if (loading && data.length === 0) {
|
|
return (
|
|
<div className='rb:flex rb:flex-col'>
|
|
<div className='rb:flex rb:items-center rb:justify-start rb:gap-2 rb:mb-4'>
|
|
<span className='rb:text-lg rb:font-medium'>{t('knowledgeBase.recallResult')}</span>
|
|
</div>
|
|
<Skeleton active paragraph={{ rows: 3 }} />
|
|
<Skeleton active paragraph={{ rows: 3 }} className='rb:mt-4' />
|
|
<Skeleton active paragraph={{ rows: 3 }} className='rb:mt-4' />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (data.length === 0 && showEmpty) {
|
|
return (
|
|
<NoData
|
|
title={t('knowledgeBase.recallTestUnStart')}
|
|
subTitle={t('knowledgeBase.recallTestUnStartSubTitle')}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (data.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const renderContent = () => (
|
|
<div className='rb:flex rb:flex-col rb:mt-4'>
|
|
{data.map((item, index) => {
|
|
const score = item.metadata?.score ?? 1;
|
|
const scorePercentage = score * 100;
|
|
const colorClass = getScoreColorClass(score);
|
|
const showScore = item.metadata?.score !== null && item.metadata?.score !== undefined;
|
|
|
|
return (
|
|
<div
|
|
key={`${item.metadata?.sort_id || index}-${index}`}
|
|
className={`rb:flex rb:flex-col rb:mb-4 rb:rounded-xl rb:bg-[#F6F6F6] rb:p-4 rb:pt-2 rb:pb-3 rb:relative rb:group ${editable ? 'rb:cursor-pointer rb:transition-all hover:rb:border-[#155EEF] hover:rb:shadow-md' : ''}`}
|
|
onClick={(e) => handleItemClick(e, item, index)}
|
|
>
|
|
{editable && (
|
|
<div className='rb:absolute rb:top-2 rb:right-2 rb:opacity-0 group-hover:rb:opacity-100 rb:transition-opacity'>
|
|
<EditOutlined className='rb:text-[#155EEF] rb:text-base' />
|
|
</div>
|
|
)}
|
|
<div className='rb:flex rb:items-center rb:justify-between'>
|
|
{showScore && (
|
|
<span className={`${colorClass} rb:text-xl rb:font-semibold`}>
|
|
{scorePercentage.toFixed(1)}% {t('knowledgeBase.similarity')}
|
|
</span>
|
|
)}
|
|
<div className={`rb:flex rb:mt-2 rb:flex rb:items-end rb:justify-end rb:gap-4 ${!showScore ? 'rb:w-full' : ''}`}>
|
|
<span className='rb:text-gray-800'>
|
|
<FileOutlined /> {item.metadata?.file_name || '-'}
|
|
</span>
|
|
<span className='rb:text-gray-500 rb:text-xs rb:bg-[#DFDFDF] rb:px-1 rb:py-[2px] rb:rounded'>
|
|
chunk_{item.metadata?.sort_id || index}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className='rb:flex rb:text-left rb:px-4 rb:py-3 rb:bg-white rb:rounded-lg rb:mt-2'>
|
|
<div className='rb:text-gray-800 rb:text-sm rb:whitespace-pre-wrap rb:break-words rb:w-full'>
|
|
{(() => {
|
|
const qaContent = parseQAContent(item.page_content);
|
|
if (qaContent) {
|
|
const formattedContent = formatQAContent(qaContent.question, qaContent.answer);
|
|
return renderTextContent(formattedContent);
|
|
}
|
|
return renderTextContent(item.page_content);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
{item.metadata?.file_created_at && (
|
|
<div className='rb:flex rb:items-center rb:justify-start rb:mt-3'>
|
|
<span className='rb:text-gray-500 rb:text-xs'>
|
|
<FieldTimeOutlined /> {formatDateTime(item.metadata.file_created_at)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
{loading && (
|
|
<div className='rb:mb-4'>
|
|
<Skeleton active paragraph={{ rows: 3 }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// If loadMore and hasMore are provided, use InfiniteScroll
|
|
if (loadMore && hasMore !== undefined) {
|
|
return (
|
|
<div className='rb:flex rb:h-full rb:flex-col'>
|
|
<div className='rb:flex rb:items-center rb:justify-start rb:gap-2'>
|
|
<span className='rb:text-lg rb:font-medium'>{t('knowledgeBase.recallResult')}</span>
|
|
<span className='rb:text-gray-500 rb:text-xs rb:pt-[2px]'>
|
|
(<span className='rb:text-[#155EEF]'>{data.length}</span> results)
|
|
</span>
|
|
</div>
|
|
<InfiniteScroll
|
|
dataLength={data.length}
|
|
next={loadMore}
|
|
hasMore={hasMore}
|
|
loader={<Skeleton active paragraph={{ rows: 3 }} className='rb:mt-4' />}
|
|
scrollableTarget={scrollableTarget}
|
|
>
|
|
{renderContent()}
|
|
</InfiniteScroll>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Otherwise use normal rendering
|
|
return (
|
|
<div className='rb:flex rb:flex-col'>
|
|
<div className='rb:flex rb:items-center rb:justify-start rb:gap-2'>
|
|
<span className='rb:text-lg rb:font-medium'>{t('knowledgeBase.recallResult')}</span>
|
|
<span className='rb:text-gray-500 rb:text-xs rb:pt-[2px]'>
|
|
(<span className='rb:text-[#155EEF]'>{data.length}</span> results)
|
|
</span>
|
|
</div>
|
|
{renderContent()}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RecallTestResult;
|