瀏覽代碼

feat(next-search): Implements document preview functionality #3221 (#9465)

### What problem does this PR solve?

feat(next-search): Implements document preview functionality

- Adds a new document preview modal component
- Implements document preview page logic
- Adds document preview-related hooks
- Optimizes document preview rendering logic
### Type of change

- [x] New Feature (non-breaking change which adds functionality)
tags/v0.20.2
chanx 2 月之前
父節點
當前提交
b1baa91ff0
No account linked to committer's email address
共有 29 個文件被更改,包括 1336 次插入122 次删除
  1. 1
    0
      web/src/components/originui/input.tsx
  2. 1
    1
      web/src/components/ui/modal/modal.tsx
  3. 89
    0
      web/src/components/ui/tooltip.tsx
  4. 3
    3
      web/src/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk/components/document-preview/csv-preview.tsx
  5. 6
    3
      web/src/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk/components/document-preview/doc-preview.tsx
  6. 3
    2
      web/src/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk/components/document-preview/excel-preview.tsx
  7. 3
    2
      web/src/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk/components/document-preview/image-preview.tsx
  8. 9
    6
      web/src/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk/components/document-preview/index.tsx
  9. 3
    3
      web/src/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk/components/document-preview/pdf-preview.tsx
  10. 6
    3
      web/src/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk/components/document-preview/ppt-preview.tsx
  11. 3
    4
      web/src/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk/components/document-preview/txt-preview.tsx
  12. 3
    0
      web/src/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk/index.tsx
  13. 29
    0
      web/src/pages/next-search/document-preview-modal/hooks.ts
  14. 65
    0
      web/src/pages/next-search/document-preview-modal/index.tsx
  15. 48
    0
      web/src/pages/next-search/highlight-markdown/index.tsx
  16. 8
    0
      web/src/pages/next-search/index.less
  17. 33
    22
      web/src/pages/next-search/index.tsx
  18. 295
    0
      web/src/pages/next-search/markdown-content/index.tsx
  19. 47
    0
      web/src/pages/next-search/mindmap-drawer.tsx
  20. 11
    0
      web/src/pages/next-search/retrieval-documents/index.less
  21. 237
    0
      web/src/pages/next-search/retrieval-documents/index.tsx
  22. 19
    2
      web/src/pages/next-search/search-home.tsx
  23. 9
    7
      web/src/pages/next-search/search-setting-aisummery-config.tsx
  24. 97
    35
      web/src/pages/next-search/search-setting.tsx
  25. 278
    10
      web/src/pages/next-search/searching.tsx
  26. 6
    2
      web/src/pages/next-search/spotlight.tsx
  27. 17
    12
      web/src/pages/next-searches/hooks.ts
  28. 6
    5
      web/src/pages/next-searches/index.tsx
  29. 1
    0
      web/src/pages/search/hooks.ts

+ 1
- 0
web/src/components/originui/input.tsx 查看文件

@@ -50,3 +50,4 @@ const Input = function ({
};

export { Input };
export default React.forwardRef(Input);

+ 1
- 1
web/src/components/ui/modal/modal.tsx 查看文件

@@ -152,7 +152,7 @@ const Modal: ModalType = ({
onClick={() => maskClosable && onOpenChange?.(false)}
>
<DialogPrimitive.Content
className={`relative w-[700px] ${full ? 'max-w-full' : sizeClasses[size]} ${className} bg-colors-background-neutral-standard rounded-lg shadow-lg transition-all focus-visible:!outline-none`}
className={`relative w-[700px] ${full ? 'max-w-full' : sizeClasses[size]} ${className} bg-colors-background-neutral-standard rounded-lg shadow-lg border transition-all focus-visible:!outline-none`}
onClick={(e) => e.stopPropagation()}
>
{/* title */}

+ 89
- 0
web/src/components/ui/tooltip.tsx 查看文件

@@ -42,3 +42,92 @@ export const FormTooltip = ({ tooltip }: { tooltip: React.ReactNode }) => {
</Tooltip>
);
};

export interface AntToolTipProps {
title: React.ReactNode;
children: React.ReactNode;
placement?: 'top' | 'bottom' | 'left' | 'right';
trigger?: 'hover' | 'click' | 'focus';
className?: string;
}

export const AntToolTip: React.FC<AntToolTipProps> = ({
title,
children,
placement = 'top',
trigger = 'hover',
className,
}) => {
const [visible, setVisible] = React.useState(false);

const showTooltip = () => {
if (trigger === 'hover' || trigger === 'focus') {
setVisible(true);
}
};

const hideTooltip = () => {
if (trigger === 'hover' || trigger === 'focus') {
setVisible(false);
}
};

const toggleTooltip = () => {
if (trigger === 'click') {
setVisible(!visible);
}
};

const getPlacementClasses = () => {
switch (placement) {
case 'top':
return 'bottom-full left-1/2 transform -translate-x-1/2 mb-2';
case 'bottom':
return 'top-full left-1/2 transform -translate-x-1/2 mt-2';
case 'left':
return 'right-full top-1/2 transform -translate-y-1/2 mr-2';
case 'right':
return 'left-full top-1/2 transform -translate-y-1/2 ml-2';
default:
return 'bottom-full left-1/2 transform -translate-x-1/2 mb-2';
}
};

return (
<div className="inline-block relative">
<div
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
onClick={toggleTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}
>
{children}
</div>
{visible && title && (
<div
className={cn(
'absolute z-50 px-2.5 py-1.5 text-xs text-white bg-gray-800 rounded-sm shadow-sm whitespace-nowrap',
getPlacementClasses(),
className,
)}
>
{title}
<div
className={cn(
'absolute w-2 h-2 bg-gray-800',
placement === 'top' &&
'bottom-[-4px] left-1/2 transform -translate-x-1/2 rotate-45',
placement === 'bottom' &&
'top-[-4px] left-1/2 transform -translate-x-1/2 rotate-45',
placement === 'left' &&
'right-[-4px] top-1/2 transform -translate-y-1/2 rotate-45',
placement === 'right' &&
'left-[-4px] top-1/2 transform -translate-y-1/2 rotate-45',
)}
/>
</div>
)}
</div>
);
};

+ 3
- 3
web/src/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk/components/document-preview/csv-preview.tsx 查看文件

@@ -3,7 +3,6 @@ import { Spin } from '@/components/ui/spin';
import request from '@/utils/request';
import classNames from 'classnames';
import React, { useEffect, useRef, useState } from 'react';
import { useGetDocumentUrl } from './hooks';

interface CSVData {
rows: string[][];
@@ -12,13 +11,14 @@ interface CSVData {

interface FileViewerProps {
className?: string;
url: string;
}

const CSVFileViewer: React.FC<FileViewerProps> = () => {
const CSVFileViewer: React.FC<FileViewerProps> = ({ url }) => {
const [data, setData] = useState<CSVData | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const containerRef = useRef<HTMLDivElement>(null);
const url = useGetDocumentUrl();
// const url = useGetDocumentUrl();
const parseCSV = (csvText: string): CSVData => {
console.log('Parsing CSV data:', csvText);
const lines = csvText.split('\n');

+ 6
- 3
web/src/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk/components/document-preview/doc-preview.tsx 查看文件

@@ -4,14 +4,17 @@ import request from '@/utils/request';
import classNames from 'classnames';
import mammoth from 'mammoth';
import { useEffect, useState } from 'react';
import { useGetDocumentUrl } from './hooks';

interface DocPreviewerProps {
className?: string;
url: string;
}

export const DocPreviewer: React.FC<DocPreviewerProps> = ({ className }) => {
const url = useGetDocumentUrl();
export const DocPreviewer: React.FC<DocPreviewerProps> = ({
className,
url,
}) => {
// const url = useGetDocumentUrl();
const [htmlContent, setHtmlContent] = useState<string>('');
const [loading, setLoading] = useState(false);
const fetchDocument = async () => {

+ 3
- 2
web/src/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk/components/document-preview/excel-preview.tsx 查看文件

@@ -1,15 +1,16 @@
import { useFetchExcel } from '@/pages/document-viewer/hooks';
import classNames from 'classnames';
import { useGetDocumentUrl } from './hooks';

interface ExcelCsvPreviewerProps {
className?: string;
url: string;
}

export const ExcelCsvPreviewer: React.FC<ExcelCsvPreviewerProps> = ({
className,
url,
}) => {
const url = useGetDocumentUrl();
// const url = useGetDocumentUrl();
const { containerRef } = useFetchExcel(url);

return (

+ 3
- 2
web/src/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk/components/document-preview/image-preview.tsx 查看文件

@@ -3,16 +3,17 @@ import { Spin } from '@/components/ui/spin';
import request from '@/utils/request';
import classNames from 'classnames';
import { useEffect, useState } from 'react';
import { useGetDocumentUrl } from './hooks';

interface ImagePreviewerProps {
className?: string;
url: string;
}

export const ImagePreviewer: React.FC<ImagePreviewerProps> = ({
className,
url,
}) => {
const url = useGetDocumentUrl();
// const url = useGetDocumentUrl();
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);


+ 9
- 6
web/src/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk/components/document-preview/index.tsx 查看文件

@@ -12,12 +12,14 @@ import { TxtPreviewer } from './txt-preview';
type PreviewProps = {
fileType: string;
className?: string;
url: string;
};
const Preview = ({
fileType,
className,
highlights,
setWidthAndHeight,
url,
}: PreviewProps & Partial<IProps>) => {
return (
<>
@@ -26,37 +28,38 @@ const Preview = ({
<PdfPreviewer
highlights={highlights}
setWidthAndHeight={setWidthAndHeight}
url={url}
></PdfPreviewer>
</section>
)}
{['doc', 'docx'].indexOf(fileType) > -1 && (
<section>
<DocPreviewer className={className} />
<DocPreviewer className={className} url={url} />
</section>
)}
{['txt', 'md'].indexOf(fileType) > -1 && (
<section>
<TxtPreviewer className={className} />
<TxtPreviewer className={className} url={url} />
</section>
)}
{['visual'].indexOf(fileType) > -1 && (
<section>
<ImagePreviewer className={className} />
<ImagePreviewer className={className} url={url} />
</section>
)}
{['pptx'].indexOf(fileType) > -1 && (
<section>
<PptPreviewer className={className} />
<PptPreviewer className={className} url={url} />
</section>
)}
{['xlsx'].indexOf(fileType) > -1 && (
<section>
<ExcelCsvPreviewer className={className} />
<ExcelCsvPreviewer className={className} url={url} />
</section>
)}
{['csv'].indexOf(fileType) > -1 && (
<section>
<CSVFileViewer className={className} />
<CSVFileViewer className={className} url={url} />
</section>
)}
</>

+ 3
- 3
web/src/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk/components/document-preview/pdf-preview.tsx 查看文件

@@ -7,7 +7,6 @@ import {
PdfLoader,
Popup,
} from 'react-pdf-highlighter';
import { useGetDocumentUrl } from './hooks';

import { useCatchDocumentError } from '@/components/pdf-previewer/hooks';
import { Spin } from '@/components/ui/spin';
@@ -17,6 +16,7 @@ import styles from './index.less';
export interface IProps {
highlights: IHighlight[];
setWidthAndHeight: (width: number, height: number) => void;
url: string;
}
const HighlightPopup = ({
comment,
@@ -30,8 +30,8 @@ const HighlightPopup = ({
) : null;

// TODO: merge with DocumentPreviewer
const PdfPreview = ({ highlights: state, setWidthAndHeight }: IProps) => {
const url = useGetDocumentUrl();
const PdfPreview = ({ highlights: state, setWidthAndHeight, url }: IProps) => {
// const url = useGetDocumentUrl();

const ref = useRef<(highlight: IHighlight) => void>(() => {});
const error = useCatchDocumentError(url);

+ 6
- 3
web/src/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk/components/document-preview/ppt-preview.tsx 查看文件

@@ -3,13 +3,16 @@ import request from '@/utils/request';
import classNames from 'classnames';
import { init } from 'pptx-preview';
import { useEffect, useRef } from 'react';
import { useGetDocumentUrl } from './hooks';
interface PptPreviewerProps {
className?: string;
url: string;
}

export const PptPreviewer: React.FC<PptPreviewerProps> = ({ className }) => {
const url = useGetDocumentUrl();
export const PptPreviewer: React.FC<PptPreviewerProps> = ({
className,
url,
}) => {
// const url = useGetDocumentUrl();
const wrapper = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const fetchDocument = async () => {

+ 3
- 4
web/src/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk/components/document-preview/txt-preview.tsx 查看文件

@@ -3,11 +3,10 @@ import { Spin } from '@/components/ui/spin';
import request from '@/utils/request';
import classNames from 'classnames';
import { useEffect, useState } from 'react';
import { useGetDocumentUrl } from './hooks';

type TxtPreviewerProps = { className?: string };
export const TxtPreviewer = ({ className }: TxtPreviewerProps) => {
const url = useGetDocumentUrl();
type TxtPreviewerProps = { className?: string; url: string };
export const TxtPreviewer = ({ className, url }: TxtPreviewerProps) => {
// const url = useGetDocumentUrl();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<string>('');
const fetchTxt = async () => {

+ 3
- 0
web/src/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk/index.tsx 查看文件

@@ -40,6 +40,7 @@ import {
useNavigatePage,
} from '@/hooks/logic-hooks/navigate-hooks';
import { useFetchKnowledgeBaseConfiguration } from '@/hooks/use-knowledge-request';
import { useGetDocumentUrl } from '../../../knowledge-chunk/components/document-preview/hooks';
import styles from './index.less';

const Chunk = () => {
@@ -73,6 +74,7 @@ const Chunk = () => {
} = useUpdateChunk();
const { navigateToDataset, getQueryString, navigateToDatasetList } =
useNavigatePage();
const fileUrl = useGetDocumentUrl();
useEffect(() => {
setChunkList(data);
}, [data]);
@@ -212,6 +214,7 @@ const Chunk = () => {
fileType={fileType}
highlights={highlights}
setWidthAndHeight={setWidthAndHeight}
url={fileUrl}
></DocumentPreview>
</section>
</div>

+ 29
- 0
web/src/pages/next-search/document-preview-modal/hooks.ts 查看文件

@@ -0,0 +1,29 @@
import { useSetModalState } from '@/hooks/common-hooks';
import { IReferenceChunk } from '@/interfaces/database/chat';
import { useCallback, useState } from 'react';

export const useClickDrawer = () => {
const { visible, showModal, hideModal } = useSetModalState();
const [selectedChunk, setSelectedChunk] = useState<IReferenceChunk>(
{} as IReferenceChunk,
);
const [documentId, setDocumentId] = useState<string>('');

const clickDocumentButton = useCallback(
(documentId: string, chunk: IReferenceChunk) => {
showModal();
setSelectedChunk(chunk);
setDocumentId(documentId);
},
[showModal],
);

return {
clickDocumentButton,
visible,
showModal,
hideModal,
selectedChunk,
documentId,
};
};

+ 65
- 0
web/src/pages/next-search/document-preview-modal/index.tsx 查看文件

@@ -0,0 +1,65 @@
import { FileIcon } from '@/components/icon-font';
import { Modal } from '@/components/ui/modal/modal';
import {
useGetChunkHighlights,
useGetDocumentUrl,
} from '@/hooks/document-hooks';
import { IModalProps } from '@/interfaces/common';
import { IReferenceChunk } from '@/interfaces/database/chat';
import { IChunk } from '@/interfaces/database/knowledge';
import DocumentPreview from '@/pages/chunk/parsed-result/add-knowledge/components/knowledge-chunk/components/document-preview';
import { useEffect, useState } from 'react';

interface IProps extends IModalProps<any> {
documentId: string;
chunk: IChunk | IReferenceChunk;
}
function getFileExtensionRegex(filename: string): string {
const match = filename.match(/\.([^.]+)$/);
return match ? match[1].toLowerCase() : '';
}
const PdfDrawer = ({
visible = false,
hideModal,
documentId,
chunk,
}: IProps) => {
const getDocumentUrl = useGetDocumentUrl(documentId);
const { highlights, setWidthAndHeight } = useGetChunkHighlights(chunk);
// const ref = useRef<(highlight: IHighlight) => void>(() => {});
// const [loaded, setLoaded] = useState(false);
const url = getDocumentUrl();

console.log('chunk--->', chunk.docnm_kwd, url);
const [fileType, setFileType] = useState('');

useEffect(() => {
if (chunk.docnm_kwd) {
const type = getFileExtensionRegex(chunk.docnm_kwd);
setFileType(type);
}
}, [chunk.docnm_kwd]);
return (
<Modal
title={
<div className="flex items-center gap-2">
<FileIcon name={chunk.docnm_kwd}></FileIcon>
{chunk.docnm_kwd}
</div>
}
onCancel={hideModal}
open={visible}
showfooter={false}
>
<DocumentPreview
className={'!h-[calc(100dvh-300px)] overflow-auto'}
fileType={fileType}
highlights={highlights}
setWidthAndHeight={setWidthAndHeight}
url={url}
></DocumentPreview>
</Modal>
);
};

export default PdfDrawer;

+ 48
- 0
web/src/pages/next-search/highlight-markdown/index.tsx 查看文件

@@ -0,0 +1,48 @@
import Markdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';

import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you

import { preprocessLaTeX } from '@/utils/chat';

const HightLightMarkdown = ({
children,
}: {
children: string | null | undefined;
}) => {
return (
<Markdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeRaw, rehypeKatex]}
className="text-text-primary text-sm"
components={
{
code(props: any) {
const { children, className, ...rest } = props;
const match = /language-(\w+)/.exec(className || '');
return match ? (
<SyntaxHighlighter {...rest} PreTag="div" language={match[1]}>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code
{...rest}
className={`${className} pt-1 px-2 pb-2 m-0 whitespace-break-spaces rounded text-text-primary text-sm`}
>
{children}
</code>
);
},
} as any
}
>
{children ? preprocessLaTeX(children) : children}
</Markdown>
);
};

export default HightLightMarkdown;

+ 8
- 0
web/src/pages/next-search/index.less 查看文件

@@ -106,3 +106,11 @@
.delay-700 {
animation-delay: 0.7s;
}

.highlightContent {
.multipleLineEllipsis(2);
em {
color: red;
font-style: normal;
}
}

+ 33
- 22
web/src/pages/next-search/index.tsx 查看文件

@@ -10,7 +10,7 @@ import {
import { Button } from '@/components/ui/button';
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks';
import { Settings } from 'lucide-react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
ISearchAppDetailProps,
useFetchSearchDetail,
@@ -26,6 +26,13 @@ export default function SearchPage() {
const { data: SearchData } = useFetchSearchDetail();

const [openSetting, setOpenSetting] = useState(false);
const [searchText, setSearchText] = useState('');
useEffect(() => {
if (isSearching) {
setOpenSetting(false);
}
}, [isSearching]);

return (
<section>
<PageHeader>
@@ -50,6 +57,8 @@ export default function SearchPage() {
<SearchHome
setIsSearching={setIsSearching}
isSearching={isSearching}
searchText={searchText}
setSearchText={setSearchText}
/>
</div>
)}
@@ -57,33 +66,35 @@ export default function SearchPage() {
<div className="animate-fade-in-up">
<SearchingPage
setIsSearching={setIsSearching}
isSearching={isSearching}
searchText={searchText}
setSearchText={setSearchText}
data={SearchData as ISearchAppDetailProps}
/>
</div>
)}
</div>
{/* {openSetting && (
<div className=" w-[440px]"> */}
<SearchSetting
className="mt-20 mr-2"
open={openSetting}
setOpen={setOpenSetting}
data={SearchData as ISearchAppDetailProps}
/>
{/* </div>
)} */}
{openSetting && (
<SearchSetting
className="mt-20 mr-2"
open={openSetting}
setOpen={setOpenSetting}
data={SearchData as ISearchAppDetailProps}
/>
)}
</div>

<div className="absolute left-5 bottom-12 ">
<Button
variant="transparent"
className="bg-bg-card"
onClick={() => setOpenSetting(!openSetting)}
>
<Settings className="text-text-secondary" />
<div className="text-text-secondary">Search Settings</div>
</Button>
</div>
{!isSearching && (
<div className="absolute left-5 bottom-12 ">
<Button
variant="transparent"
className="bg-bg-card"
onClick={() => setOpenSetting(!openSetting)}
>
<Settings className="text-text-secondary" />
<div className="text-text-secondary">Search Settings</div>
</Button>
</div>
)}
</section>
);
}

+ 295
- 0
web/src/pages/next-search/markdown-content/index.tsx 查看文件

@@ -0,0 +1,295 @@
import Image from '@/components/image';
import SvgIcon from '@/components/svg-icon';
import { IReference, IReferenceChunk } from '@/interfaces/database/chat';
import { getExtension } from '@/utils/document-util';
import { InfoCircleOutlined } from '@ant-design/icons';
import DOMPurify from 'dompurify';
import { useCallback, useEffect, useMemo } from 'react';
import Markdown from 'react-markdown';
import reactStringReplace from 'react-string-replace';
import SyntaxHighlighter from 'react-syntax-highlighter';
import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { visitParents } from 'unist-util-visit-parents';

import { useFetchDocumentThumbnailsByIds } from '@/hooks/document-hooks';
import { useTranslation } from 'react-i18next';

import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you

import {
preprocessLaTeX,
replaceThinkToSection,
showImage,
} from '@/utils/chat';

import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { currentReg, replaceTextByOldReg } from '@/pages/next-chats/utils';
import classNames from 'classnames';
import { omit } from 'lodash';
import { pipe } from 'lodash/fp';

const getChunkIndex = (match: string) => Number(match);

// Defining Tailwind CSS class name constants
const styles = {
referenceChunkImage: 'w-[10vw] object-contain',
referenceInnerChunkImage: 'block object-contain max-w-full max-h-[6vh]',
referenceImagePreview: 'max-w-[45vw] max-h-[45vh]',
chunkContentText: 'max-h-[45vh] overflow-y-auto',
documentLink: 'p-0',
referenceIcon: 'px-[6px]',
fileThumbnail: 'inline-block max-w-[40px]',
};

// TODO: The display of the table is inconsistent with the display previously placed in the MessageItem.
const MarkdownContent = ({
reference,
clickDocumentButton,
content,
}: {
content: string;
loading: boolean;
reference: IReference;
clickDocumentButton?: (documentId: string, chunk: IReferenceChunk) => void;
}) => {
const { t } = useTranslation();
const { setDocumentIds, data: fileThumbnails } =
useFetchDocumentThumbnailsByIds();
const contentWithCursor = useMemo(() => {
// let text = DOMPurify.sanitize(content);
let text = content;
if (text === '') {
text = t('chat.searching');
}
const nextText = replaceTextByOldReg(text);
return pipe(replaceThinkToSection, preprocessLaTeX)(nextText);
}, [content, t]);

useEffect(() => {
const docAggs = reference?.doc_aggs;
setDocumentIds(Array.isArray(docAggs) ? docAggs.map((x) => x.doc_id) : []);
}, [reference, setDocumentIds]);

const handleDocumentButtonClick = useCallback(
(
documentId: string,
chunk: IReferenceChunk,
isPdf: boolean,
documentUrl?: string,
) =>
() => {
if (!isPdf) {
if (!documentUrl) {
return;
}
window.open(documentUrl, '_blank');
} else {
clickDocumentButton?.(documentId, chunk);
}
},
[clickDocumentButton],
);

const rehypeWrapReference = () => {
return function wrapTextTransform(tree: any) {
visitParents(tree, 'text', (node, ancestors) => {
const latestAncestor = ancestors.at(-1);
if (
latestAncestor.tagName !== 'custom-typography' &&
latestAncestor.tagName !== 'code'
) {
node.type = 'element';
node.tagName = 'custom-typography';
node.properties = {};
node.children = [{ type: 'text', value: node.value }];
}
});
};
};

const getReferenceInfo = useCallback(
(chunkIndex: number) => {
const chunks = reference?.chunks ?? [];
const chunkItem = chunks[chunkIndex];
const document = reference?.doc_aggs?.find(
(x) => x?.doc_id === chunkItem?.document_id,
);
const documentId = document?.doc_id;
const documentUrl = document?.url;
const fileThumbnail = documentId ? fileThumbnails[documentId] : '';
const fileExtension = documentId ? getExtension(document?.doc_name) : '';
const imageId = chunkItem?.image_id;

return {
documentUrl,
fileThumbnail,
fileExtension,
imageId,
chunkItem,
documentId,
document,
};
},
[fileThumbnails, reference],
);

const getPopoverContent = useCallback(
(chunkIndex: number) => {
const {
documentUrl,
fileThumbnail,
fileExtension,
imageId,
chunkItem,
documentId,
document,
} = getReferenceInfo(chunkIndex);

return (
<div key={chunkItem?.id} className="flex gap-2">
{imageId && (
<Popover>
<PopoverTrigger>
<Image
id={imageId}
className={styles.referenceChunkImage}
></Image>
</PopoverTrigger>
<PopoverContent>
<Image
id={imageId}
className={styles.referenceImagePreview}
></Image>
</PopoverContent>
</Popover>
)}
<div className={'space-y-2 max-w-[40vw]'}>
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(chunkItem?.content ?? ''),
}}
className={classNames(styles.chunkContentText)}
></div>
{documentId && (
<div className="flex gap-2">
{fileThumbnail ? (
<img
src={fileThumbnail}
alt=""
className={styles.fileThumbnail}
/>
) : (
<SvgIcon
name={`file-icon/${fileExtension}`}
width={24}
></SvgIcon>
)}
<Button
variant="link"
className={classNames(styles.documentLink, 'text-wrap')}
onClick={handleDocumentButtonClick(
documentId,
chunkItem,
fileExtension === 'pdf',
documentUrl,
)}
>
{document?.doc_name}
</Button>
</div>
)}
</div>
</div>
);
},
[getReferenceInfo, handleDocumentButtonClick],
);

const renderReference = useCallback(
(text: string) => {
let replacedText = reactStringReplace(text, currentReg, (match, i) => {
const chunkIndex = getChunkIndex(match);

const { documentUrl, fileExtension, imageId, chunkItem, documentId } =
getReferenceInfo(chunkIndex);

const docType = chunkItem?.doc_type;

return showImage(docType) ? (
<Image
id={imageId}
className={styles.referenceInnerChunkImage}
onClick={
documentId
? handleDocumentButtonClick(
documentId,
chunkItem,
fileExtension === 'pdf',
documentUrl,
)
: () => {}
}
></Image>
) : (
<Popover>
<PopoverTrigger>
<InfoCircleOutlined className={styles.referenceIcon} />
</PopoverTrigger>
<PopoverContent>{getPopoverContent(chunkIndex)}</PopoverContent>
</Popover>
);
});

return replacedText;
},
[getPopoverContent, getReferenceInfo, handleDocumentButtonClick],
);

return (
<Markdown
rehypePlugins={[rehypeWrapReference, rehypeKatex, rehypeRaw]}
remarkPlugins={[remarkGfm, remarkMath]}
className="[&>section.think]:pl-[10px] [&>section.think]:text-[#8b8b8b] [&>section.think]:border-l-2 [&>section.think]:border-l-[#d5d3d3] [&>section.think]:mb-[10px] [&>section.think]:text-xs [&>blockquote]:pl-[10px] [&>blockquote]:border-l-4 [&>blockquote]:border-l-[#ccc] text-sm"
components={
{
'custom-typography': ({ children }: { children: string }) =>
renderReference(children),
code(props: any) {
const { children, className, ...rest } = props;
const restProps = omit(rest, 'node');
const match = /language-(\w+)/.exec(className || '');
return match ? (
<SyntaxHighlighter
{...restProps}
PreTag="div"
language={match[1]}
wrapLongLines
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code
{...restProps}
className={classNames(className, 'text-wrap')}
>
{children}
</code>
);
},
} as any
}
>
{contentWithCursor}
</Markdown>
);
};

export default MarkdownContent;

+ 47
- 0
web/src/pages/next-search/mindmap-drawer.tsx 查看文件

@@ -0,0 +1,47 @@
import IndentedTree from '@/components/indented-tree/indented-tree';
import { Progress } from '@/components/ui/progress';
import { IModalProps } from '@/interfaces/common';
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePendingMindMap } from '../search/hooks';

interface IProps extends IModalProps<any> {
data: any;
}

const MindMapDrawer = ({ data, hideModal, visible, loading }: IProps) => {
const { t } = useTranslation();
const percent = usePendingMindMap();
return (
<div className="w-[400px] h-[420px]">
<div className="flex w-full justify-between items-center mb-2">
<div className="text-text-primary font-medium text-base">
{t('chunk.mind')}
</div>
<X
className="text-text-primary cursor-pointer"
size={16}
onClick={() => {
hideModal?.();
}}
/>
</div>
{loading && (
<div className="absolute top-48">
<Progress value={percent} className="h-1 flex-1 min-w-10" />
</div>
)}
{!loading && (
<div className="bg-bg-card rounded-lg p-4 w-[400px] h-[380px]">
<IndentedTree
data={data}
show
style={{ width: '100%', height: '100%' }}
></IndentedTree>
</div>
)}
</div>
);
};

export default MindMapDrawer;

+ 11
- 0
web/src/pages/next-search/retrieval-documents/index.less 查看文件

@@ -0,0 +1,11 @@
.selectFilesCollapse {
:global(.ant-collapse-header) {
padding-left: 22px;
}
margin-bottom: 32px;
overflow-y: auto;
}

.selectFilesTitle {
padding-right: 10px;
}

+ 237
- 0
web/src/pages/next-search/retrieval-documents/index.tsx 查看文件

@@ -0,0 +1,237 @@
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import { MultiSelectOptionType } from '@/components/ui/multi-select';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
useAllTestingResult,
useSelectTestingResult,
} from '@/hooks/knowledge-hooks';
import { cn } from '@/lib/utils';
import { Separator } from '@radix-ui/react-select';
import { CheckIcon, ChevronDown, Files, XIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';

interface IProps {
onTesting(documentIds: string[]): void;
setSelectedDocumentIds(documentIds: string[]): void;
selectedDocumentIds: string[];
}

const RetrievalDocuments = ({
onTesting,
selectedDocumentIds,
setSelectedDocumentIds,
}: IProps) => {
const { t } = useTranslation();
const { documents: documentsAll } = useAllTestingResult();
const { documents } = useSelectTestingResult();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const maxCount = 3;
const { documents: useDocuments } = {
documents:
documentsAll?.length > documents?.length ? documentsAll : documents,
};
const [selectedValues, setSelectedValues] =
useState<string[]>(selectedDocumentIds);

const multiOptions = useMemo(() => {
return useDocuments?.map((item) => {
return {
label: item.doc_name,
value: item.doc_id,
disabled: item.doc_name === 'Disabled User',
// suffix: (
// <div className="flex justify-between gap-3 ">
// <div>{item.count}</div>
// <div>
// <Eye />
// </div>
// </div>
// ),
};
});
}, [useDocuments]);

const handleTogglePopover = () => {
setIsPopoverOpen((prev) => !prev);
};

const onValueChange = (value: string[]) => {
console.log(value);
onTesting(value);
setSelectedDocumentIds(value);
// handleDatasetSelectChange(value, field.onChange);
};
const handleClear = () => {
setSelectedValues([]);
onValueChange([]);
};

const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
setIsPopoverOpen(true);
} else if (event.key === 'Backspace' && !event.currentTarget.value) {
const newSelectedValues = [...selectedValues];
newSelectedValues.pop();
setSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
}
};
const toggleOption = (option: string) => {
const newSelectedValues = selectedValues.includes(option)
? selectedValues.filter((value) => value !== option)
: [...selectedValues, option];
setSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
};
return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button
onClick={handleTogglePopover}
className={cn(
'flex w-full p-1 rounded-md text-base text-text-primary border min-h-10 h-auto items-center justify-between bg-inherit hover:bg-inherit [&_svg]:pointer-events-auto',
)}
>
<div className="flex justify-between items-center w-full">
<div className="flex flex-wrap items-center gap-2">
<Files />
<span>
{selectedDocumentIds?.length ?? 0}/{useDocuments?.length ?? 0}
</span>
Files
</div>
<div className="flex items-center justify-between">
<XIcon
className="h-4 mx-2 cursor-pointer text-muted-foreground"
onClick={(event) => {
event.stopPropagation();
handleClear();
}}
/>
<Separator
orientation="vertical"
className="flex min-h-6 h-full"
/>
<ChevronDown className="h-4 mx-2 cursor-pointer text-muted-foreground" />
</div>
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-0"
align="start"
onEscapeKeyDown={() => setIsPopoverOpen(false)}
>
<Command>
<CommandInput
placeholder="Search..."
onKeyDown={handleInputKeyDown}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{!multiOptions.some((x) => 'options' in x) &&
(multiOptions as unknown as MultiSelectOptionType[]).map(
(option) => {
const isSelected = selectedValues.includes(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => {
if (option.disabled) return false;
toggleOption(option.value);
}}
className={cn('cursor-pointer', {
'cursor-not-allowed text-text-disabled':
option.disabled,
})}
>
<div
className={cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
isSelected
? 'bg-primary '
: 'opacity-50 [&_svg]:invisible',

{ 'text-primary-foreground': !option.disabled },
{ 'text-text-disabled': option.disabled },
)}
>
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon
className={cn('mr-2 h-4 w-4 ', {
'text-text-disabled': option.disabled,
'text-muted-foreground': !option.disabled,
})}
/>
)}
<span
className={cn({
'text-text-disabled': option.disabled,
})}
>
{option.label}
</span>
{option.suffix && (
<span
className={cn({
'text-text-disabled': option.disabled,
})}
>
{option.suffix}
</span>
)}
</CommandItem>
);
},
)}
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<div className="flex items-center justify-between">
{selectedValues.length > 0 && (
<>
<CommandItem
onSelect={handleClear}
className="flex-1 justify-center cursor-pointer"
>
Clear
</CommandItem>
<Separator
orientation="vertical"
className="flex min-h-6 h-full"
/>
</>
)}
<CommandItem
onSelect={() => setIsPopoverOpen(false)}
className="flex-1 justify-center cursor-pointer max-w-full"
>
Close
</CommandItem>
</div>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};

export default RetrievalDocuments;

+ 19
- 2
web/src/pages/next-search/search-home.tsx 查看文件

@@ -1,5 +1,6 @@
import { Input } from '@/components/originui/input';
import { Button } from '@/components/ui/button';
import { useFetchUserInfo } from '@/hooks/user-setting-hooks';
import { cn } from '@/lib/utils';
import { Search } from 'lucide-react';
import { Dispatch, SetStateAction } from 'react';
@@ -9,10 +10,15 @@ import Spotlight from './spotlight';
export default function SearchPage({
isSearching,
setIsSearching,
searchText,
setSearchText,
}: {
isSearching: boolean;
setIsSearching: Dispatch<SetStateAction<boolean>>;
searchText: string;
setSearchText: Dispatch<SetStateAction<string>>;
}) {
const { data: userInfo } = useFetchUserInfo();
return (
<section className="relative w-full flex transition-all justify-center items-center mt-32">
<div className="relative z-10 px-8 pt-8 flex text-transparent flex-col justify-center items-center w-[780px]">
@@ -30,14 +36,25 @@ export default function SearchPage({
{!isSearching && (
<>
<p className="mb-4 transition-opacity">👋 Hi there</p>
<p className="mb-10 transition-opacity">Welcome back, KiKi</p>
<p className="mb-10 transition-opacity">
Welcome back, {userInfo?.nickname}
</p>
</>
)}

<div className="relative w-full ">
<Input
placeholder="How can I help you today?"
className="w-full rounded-full py-6 px-4 pr-10 text-white text-lg bg-background delay-700"
className="w-full rounded-full py-6 px-4 pr-10 text-text-primary text-lg bg-bg-base delay-700"
value={searchText}
onKeyUp={(e) => {
if (e.key === 'Enter') {
setIsSearching(!isSearching);
}
}}
onChange={(e) => {
setSearchText(e.target.value || '');
}}
/>
<button
type="button"

+ 9
- 7
web/src/pages/next-search/search-setting-aisummery-config.tsx 查看文件

@@ -33,15 +33,16 @@ interface LlmSettingFieldItemsProps {

export const LlmSettingSchema = {
llm_id: z.string(),
parameter: z.string(),
temperature: z.coerce.number(),
top_p: z.string(),
top_p: z.coerce.number(),
presence_penalty: z.coerce.number(),
frequency_penalty: z.coerce.number(),
temperatureEnabled: z.boolean(),
topPEnabled: z.boolean(),
presencePenaltyEnabled: z.boolean(),
frequencyPenaltyEnabled: z.boolean(),
maxTokensEnabled: z.boolean(),
// maxTokensEnabled: z.boolean(),
};

export function LlmSettingFieldItems({
@@ -58,7 +59,8 @@ export function LlmSettingFieldItems({

const handleChange = useCallback(
(parameter: string) => {
// const currentValues = { ...form.getValues() };
const currentValues = { ...form.getValues() };
console.log('currentValues', currentValues);
const values =
settledModelVariableMap[
parameter as keyof typeof settledModelVariableMap
@@ -145,28 +147,28 @@ export function LlmSettingFieldItems({
/>
<SliderInputSwitchFormField
name={getFieldWithPrefix('temperature')}
checkName="temperatureEnabled"
checkName={getFieldWithPrefix('temperatureEnabled')}
label="temperature"
max={1}
step={0.01}
></SliderInputSwitchFormField>
<SliderInputSwitchFormField
name={getFieldWithPrefix('top_p')}
checkName="topPEnabled"
checkName={getFieldWithPrefix('topPEnabled')}
label="topP"
max={1}
step={0.01}
></SliderInputSwitchFormField>
<SliderInputSwitchFormField
name={getFieldWithPrefix('presence_penalty')}
checkName="presencePenaltyEnabled"
checkName={getFieldWithPrefix('presencePenaltyEnabled')}
label="presencePenalty"
max={1}
step={0.01}
></SliderInputSwitchFormField>
<SliderInputSwitchFormField
name={getFieldWithPrefix('frequency_penalty')}
checkName="frequencyPenaltyEnabled"
checkName={getFieldWithPrefix('frequencyPenaltyEnabled')}
label="frequencyPenalty"
max={1}
step={0.01}

+ 97
- 35
web/src/pages/next-search/search-setting.tsx 查看文件

@@ -30,17 +30,24 @@ import { cn } from '@/lib/utils';
import { transformFile2Base64 } from '@/utils/file-util';
import { zodResolver } from '@hookform/resolvers/zod';
import { t } from 'i18next';
import { PanelRightClose, Pencil, Upload } from 'lucide-react';
import { Pencil, Upload, X } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { LlmModelType, ModelVariableType } from '../dataset/dataset/constant';
import {
LlmModelType,
ModelVariableType,
settledModelVariableMap,
} from '../dataset/dataset/constant';
import {
ISearchAppDetailProps,
IUpdateSearchProps,
useUpdateSearch,
} from '../next-searches/hooks';
import { LlmSettingFieldItems } from './search-setting-aisummery-config';
import {
LlmSettingFieldItems,
LlmSettingSchema,
} from './search-setting-aisummery-config';

interface SearchSettingProps {
open: boolean;
@@ -48,6 +55,15 @@ interface SearchSettingProps {
className?: string;
data: ISearchAppDetailProps;
}
interface ISubmitLlmSettingProps {
llm_id: string;
parameter: string;
temperature?: number;
top_p?: number;
frequency_penalty?: number;
presence_penalty?: number;
}

const SearchSettingFormSchema = z
.object({
search_id: z.string().optional(),
@@ -64,14 +80,7 @@ const SearchSettingFormSchema = z
use_rerank: z.boolean(),
top_k: z.number(),
summary: z.boolean(),
llm_setting: z.object({
llm_id: z.string(),
parameter: z.string(),
temperature: z.number(),
top_p: z.union([z.string(), z.number()]),
frequency_penalty: z.number(),
presence_penalty: z.number(),
}),
llm_setting: z.object(LlmSettingSchema),
related_search: z.boolean(),
query_mindmap: z.boolean(),
}),
@@ -133,10 +142,26 @@ const SearchSetting: React.FC<SearchSettingProps> = ({
llm_setting: {
llm_id: llm_setting?.llm_id || '',
parameter: llm_setting?.parameter || ModelVariableType.Improvise,
temperature: llm_setting?.temperature || 0.8,
top_p: llm_setting?.top_p || 0.9,
frequency_penalty: llm_setting?.frequency_penalty || 0.1,
presence_penalty: llm_setting?.presence_penalty || 0.1,
temperature:
llm_setting?.temperature ||
settledModelVariableMap[ModelVariableType.Improvise].temperature,
top_p:
llm_setting?.top_p ||
settledModelVariableMap[ModelVariableType.Improvise].top_p,
frequency_penalty:
llm_setting?.frequency_penalty ||
settledModelVariableMap[ModelVariableType.Improvise]
.frequency_penalty,
presence_penalty:
llm_setting?.presence_penalty ||
settledModelVariableMap[ModelVariableType.Improvise]
.presence_penalty,
temperatureEnabled: llm_setting?.temperature ? true : false,
topPEnabled: llm_setting?.top_p ? true : false,
presencePenaltyEnabled: llm_setting?.presence_penalty ? true : false,
frequencyPenaltyEnabled: llm_setting?.frequency_penalty
? true
: false,
},
chat_settingcross_languages: [],
highlight: false,
@@ -193,7 +218,10 @@ const SearchSetting: React.FC<SearchSettingProps> = ({
setDatasetList(datasetListMap);
}, [datasetListOrigin, datasetSelectEmbdId]);

const handleDatasetSelectChange = (value, onChange) => {
const handleDatasetSelectChange = (
value: string[],
onChange: (value: string[]) => void,
) => {
console.log(value);
if (value.length) {
const data = datasetListOrigin?.find((item) => item.id === value[0]);
@@ -224,18 +252,44 @@ const SearchSetting: React.FC<SearchSettingProps> = ({
name: 'search_config.summary',
});

const { updateSearch, isLoading: isUpdating } = useUpdateSearch();
const { updateSearch } = useUpdateSearch();
const { data: systemSetting } = useFetchTenantInfo();
const onSubmit = async (
formData: IUpdateSearchProps & { tenant_id: string },
) => {
try {
const { search_config, ...other_formdata } = formData;
const { llm_setting, ...other_config } = search_config;
const llmSetting = {
llm_id: llm_setting.llm_id,
parameter: llm_setting.parameter,
temperature: llm_setting.temperature,
top_p: llm_setting.top_p,
frequency_penalty: llm_setting.frequency_penalty,
presence_penalty: llm_setting.presence_penalty,
} as ISubmitLlmSettingProps;
if (!llm_setting.frequencyPenaltyEnabled) {
delete llmSetting.frequency_penalty;
}
if (!llm_setting.presencePenaltyEnabled) {
delete llmSetting.presence_penalty;
}
if (!llm_setting.temperatureEnabled) {
delete llmSetting.temperature;
}
if (!llm_setting.topPEnabled) {
delete llmSetting.top_p;
}
await updateSearch({
...formData,
...other_formdata,
search_config: {
...other_config,
llm_setting: { ...llmSetting },
},
tenant_id: systemSetting.tenant_id,
avatar: avatarBase64Str,
});
setOpen(false); // 关闭弹窗
setOpen(false);
} catch (error) {
console.error('Failed to update search:', error);
}
@@ -256,10 +310,7 @@ const SearchSetting: React.FC<SearchSettingProps> = ({
<div className="flex justify-between items-center text-base mb-8">
<div className="text-text-primary">Search Settings</div>
<div onClick={() => setOpen(false)}>
<PanelRightClose
size={16}
className="text-text-primary cursor-pointer"
/>
<X size={16} className="text-text-primary cursor-pointer" />
</div>
</div>
<div
@@ -271,7 +322,7 @@ const SearchSetting: React.FC<SearchSettingProps> = ({
onSubmit={formMethods.handleSubmit(
(data) => {
console.log('Form submitted with data:', data);
onSubmit(data as IUpdateSearchProps);
onSubmit(data as unknown as IUpdateSearchProps);
},
(errors) => {
console.log('Validation errors:', errors);
@@ -462,26 +513,37 @@ const SearchSetting: React.FC<SearchSettingProps> = ({
</FormItem>
)}
/>

<FormField
control={formMethods.control}
name="search_config.top_k"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormItem>
<FormLabel>Top K</FormLabel>
<FormControl>
<div className="flex justify-between items-center">
<div
className={cn(
'flex items-center gap-4 justify-between',
className,
)}
>
<FormControl>
<SingleFormSlider
{...field}
max={100}
min={0}
step={1}
value={field.value as number}
onChange={(values) => field.onChange(values)}
></SingleFormSlider>
<Label className="w-10 h-6 bg-bg-card flex justify-center items-center rounded-lg ml-20">
{field.value}
</Label>
</div>
</FormControl>
</FormControl>
<FormControl>
<Input
type={'number'}
className="h-7 w-20 bg-bg-card"
max={100}
min={0}
step={1}
{...field}
></Input>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}

+ 278
- 10
web/src/pages/next-search/searching.tsx 查看文件

@@ -1,22 +1,107 @@
import { FileIcon } from '@/components/icon-font';
import { ImageWithPopover } from '@/components/image';
import { Input } from '@/components/originui/input';
import { useClickDrawer } from '@/components/pdf-drawer/hooks';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { RAGFlowPagination } from '@/components/ui/ragflow-pagination';
import { Skeleton } from '@/components/ui/skeleton';
import { Spin } from '@/components/ui/spin';
import { useSelectTestingResult } from '@/hooks/knowledge-hooks';
import { useGetPaginationWithRouter } from '@/hooks/logic-hooks';
import { IReference } from '@/interfaces/database/chat';
import { cn } from '@/lib/utils';
import { Search, X } from 'lucide-react';
import { Dispatch, SetStateAction } from 'react';
import DOMPurify from 'dompurify';
import { t } from 'i18next';
import { isEmpty } from 'lodash';
import { BrainCircuit, Search, Square, Tag, X } from 'lucide-react';
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useRef,
} from 'react';
import { ISearchAppDetailProps } from '../next-searches/hooks';
import { useSendQuestion, useShowMindMapDrawer } from '../search/hooks';
import PdfDrawer from './document-preview-modal';
import HightLightMarkdown from './highlight-markdown';
import './index.less';

import styles from './index.less';
import MarkdownContent from './markdown-content';
import MindMapDrawer from './mindmap-drawer';
import RetrievalDocuments from './retrieval-documents';
export default function SearchingPage({
isSearching,
searchText,
data: searchData,
setIsSearching,
}: {
isSearching: boolean;
searchText: string;
setIsSearching: Dispatch<SetStateAction<boolean>>;
setSearchText: Dispatch<SetStateAction<string>>;
data: ISearchAppDetailProps;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const {
sendQuestion,
handleClickRelatedQuestion,
handleSearchStrChange,
handleTestChunk,
setSelectedDocumentIds,
answer,
sendingLoading,
relatedQuestions,
searchStr,
loading,
isFirstRender,
selectedDocumentIds,
isSearchStrEmpty,
setSearchStr,
stopOutputMessage,
} = useSendQuestion(searchData.search_config.kb_ids);
const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } =
useClickDrawer();

useEffect(() => {
if (searchText) {
setSearchStr(searchText);
sendQuestion(searchText);
}
// regain focus
if (inputRef.current) {
inputRef.current.focus();
}
}, [searchText, sendQuestion, setSearchStr]);

const {
mindMapVisible,
hideMindMapModal,
showMindMapModal,
mindMapLoading,
mindMap,
} = useShowMindMapDrawer(searchData.search_config.kb_ids, searchStr);
const { chunks, total } = useSelectTestingResult();
const handleSearch = useCallback(() => {
sendQuestion(searchStr);
}, [searchStr, sendQuestion]);

const { pagination, setPagination } = useGetPaginationWithRouter();
const onChange = (pageNumber: number, pageSize: number) => {
setPagination({ page: pageNumber, pageSize });
handleTestChunk(selectedDocumentIds, pageNumber, pageSize);
};

return (
<section
className={cn(
'relative w-full flex transition-all justify-start items-center',
)}
>
{/* search header */}
<div
className={cn(
'relative z-10 px-8 pt-8 flex text-transparent justify-start items-start w-full',
@@ -24,41 +109,224 @@ export default function SearchingPage({
>
<h1
className={cn(
'text-4xl font-bold bg-gradient-to-r from-sky-600 from-30% via-sky-500 via-60% to-emerald-500 bg-clip-text',
'text-4xl font-bold bg-gradient-to-r from-sky-600 from-30% via-sky-500 via-60% to-emerald-500 bg-clip-text cursor-pointer',
)}
onClick={() => {
setIsSearching(false);
}}
>
RAGFlow
</h1>

<div
className={cn(
' rounded-lg text-primary text-xl sticky flex justify-center w-2/3 max-w-[780px] transform scale-100 ml-16 ',
' rounded-lg text-primary text-xl sticky flex flex-col justify-center w-2/3 max-w-[780px] transform scale-100 ml-16 ',
)}
>
<div className={cn('flex flex-col justify-start items-start w-full')}>
<div className="relative w-full text-primary">
<Input
ref={inputRef}
key="search-input"
placeholder="How can I help you today?"
className={cn(
'w-full rounded-full py-6 pl-4 !pr-[8rem] text-primary text-lg bg-background',
)}
value={searchStr}
onChange={handleSearchStrChange}
disabled={sendingLoading}
onKeyUp={(e) => {
if (e.key === 'Enter') {
handleSearch();
}
}}
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 transform flex items-center gap-1">
<X />|
<X
className="text-text-secondary"
size={14}
onClick={() => {
handleClickRelatedQuestion('');
}}
/>
<span className="text-text-secondary">|</span>
<button
type="button"
className="rounded-full bg-white p-1 text-gray-800 shadow w-12 h-8 ml-4"
onClick={() => {
setIsSearching(!isSearching);
if (sendingLoading) {
stopOutputMessage();
} else {
handleSearch();
}
}}
>
<Search size={22} className="m-auto" />
{sendingLoading ? (
<Square size={22} className="m-auto" />
) : (
<Search size={22} className="m-auto" />
)}
</button>
</div>
</div>
</div>

{/* search body */}
<div
className="w-full mt-5 overflow-auto scrollbar-none "
style={{ height: 'calc(100vh - 250px)' }}
>
{searchData.search_config.summary && (
<>
<div className="flex justify-start items-start text-text-primary text-2xl">
AI Summary
</div>
{isEmpty(answer) && sendingLoading ? (
<div className="space-y-2">
<Skeleton className="h-4 w-full bg-bg-card" />
<Skeleton className="h-4 w-full bg-bg-card" />
<Skeleton className="h-4 w-2/3 bg-bg-card" />
</div>
) : (
answer.answer && (
<div className="border rounded-lg p-4 mt-3 max-h-52 overflow-auto scrollbar-none">
<MarkdownContent
loading={sendingLoading}
content={answer.answer}
reference={answer.reference ?? ({} as IReference)}
clickDocumentButton={clickDocumentButton}
></MarkdownContent>
</div>
)
)}
</>
)}

<div className="w-full border-b border-border-default/80 my-6"></div>
{/* retrieval documents */}
<div className=" mt-3 w-44 ">
<RetrievalDocuments
selectedDocumentIds={selectedDocumentIds}
setSelectedDocumentIds={setSelectedDocumentIds}
onTesting={handleTestChunk}
></RetrievalDocuments>
</div>
<div className="w-full border-b border-border-default/80 my-6"></div>
<div className="mt-3 ">
<Spin spinning={loading}>
{chunks?.length > 0 && (
<>
{chunks.map((chunk, index) => {
return (
<>
<div
key={chunk.chunk_id}
className="w-full flex flex-col"
>
<div className="w-full">
<ImageWithPopover
id={chunk.img_id}
></ImageWithPopover>
<Popover>
<PopoverTrigger asChild>
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(
`${chunk.highlight}...`,
),
}}
className="text-sm text-text-primary mb-1"
></div>
</PopoverTrigger>
<PopoverContent className="text-text-primary">
<HightLightMarkdown>
{chunk.content_with_weight}
</HightLightMarkdown>
</PopoverContent>
</Popover>
</div>
<div
className="flex gap-2 items-center text-xs text-text-secondary border p-1 rounded-lg w-fit"
onClick={() =>
clickDocumentButton(chunk.doc_id, chunk as any)
}
>
<FileIcon name={chunk.docnm_kwd}></FileIcon>
{chunk.docnm_kwd}
</div>
</div>
{index < chunks.length - 1 && (
<div className="w-full border-b border-border-default/80 mt-6"></div>
)}
</>
);
})}
</>
)}
</Spin>
{relatedQuestions?.length > 0 && (
<div title={t('chat.relatedQuestion')}>
<div className="flex gap-2">
{relatedQuestions?.map((x, idx) => (
<Tag
key={idx}
className={styles.tag}
onClick={handleClickRelatedQuestion(x)}
>
{x}
</Tag>
))}
</div>
</div>
)}
</div>
</div>
<div className="mt-8 px-8 pb-8">
<RAGFlowPagination
current={pagination.current}
pageSize={pagination.pageSize}
total={total}
onChange={onChange}
></RAGFlowPagination>
</div>
</div>
</div>
{!mindMapVisible &&
!isFirstRender &&
!isSearchStrEmpty &&
!isEmpty(searchData.search_config.kb_ids) && (
<Popover>
<PopoverTrigger asChild>
<Button
className="rounded-lg h-8 w-8 p-0 absolute top-28 right-3 z-30"
variant={'transparent'}
onClick={showMindMapModal}
>
{/* <SvgIcon name="paper-clip" width={24} height={30}></SvgIcon> */}
<BrainCircuit size={24} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-fit">{t('chunk.mind')}</PopoverContent>
</Popover>
)}
{visible && (
<PdfDrawer
visible={visible}
hideModal={hideModal}
documentId={documentId}
chunk={selectedChunk}
></PdfDrawer>
)}
{mindMapVisible && (
<div className="absolute top-20 right-16 z-30">
<MindMapDrawer
visible={mindMapVisible}
hideModal={hideMindMapModal}
data={mindMap}
loading={mindMapLoading}
></MindMapDrawer>
</div>
)}
</section>
);
}

+ 6
- 2
web/src/pages/next-search/spotlight.tsx 查看文件

@@ -1,3 +1,4 @@
import { useIsDarkTheme } from '@/components/theme-provider';
import React from 'react';

interface SpotlightProps {
@@ -5,6 +6,8 @@ interface SpotlightProps {
}

const Spotlight: React.FC<SpotlightProps> = ({ className }) => {
const isDark = useIsDarkTheme();
console.log('isDark', isDark);
return (
<div
className={`absolute inset-0 opacity-80 ${className} rounded-lg`}
@@ -16,8 +19,9 @@ const Spotlight: React.FC<SpotlightProps> = ({ className }) => {
<div
className="absolute inset-0"
style={{
background:
'radial-gradient(circle at 50% 190%, #fff4 0%, #fff0 60%)',
background: isDark
? 'radial-gradient(circle at 50% 190%, #fff4 0%, #fff0 60%)'
: 'radial-gradient(circle at 50% 190%, #E4F3FF 0%, #E4F3FF00 60%)',
pointerEvents: 'none',
}}
></div>

+ 17
- 12
web/src/pages/next-searches/hooks.ts 查看文件

@@ -1,8 +1,8 @@
// src/pages/next-searches/hooks.ts

import message from '@/components/ui/message';
import searchService from '@/services/search-service';
import { useMutation, useQuery } from '@tanstack/react-query';
import { message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'umi';
@@ -23,7 +23,6 @@ export const useCreateSearch = () => {

const {
data,
isLoading,
isError,
mutateAsync: createSearchMutation,
} = useMutation<CreateSearchResponse, Error, CreateSearchProps>({
@@ -50,7 +49,7 @@ export const useCreateSearch = () => {
[createSearchMutation],
);

return { data, isLoading, isError, createSearch };
return { data, isError, createSearch };
};

export interface SearchListParams {
@@ -128,7 +127,6 @@ export const useDeleteSearch = () => {

const {
data,
isLoading,
isError,
mutateAsync: deleteSearchMutation,
} = useMutation<DeleteSearchResponse, Error, DeleteSearchProps>({
@@ -155,7 +153,7 @@ export const useDeleteSearch = () => {
[deleteSearchMutation],
);

return { data, isLoading, isError, deleteSearch };
return { data, isError, deleteSearch };
};

interface IllmSettingProps {
@@ -166,7 +164,12 @@ interface IllmSettingProps {
frequency_penalty: number;
presence_penalty: number;
}

interface IllmSettingEnableProps {
temperatureEnabled?: boolean;
topPEnabled?: boolean;
presencePenaltyEnabled?: boolean;
frequencyPenaltyEnabled?: boolean;
}
export interface ISearchAppDetailProps {
avatar: any;
created_by: string;
@@ -184,7 +187,7 @@ export interface ISearchAppDetailProps {
rerank_id: string;
similarity_threshold: number;
summary: boolean;
llm_setting: IllmSettingProps;
llm_setting: IllmSettingProps & IllmSettingEnableProps;
top_k: number;
use_kg: boolean;
vector_similarity_weight: number;
@@ -225,10 +228,9 @@ export type IUpdateSearchProps = Omit<ISearchAppDetailProps, 'id'> & {

export const useUpdateSearch = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const {
data,
isLoading,
isError,
mutateAsync: updateSearchMutation,
} = useMutation<any, Error, IUpdateSearchProps>({
@@ -241,8 +243,11 @@ export const useUpdateSearch = () => {
}
return response.data;
},
onSuccess: () => {
onSuccess: (data, variables) => {
message.success(t('message.updated'));
queryClient.invalidateQueries({
queryKey: ['searchDetail', variables.search_id],
});
},
onError: (error) => {
message.error(t('message.error', { error: error.message }));
@@ -256,5 +261,5 @@ export const useUpdateSearch = () => {
[updateSearchMutation],
);

return { data, isLoading, isError, updateSearch };
return { data, isError, updateSearch };
};

+ 6
- 5
web/src/pages/next-searches/index.tsx 查看文件

@@ -12,6 +12,7 @@ import {
import { Modal } from '@/components/ui/modal/modal';
import { RAGFlowPagination } from '@/components/ui/ragflow-pagination';
import { useTranslate } from '@/hooks/common-hooks';
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks';
import { zodResolver } from '@hookform/resolvers/zod';
import { pick } from 'lodash';
import { Plus, Search } from 'lucide-react';
@@ -30,6 +31,7 @@ type SearchFormValues = z.infer<typeof searchFormSchema>;
export default function SearchList() {
// const { data } = useFetchFlowList();
const { t } = useTranslate('search');
const { navigateToSearch } = useNavigatePage();
const { isLoading, createSearch } = useCreateSearch();
const {
data: list,
@@ -48,7 +50,10 @@ export default function SearchList() {
};

const onSubmit = async (values: SearchFormValues) => {
await createSearch({ name: values.name });
const res = await createSearch({ name: values.name });
if (res) {
navigateToSearch(res?.search_id);
}
if (!isLoading) {
setOpenCreateModal(false);
}
@@ -88,16 +93,12 @@ export default function SearchList() {
{list?.data.search_apps.map((x) => {
return <SearchCard key={x.id} data={x}></SearchCard>;
})}
{/* {data.map((x) => {
return <SearchCard key={x.id} data={x}></SearchCard>;
})} */}
</div>
{list?.data.total && (
<RAGFlowPagination
{...pick(searchParams, 'current', 'pageSize')}
total={list?.data.total}
onChange={handlePageChange}
on
/>
)}
<Modal

+ 1
- 0
web/src/pages/search/hooks.ts 查看文件

@@ -135,6 +135,7 @@ export const useSendQuestion = (kbIds: string[]) => {
answer: currentAnswer,
relatedQuestions: relatedQuestions?.slice(0, 5) ?? [],
searchStr,
setSearchStr,
isFirstRender,
selectedDocumentIds,
isSearchStrEmpty: isEmpty(trim(searchStr)),

Loading…
取消
儲存