### What problem does this PR solve? Feat: New search page components and features #3221 - Added search homepage, search settings, and ongoing search components - Implemented features such as search app list, creating search apps, and deleting search apps - Optimized the multi-select component, adding disabled state and suffix display - Adjusted navigation hooks to support search page navigation ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.20.2
| @@ -23,8 +23,8 @@ const getColorForName = (name: string): { from: string; to: string } => { | |||
| const hue = hash % 360; | |||
| return { | |||
| from: `hsl(${hue}, 70%, 80%)`, | |||
| to: `hsl(${hue}, 60%, 30%)`, | |||
| to: `hsl(${hue}, 70%, 80%)`, | |||
| from: `hsl(${hue}, 60%, 30%)`, | |||
| }; | |||
| }; | |||
| export const RAGFlowAvatar = memo( | |||
| @@ -34,6 +34,7 @@ export type MultiSelectOptionType = { | |||
| label: React.ReactNode; | |||
| value: string; | |||
| disabled?: boolean; | |||
| suffix?: React.ReactNode; | |||
| icon?: React.ComponentType<{ className?: string }>; | |||
| }; | |||
| @@ -54,23 +55,41 @@ function MultiCommandItem({ | |||
| return ( | |||
| <CommandItem | |||
| key={option.value} | |||
| onSelect={() => toggleOption(option.value)} | |||
| className="cursor-pointer" | |||
| 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 text-primary-foreground' | |||
| : 'opacity-50 [&_svg]:invisible', | |||
| 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="mr-2 h-4 w-4 text-muted-foreground" /> | |||
| <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> | |||
| )} | |||
| <span>{option.label}</span> | |||
| </CommandItem> | |||
| ); | |||
| } | |||
| @@ -156,6 +175,11 @@ interface MultiSelectProps | |||
| * Optional, can be used to add custom styles. | |||
| */ | |||
| className?: string; | |||
| /** | |||
| * If true, renders the multi-select component with a select all option. | |||
| */ | |||
| showSelectAll?: boolean; | |||
| } | |||
| export const MultiSelect = React.forwardRef< | |||
| @@ -174,6 +198,7 @@ export const MultiSelect = React.forwardRef< | |||
| modalPopover = false, | |||
| asChild = false, | |||
| className, | |||
| showSelectAll = true, | |||
| ...props | |||
| }, | |||
| ref, | |||
| @@ -340,23 +365,25 @@ export const MultiSelect = React.forwardRef< | |||
| <CommandList> | |||
| <CommandEmpty>No results found.</CommandEmpty> | |||
| <CommandGroup> | |||
| <CommandItem | |||
| key="all" | |||
| onSelect={toggleAll} | |||
| className="cursor-pointer" | |||
| > | |||
| <div | |||
| className={cn( | |||
| 'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary', | |||
| selectedValues.length === flatOptions.length | |||
| ? 'bg-primary text-primary-foreground' | |||
| : 'opacity-50 [&_svg]:invisible', | |||
| )} | |||
| {showSelectAll && ( | |||
| <CommandItem | |||
| key="all" | |||
| onSelect={toggleAll} | |||
| className="cursor-pointer" | |||
| > | |||
| <CheckIcon className="h-4 w-4" /> | |||
| </div> | |||
| <span>(Select All)</span> | |||
| </CommandItem> | |||
| <div | |||
| className={cn( | |||
| 'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary', | |||
| selectedValues.length === flatOptions.length | |||
| ? 'bg-primary text-primary-foreground' | |||
| : 'opacity-50 [&_svg]:invisible', | |||
| )} | |||
| > | |||
| <CheckIcon className="h-4 w-4" /> | |||
| </div> | |||
| <span>(Select All)</span> | |||
| </CommandItem> | |||
| )} | |||
| {!options.some((x) => 'options' in x) && | |||
| (options as unknown as MultiSelectOptionType[]).map( | |||
| (option) => { | |||
| @@ -72,9 +72,12 @@ export const useNavigatePage = () => { | |||
| navigate(Routes.Searches); | |||
| }, [navigate]); | |||
| const navigateToSearch = useCallback(() => { | |||
| navigate(Routes.Search); | |||
| }, [navigate]); | |||
| const navigateToSearch = useCallback( | |||
| (id: string) => { | |||
| navigate(`${Routes.Search}/${id}`); | |||
| }, | |||
| [navigate], | |||
| ); | |||
| const navigateToChunkParsedResult = useCallback( | |||
| (id: string, knowledgeId?: string) => () => { | |||
| @@ -0,0 +1,108 @@ | |||
| @keyframes fadeInUp { | |||
| from { | |||
| opacity: 0; | |||
| transform: translate3d(0, 100%, 0); | |||
| } | |||
| to { | |||
| opacity: 1; | |||
| transform: translate3d(0, 0, 0); | |||
| } | |||
| } | |||
| @keyframes fadeInLeft { | |||
| from { | |||
| opacity: 0; | |||
| transform: translate3d(-50%, 0, 0); | |||
| } | |||
| to { | |||
| opacity: 1; | |||
| transform: translate3d(0, 0, 0); | |||
| } | |||
| } | |||
| @keyframes fadeInRight { | |||
| from { | |||
| opacity: 0; | |||
| transform: translate3d(50%, 0, 0); | |||
| } | |||
| to { | |||
| opacity: 1; | |||
| transform: translate3d(0, 0, 0); | |||
| } | |||
| } | |||
| @keyframes fadeOutRight { | |||
| from { | |||
| opacity: 0; | |||
| transform: translate3d(0, 0, 0); | |||
| } | |||
| to { | |||
| opacity: 1; | |||
| transform: translate3d(120%, 0, 0); | |||
| } | |||
| } | |||
| @keyframes fadeInDown { | |||
| from { | |||
| opacity: 0; | |||
| transform: translate3d(0, -50%, 0); | |||
| } | |||
| to { | |||
| opacity: 1; | |||
| transform: translate3d(0, 0, 0); | |||
| } | |||
| } | |||
| .animate-fade-in-up { | |||
| animation-name: fadeInUp; | |||
| animation-duration: 0.5s; | |||
| animation-fill-mode: both; | |||
| } | |||
| .animate-fade-in-down { | |||
| animation-name: fadeInDown; | |||
| animation-duration: 0.5s; | |||
| animation-fill-mode: both; | |||
| } | |||
| .animate-fade-in-left { | |||
| animation-name: fadeInLeft; | |||
| animation-duration: 0.5s; | |||
| animation-fill-mode: both; | |||
| } | |||
| .animate-fade-in-right { | |||
| animation-name: fadeInRight; | |||
| animation-duration: 0.5s; | |||
| animation-fill-mode: both; | |||
| } | |||
| .animate-fade-out-right { | |||
| animation-name: fadeOutRight; | |||
| animation-duration: 0.5s; | |||
| animation-fill-mode: both; | |||
| } | |||
| .delay-100 { | |||
| animation-delay: 0.1s; | |||
| } | |||
| .delay-200 { | |||
| animation-delay: 0.2s; | |||
| } | |||
| .delay-300 { | |||
| animation-delay: 0.3s; | |||
| } | |||
| .delay-400 { | |||
| animation-delay: 0.4s; | |||
| } | |||
| .delay-500 { | |||
| animation-delay: 0.5s; | |||
| } | |||
| .delay-600 { | |||
| animation-delay: 0.6s; | |||
| } | |||
| .delay-700 { | |||
| animation-delay: 0.7s; | |||
| } | |||
| @@ -1,21 +1,89 @@ | |||
| import { PageHeader } from '@/components/page-header'; | |||
| import { | |||
| Breadcrumb, | |||
| BreadcrumbItem, | |||
| BreadcrumbLink, | |||
| BreadcrumbList, | |||
| BreadcrumbPage, | |||
| BreadcrumbSeparator, | |||
| } from '@/components/ui/breadcrumb'; | |||
| import { Button } from '@/components/ui/button'; | |||
| import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks'; | |||
| import { EllipsisVertical } from 'lucide-react'; | |||
| import { Settings } from 'lucide-react'; | |||
| import { useState } from 'react'; | |||
| import { | |||
| ISearchAppDetailProps, | |||
| useFetchSearchDetail, | |||
| } from '../next-searches/hooks'; | |||
| import './index.less'; | |||
| import SearchHome from './search-home'; | |||
| import { SearchSetting } from './search-setting'; | |||
| import SearchingPage from './searching'; | |||
| export default function SearchPage() { | |||
| const { navigateToSearchList } = useNavigatePage(); | |||
| const [isSearching, setIsSearching] = useState(false); | |||
| const { data: SearchData } = useFetchSearchDetail(); | |||
| const [openSetting, setOpenSetting] = useState(false); | |||
| return ( | |||
| <section> | |||
| <PageHeader back={navigateToSearchList} title="Search app 01"> | |||
| <div className="flex items-center gap-2"> | |||
| <Button variant={'icon'} size={'icon'}> | |||
| <EllipsisVertical /> | |||
| </Button> | |||
| <Button size={'sm'}>Publish</Button> | |||
| </div> | |||
| <PageHeader> | |||
| <Breadcrumb> | |||
| <BreadcrumbList> | |||
| <BreadcrumbItem> | |||
| <BreadcrumbLink onClick={navigateToSearchList}> | |||
| Search | |||
| </BreadcrumbLink> | |||
| </BreadcrumbItem> | |||
| <BreadcrumbSeparator /> | |||
| <BreadcrumbItem> | |||
| <BreadcrumbPage>{SearchData?.name}</BreadcrumbPage> | |||
| </BreadcrumbItem> | |||
| </BreadcrumbList> | |||
| </Breadcrumb> | |||
| </PageHeader> | |||
| <div className="flex gap-3 w-full"> | |||
| <div className="flex-1"> | |||
| {!isSearching && ( | |||
| <div className="animate-fade-in-down"> | |||
| <SearchHome | |||
| setIsSearching={setIsSearching} | |||
| isSearching={isSearching} | |||
| /> | |||
| </div> | |||
| )} | |||
| {isSearching && ( | |||
| <div className="animate-fade-in-up"> | |||
| <SearchingPage | |||
| setIsSearching={setIsSearching} | |||
| isSearching={isSearching} | |||
| /> | |||
| </div> | |||
| )} | |||
| </div> | |||
| {/* {openSetting && ( | |||
| <div className=" w-[440px]"> */} | |||
| <SearchSetting | |||
| className="mt-20 mr-2" | |||
| open={openSetting} | |||
| setOpen={setOpenSetting} | |||
| data={SearchData as ISearchAppDetailProps} | |||
| /> | |||
| {/* </div> | |||
| )} */} | |||
| </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> | |||
| </section> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,93 @@ | |||
| import { Input } from '@/components/originui/input'; | |||
| import { Button } from '@/components/ui/button'; | |||
| import { cn } from '@/lib/utils'; | |||
| import { Search } from 'lucide-react'; | |||
| import { Dispatch, SetStateAction } from 'react'; | |||
| import './index.less'; | |||
| import Spotlight from './spotlight'; | |||
| export default function SearchPage({ | |||
| isSearching, | |||
| setIsSearching, | |||
| }: { | |||
| isSearching: boolean; | |||
| setIsSearching: Dispatch<SetStateAction<boolean>>; | |||
| }) { | |||
| 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]"> | |||
| <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', | |||
| )} | |||
| > | |||
| RAGFlow | |||
| </h1> | |||
| <div className="rounded-lg text-primary text-xl sticky flex justify-center w-full transform scale-100 mt-8 p-6 h-[230px] border"> | |||
| {!isSearching && <Spotlight className="z-0" />} | |||
| <div className="flex flex-col justify-center items-center w-2/3"> | |||
| {!isSearching && ( | |||
| <> | |||
| <p className="mb-4 transition-opacity">👋 Hi there</p> | |||
| <p className="mb-10 transition-opacity">Welcome back, KiKi</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" | |||
| /> | |||
| <button | |||
| type="button" | |||
| className="absolute right-2 top-1/2 -translate-y-1/2 transform rounded-full bg-white p-2 text-gray-800 shadow w-12" | |||
| onClick={() => { | |||
| setIsSearching(!isSearching); | |||
| }} | |||
| > | |||
| <Search size={22} className="m-auto" /> | |||
| </button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div className="mt-14 w-full overflow-hidden opacity-100 max-h-96"> | |||
| <p className="text-text-primary mb-2 text-xl">Related Search</p> | |||
| <div className="mt-2 flex flex-wrap justify-start gap-2"> | |||
| <Button | |||
| variant="transparent" | |||
| className="bg-bg-card text-text-secondary" | |||
| > | |||
| Related Search | |||
| </Button> | |||
| <Button | |||
| variant="transparent" | |||
| className="bg-bg-card text-text-secondary" | |||
| > | |||
| Related Search Related SearchRelated Search | |||
| </Button> | |||
| <Button | |||
| variant="transparent" | |||
| className="bg-bg-card text-text-secondary" | |||
| > | |||
| Related Search Search | |||
| </Button> | |||
| <Button | |||
| variant="transparent" | |||
| className="bg-bg-card text-text-secondary" | |||
| > | |||
| Related Search Related SearchRelated Search | |||
| </Button> | |||
| <Button | |||
| variant="transparent" | |||
| className="bg-bg-card text-text-secondary" | |||
| > | |||
| Related Search | |||
| </Button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </section> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,497 @@ | |||
| // src/pages/next-search/search-setting.tsx | |||
| import { RAGFlowAvatar } from '@/components/ragflow-avatar'; | |||
| import { Button } from '@/components/ui/button'; | |||
| import { SingleFormSlider } from '@/components/ui/dual-range-slider'; | |||
| import { | |||
| Form, | |||
| FormControl, | |||
| FormField, | |||
| FormItem, | |||
| FormLabel, | |||
| FormMessage, | |||
| } from '@/components/ui/form'; | |||
| import { Input } from '@/components/ui/input'; | |||
| import { Label } from '@/components/ui/label'; | |||
| import { | |||
| MultiSelect, | |||
| MultiSelectOptionType, | |||
| } from '@/components/ui/multi-select'; | |||
| import { | |||
| Select, | |||
| SelectContent, | |||
| SelectItem, | |||
| SelectTrigger, | |||
| SelectValue, | |||
| } from '@/components/ui/select'; | |||
| import { Switch } from '@/components/ui/switch'; | |||
| import { useFetchKnowledgeList } from '@/hooks/knowledge-hooks'; | |||
| import { IKnowledge } from '@/interfaces/database/knowledge'; | |||
| import { cn } from '@/lib/utils'; | |||
| import { transformFile2Base64 } from '@/utils/file-util'; | |||
| import { t } from 'i18next'; | |||
| import { PanelRightClose, Pencil, Upload } from 'lucide-react'; | |||
| import { useEffect, useState } from 'react'; | |||
| import { useForm } from 'react-hook-form'; | |||
| import { ISearchAppDetailProps } from '../next-searches/hooks'; | |||
| interface SearchSettingProps { | |||
| open: boolean; | |||
| setOpen: (open: boolean) => void; | |||
| className?: string; | |||
| data: ISearchAppDetailProps; | |||
| } | |||
| const SearchSetting: React.FC<SearchSettingProps> = ({ | |||
| open = false, | |||
| setOpen, | |||
| className, | |||
| data, | |||
| }) => { | |||
| const [width0, setWidth0] = useState('w-[440px]'); | |||
| // "avatar": null, | |||
| // "created_by": "c3fb861af27a11efa69751e139332ced", | |||
| // "description": "My first search app", | |||
| // "id": "22e874584b4511f0aa1ac57b9ea5a68b", | |||
| // "name": "updated search app", | |||
| // "search_config": { | |||
| // "cross_languages": [], | |||
| // "doc_ids": [], | |||
| // "highlight": false, | |||
| // "kb_ids": [], | |||
| // "keyword": false, | |||
| // "query_mindmap": false, | |||
| // "related_search": false, | |||
| // "rerank_id": "", | |||
| // "similarity_threshold": 0.5, | |||
| // "summary": false, | |||
| // "top_k": 1024, | |||
| // "use_kg": true, | |||
| // "vector_similarity_weight": 0.3, | |||
| // "web_search": false | |||
| // }, | |||
| // "tenant_id": "c3fb861af27a11efa69751e139332ced", | |||
| // "update_time": 1750144129641 | |||
| const formMethods = useForm({ | |||
| defaultValues: { | |||
| id: '', | |||
| name: '', | |||
| avatar: '', | |||
| description: 'You are an intelligent assistant.', | |||
| datasets: '', | |||
| keywordSimilarityWeight: 20, | |||
| rerankModel: false, | |||
| aiSummary: false, | |||
| topK: true, | |||
| searchMethod: '', | |||
| model: '', | |||
| enableWebSearch: false, | |||
| enableRelatedSearch: true, | |||
| showQueryMindmap: true, | |||
| }, | |||
| }); | |||
| const [avatarFile, setAvatarFile] = useState<File | null>(null); | |||
| const [avatarBase64Str, setAvatarBase64Str] = useState(''); // Avatar Image base64 | |||
| const [datasetList, setDatasetList] = useState<MultiSelectOptionType[]>([]); | |||
| const [datasetSelectEmbdId, setDatasetSelectEmbdId] = useState(''); | |||
| useEffect(() => { | |||
| if (!open) { | |||
| setTimeout(() => { | |||
| setWidth0('w-0 hidden'); | |||
| }, 500); | |||
| } else { | |||
| setWidth0('w-[440px]'); | |||
| } | |||
| }, [open]); | |||
| useEffect(() => { | |||
| if (!avatarFile) { | |||
| setAvatarBase64Str(data?.avatar); | |||
| } | |||
| }, [avatarFile, data?.avatar]); | |||
| useEffect(() => { | |||
| if (avatarFile) { | |||
| (async () => { | |||
| // make use of img compression transformFile2Base64 | |||
| setAvatarBase64Str(await transformFile2Base64(avatarFile)); | |||
| })(); | |||
| } | |||
| }, [avatarFile]); | |||
| const { list: datasetListOrigin, loading: datasetLoading } = | |||
| useFetchKnowledgeList(); | |||
| useEffect(() => { | |||
| const datasetListMap = datasetListOrigin.map((item: IKnowledge) => { | |||
| return { | |||
| label: item.name, | |||
| suffix: ( | |||
| <div className="text-xs px-4 p-1 bg-bg-card text-text-secondary rounded-lg border border-bg-card"> | |||
| {item.embd_id} | |||
| </div> | |||
| ), | |||
| value: item.id, | |||
| disabled: | |||
| item.embd_id !== datasetSelectEmbdId && datasetSelectEmbdId !== '', | |||
| }; | |||
| }); | |||
| setDatasetList(datasetListMap); | |||
| }, [datasetListOrigin, datasetSelectEmbdId]); | |||
| const handleDatasetSelectChange = (value, onChange) => { | |||
| console.log(value); | |||
| if (value.length) { | |||
| const data = datasetListOrigin?.find((item) => item.id === value[0]); | |||
| setDatasetSelectEmbdId(data?.embd_id ?? ''); | |||
| } else { | |||
| setDatasetSelectEmbdId(''); | |||
| } | |||
| onChange?.(value); | |||
| }; | |||
| return ( | |||
| <div | |||
| className={cn( | |||
| 'text-text-primary border p-4 rounded-lg', | |||
| { | |||
| 'animate-fade-in-right': open, | |||
| 'animate-fade-out-right': !open, | |||
| }, | |||
| width0, | |||
| className, | |||
| )} | |||
| style={{ height: 'calc(100dvh - 170px)' }} | |||
| > | |||
| <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" | |||
| /> | |||
| </div> | |||
| </div> | |||
| <div | |||
| style={{ height: 'calc(100dvh - 270px)' }} | |||
| className="overflow-y-auto scrollbar-auto p-1 text-text-secondary" | |||
| > | |||
| <Form {...formMethods}> | |||
| <form | |||
| onSubmit={formMethods.handleSubmit((data) => console.log(data))} | |||
| className="space-y-6" | |||
| > | |||
| {/* Name */} | |||
| <FormField | |||
| control={formMethods.control} | |||
| name="name" | |||
| rules={{ required: 'Name is required' }} | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel> | |||
| <span className="text-destructive mr-1"> *</span>Name | |||
| </FormLabel> | |||
| <FormControl> | |||
| <Input placeholder="Name" {...field} /> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| {/* Avatar */} | |||
| <FormField | |||
| control={formMethods.control} | |||
| name="avatar" | |||
| render={() => ( | |||
| <FormItem> | |||
| <FormLabel>Avatar</FormLabel> | |||
| <FormControl> | |||
| <div className="relative group"> | |||
| {!avatarBase64Str ? ( | |||
| <div className="w-[64px] h-[64px] grid place-content-center border border-dashed rounded-md"> | |||
| <div className="flex flex-col items-center"> | |||
| <Upload /> | |||
| <p>{t('common.upload')}</p> | |||
| </div> | |||
| </div> | |||
| ) : ( | |||
| <div className="w-[64px] h-[64px] relative grid place-content-center"> | |||
| <RAGFlowAvatar | |||
| avatar={avatarBase64Str} | |||
| name={data.name} | |||
| className="w-[64px] h-[64px] rounded-md block" | |||
| /> | |||
| <div className="absolute inset-0 bg-[#000]/20 group-hover:bg-[#000]/60"> | |||
| <Pencil | |||
| size={20} | |||
| className="absolute right-2 bottom-0 opacity-50 hidden group-hover:block" | |||
| /> | |||
| </div> | |||
| </div> | |||
| )} | |||
| <Input | |||
| placeholder="" | |||
| // {...field} | |||
| type="file" | |||
| title="" | |||
| accept="image/*" | |||
| className="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer" | |||
| onChange={(ev) => { | |||
| const file = ev.target?.files?.[0]; | |||
| if ( | |||
| /\.(jpg|jpeg|png|webp|bmp)$/i.test(file?.name ?? '') | |||
| ) { | |||
| setAvatarFile(file!); | |||
| } | |||
| ev.target.value = ''; | |||
| }} | |||
| /> | |||
| </div> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| {/* Description */} | |||
| <FormField | |||
| control={formMethods.control} | |||
| name="description" | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel>Description</FormLabel> | |||
| <FormControl> | |||
| <Input | |||
| placeholder="Description" | |||
| {...field} | |||
| defaultValue="You are an intelligent assistant." | |||
| /> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| {/* Datasets */} | |||
| <FormField | |||
| control={formMethods.control} | |||
| name="datasets" | |||
| rules={{ required: 'Datasets is required' }} | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel> | |||
| <span className="text-destructive mr-1"> *</span>Datasets | |||
| </FormLabel> | |||
| <FormControl> | |||
| <MultiSelect | |||
| options={datasetList} | |||
| onValueChange={(value) => { | |||
| handleDatasetSelectChange(value, field.onChange); | |||
| }} | |||
| showSelectAll={false} | |||
| placeholder={t('chat.knowledgeBasesMessage')} | |||
| variant="inverted" | |||
| maxCount={10} | |||
| {...field} | |||
| /> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| {/* Keyword Similarity Weight */} | |||
| <FormField | |||
| control={formMethods.control} | |||
| name="keywordSimilarityWeight" | |||
| render={({ field }) => ( | |||
| <FormItem className="flex flex-col"> | |||
| <FormLabel>Keyword Similarity Weight</FormLabel> | |||
| <FormControl> | |||
| <div className="flex justify-between items-center"> | |||
| <SingleFormSlider | |||
| max={100} | |||
| 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> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| {/* Rerank Model */} | |||
| <FormField | |||
| control={formMethods.control} | |||
| name="rerankModel" | |||
| render={({ field }) => ( | |||
| <FormItem className="flex flex-row items-start space-x-3 space-y-0"> | |||
| <FormControl> | |||
| <Switch | |||
| checked={field.value} | |||
| onCheckedChange={field.onChange} | |||
| /> | |||
| </FormControl> | |||
| <FormLabel>Rerank Model</FormLabel> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| {/* AI Summary */} | |||
| <FormField | |||
| control={formMethods.control} | |||
| name="aiSummary" | |||
| render={({ field }) => ( | |||
| <FormItem className="flex flex-row items-start space-x-3 space-y-0"> | |||
| <FormControl> | |||
| <Switch | |||
| checked={field.value} | |||
| onCheckedChange={field.onChange} | |||
| /> | |||
| </FormControl> | |||
| <FormLabel>AI Summary</FormLabel> | |||
| <Label className="text-sm text-muted-foreground"> | |||
| 默认不打开 | |||
| </Label> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| {/* Top K */} | |||
| <FormField | |||
| control={formMethods.control} | |||
| name="topK" | |||
| render={({ field }) => ( | |||
| <FormItem className="flex flex-row items-start space-x-3 space-y-0"> | |||
| <FormControl> | |||
| <Switch | |||
| checked={field.value} | |||
| onCheckedChange={field.onChange} | |||
| /> | |||
| </FormControl> | |||
| <FormLabel>Top K</FormLabel> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| {/* Search Method */} | |||
| <FormField | |||
| control={formMethods.control} | |||
| name="searchMethod" | |||
| rules={{ required: 'Search Method is required' }} | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel> | |||
| <span className="text-destructive mr-1"> *</span>Search | |||
| Method | |||
| </FormLabel> | |||
| <FormControl> | |||
| <Select | |||
| onValueChange={field.onChange} | |||
| defaultValue={field.value} | |||
| > | |||
| <SelectTrigger> | |||
| <SelectValue placeholder="Select search method..." /> | |||
| </SelectTrigger> | |||
| <SelectContent> | |||
| <SelectItem value="method1">Method 1</SelectItem> | |||
| <SelectItem value="method2">Method 2</SelectItem> | |||
| </SelectContent> | |||
| </Select> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| {/* Model */} | |||
| <FormField | |||
| control={formMethods.control} | |||
| name="model" | |||
| rules={{ required: 'Model is required' }} | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel> | |||
| <span className="text-destructive mr-1"> *</span>Model | |||
| </FormLabel> | |||
| <FormControl> | |||
| <Select | |||
| onValueChange={field.onChange} | |||
| defaultValue={field.value} | |||
| > | |||
| <SelectTrigger> | |||
| <SelectValue placeholder="Select model..." /> | |||
| </SelectTrigger> | |||
| <SelectContent> | |||
| <SelectItem value="model1">Model 1</SelectItem> | |||
| <SelectItem value="model2">Model 2</SelectItem> | |||
| </SelectContent> | |||
| </Select> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| {/* Feature Controls */} | |||
| <FormField | |||
| control={formMethods.control} | |||
| name="enableWebSearch" | |||
| render={({ field }) => ( | |||
| <FormItem className="flex flex-row items-start space-x-3 space-y-0"> | |||
| <FormControl> | |||
| <Switch | |||
| checked={field.value} | |||
| onCheckedChange={field.onChange} | |||
| /> | |||
| </FormControl> | |||
| <FormLabel>Enable Web Search</FormLabel> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <FormField | |||
| control={formMethods.control} | |||
| name="enableRelatedSearch" | |||
| render={({ field }) => ( | |||
| <FormItem className="flex flex-row items-start space-x-3 space-y-0"> | |||
| <FormControl> | |||
| <Switch | |||
| checked={field.value} | |||
| onCheckedChange={field.onChange} | |||
| /> | |||
| </FormControl> | |||
| <FormLabel>Enable Related Search</FormLabel> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <FormField | |||
| control={formMethods.control} | |||
| name="showQueryMindmap" | |||
| render={({ field }) => ( | |||
| <FormItem className="flex flex-row items-start space-x-3 space-y-0"> | |||
| <FormControl> | |||
| <Switch | |||
| checked={field.value} | |||
| onCheckedChange={field.onChange} | |||
| /> | |||
| </FormControl> | |||
| <FormLabel>Show Query Mindmap</FormLabel> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| {/* Submit Button */} | |||
| <div className="flex justify-end"> | |||
| <Button type="submit">Confirm</Button> | |||
| </div> | |||
| </form> | |||
| </Form> | |||
| </div> | |||
| </div> | |||
| ); | |||
| }; | |||
| export { SearchSetting }; | |||
| @@ -0,0 +1,64 @@ | |||
| import { Input } from '@/components/originui/input'; | |||
| import { cn } from '@/lib/utils'; | |||
| import { Search, X } from 'lucide-react'; | |||
| import { Dispatch, SetStateAction } from 'react'; | |||
| import './index.less'; | |||
| export default function SearchingPage({ | |||
| isSearching, | |||
| setIsSearching, | |||
| }: { | |||
| isSearching: boolean; | |||
| setIsSearching: Dispatch<SetStateAction<boolean>>; | |||
| }) { | |||
| return ( | |||
| <section | |||
| className={cn( | |||
| 'relative w-full flex transition-all justify-start items-center', | |||
| )} | |||
| > | |||
| <div | |||
| className={cn( | |||
| 'relative z-10 px-8 pt-8 flex text-transparent justify-start items-start w-full', | |||
| )} | |||
| > | |||
| <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', | |||
| )} | |||
| > | |||
| 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 ', | |||
| )} | |||
| > | |||
| <div className={cn('flex flex-col justify-start items-start w-full')}> | |||
| <div className="relative w-full text-primary"> | |||
| <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', | |||
| )} | |||
| /> | |||
| <div className="absolute right-2 top-1/2 -translate-y-1/2 transform flex items-center gap-1"> | |||
| <X />| | |||
| <button | |||
| type="button" | |||
| className="rounded-full bg-white p-1 text-gray-800 shadow w-12 h-8 ml-4" | |||
| onClick={() => { | |||
| setIsSearching(!isSearching); | |||
| }} | |||
| > | |||
| <Search size={22} className="m-auto" /> | |||
| </button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </section> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,28 @@ | |||
| import React from 'react'; | |||
| interface SpotlightProps { | |||
| className?: string; | |||
| } | |||
| const Spotlight: React.FC<SpotlightProps> = ({ className }) => { | |||
| return ( | |||
| <div | |||
| className={`absolute inset-0 opacity-80 ${className} rounded-lg`} | |||
| style={{ | |||
| backdropFilter: 'blur(30px)', | |||
| zIndex: -1, | |||
| }} | |||
| > | |||
| <div | |||
| className="absolute inset-0" | |||
| style={{ | |||
| background: | |||
| 'radial-gradient(circle at 50% 190%, #fff4 0%, #fff0 60%)', | |||
| pointerEvents: 'none', | |||
| }} | |||
| ></div> | |||
| </div> | |||
| ); | |||
| }; | |||
| export default Spotlight; | |||
| @@ -0,0 +1,209 @@ | |||
| // src/pages/next-searches/hooks.ts | |||
| import searchService from '@/services/search-service'; | |||
| import { useMutation, useQuery } from '@tanstack/react-query'; | |||
| import { message } from 'antd'; | |||
| import { useCallback, useState } from 'react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { useParams } from 'umi'; | |||
| interface CreateSearchProps { | |||
| name: string; | |||
| description?: string; | |||
| } | |||
| interface CreateSearchResponse { | |||
| id: string; | |||
| name: string; | |||
| description: string; | |||
| } | |||
| export const useCreateSearch = () => { | |||
| const { t } = useTranslation(); | |||
| const { | |||
| data, | |||
| isLoading, | |||
| isError, | |||
| mutateAsync: createSearchMutation, | |||
| } = useMutation<CreateSearchResponse, Error, CreateSearchProps>({ | |||
| mutationKey: ['createSearch'], | |||
| mutationFn: async (props) => { | |||
| const { data: response } = await searchService.createSearch(props); | |||
| if (response.code !== 0) { | |||
| throw new Error(response.message || 'Failed to create search'); | |||
| } | |||
| return response.data; | |||
| }, | |||
| onSuccess: () => { | |||
| message.success(t('message.created')); | |||
| }, | |||
| onError: (error) => { | |||
| message.error(t('message.error', { error: error.message })); | |||
| }, | |||
| }); | |||
| const createSearch = useCallback( | |||
| (props: CreateSearchProps) => { | |||
| return createSearchMutation(props); | |||
| }, | |||
| [createSearchMutation], | |||
| ); | |||
| return { data, isLoading, isError, createSearch }; | |||
| }; | |||
| export interface SearchListParams { | |||
| keywords?: string; | |||
| parser_id?: string; | |||
| page?: number; | |||
| page_size?: number; | |||
| orderby?: string; | |||
| desc?: boolean; | |||
| owner_ids?: string; | |||
| } | |||
| export interface ISearchAppProps { | |||
| avatar: any; | |||
| create_time: number; | |||
| created_by: string; | |||
| description: string; | |||
| id: string; | |||
| name: string; | |||
| nickname: string; | |||
| status: string; | |||
| tenant_avatar: any; | |||
| tenant_id: string; | |||
| update_time: number; | |||
| } | |||
| interface SearchListResponse { | |||
| code: number; | |||
| data: { | |||
| search_apps: Array<ISearchAppProps>; | |||
| total: number; | |||
| }; | |||
| message: string; | |||
| } | |||
| export const useFetchSearchList = (params?: SearchListParams) => { | |||
| const [searchParams, setSearchParams] = useState<SearchListParams>({ | |||
| page: 1, | |||
| page_size: 10, | |||
| ...params, | |||
| }); | |||
| const { data, isLoading, isError } = useQuery<SearchListResponse, Error>({ | |||
| queryKey: ['searchList', searchParams], | |||
| queryFn: async () => { | |||
| const { data: response } = | |||
| await searchService.getSearchList(searchParams); | |||
| if (response.code !== 0) { | |||
| throw new Error(response.message || 'Failed to fetch search list'); | |||
| } | |||
| return response; | |||
| }, | |||
| }); | |||
| const setSearchListParams = (newParams: SearchListParams) => { | |||
| setSearchParams((prevParams) => ({ | |||
| ...prevParams, | |||
| ...newParams, | |||
| })); | |||
| }; | |||
| return { data, isLoading, isError, searchParams, setSearchListParams }; | |||
| }; | |||
| interface DeleteSearchProps { | |||
| search_id: string; | |||
| } | |||
| interface DeleteSearchResponse { | |||
| code: number; | |||
| data: boolean; | |||
| message: string; | |||
| } | |||
| export const useDeleteSearch = () => { | |||
| const { t } = useTranslation(); | |||
| const { | |||
| data, | |||
| isLoading, | |||
| isError, | |||
| mutateAsync: deleteSearchMutation, | |||
| } = useMutation<DeleteSearchResponse, Error, DeleteSearchProps>({ | |||
| mutationKey: ['deleteSearch'], | |||
| mutationFn: async (props) => { | |||
| const response = await searchService.deleteSearch(props); | |||
| if (response.code !== 0) { | |||
| throw new Error(response.message || 'Failed to delete search'); | |||
| } | |||
| return response; | |||
| }, | |||
| onSuccess: () => { | |||
| message.success(t('message.deleted')); | |||
| }, | |||
| onError: (error) => { | |||
| message.error(t('message.error', { error: error.message })); | |||
| }, | |||
| }); | |||
| const deleteSearch = useCallback( | |||
| (props: DeleteSearchProps) => { | |||
| return deleteSearchMutation(props); | |||
| }, | |||
| [deleteSearchMutation], | |||
| ); | |||
| return { data, isLoading, isError, deleteSearch }; | |||
| }; | |||
| export interface ISearchAppDetailProps { | |||
| avatar: any; | |||
| created_by: string; | |||
| description: string; | |||
| id: string; | |||
| name: string; | |||
| search_config: { | |||
| cross_languages: string[]; | |||
| doc_ids: string[]; | |||
| highlight: boolean; | |||
| kb_ids: string[]; | |||
| keyword: boolean; | |||
| query_mindmap: boolean; | |||
| related_search: boolean; | |||
| rerank_id: string; | |||
| similarity_threshold: number; | |||
| summary: boolean; | |||
| top_k: number; | |||
| use_kg: boolean; | |||
| vector_similarity_weight: number; | |||
| web_search: boolean; | |||
| }; | |||
| tenant_id: string; | |||
| update_time: number; | |||
| } | |||
| interface SearchDetailResponse { | |||
| code: number; | |||
| data: ISearchAppDetailProps; | |||
| message: string; | |||
| } | |||
| export const useFetchSearchDetail = () => { | |||
| const { id } = useParams(); | |||
| const { data, isLoading, isError } = useQuery<SearchDetailResponse, Error>({ | |||
| queryKey: ['searchDetail', id], | |||
| queryFn: async () => { | |||
| const { data: response } = await searchService.getSearchDetail({ | |||
| search_id: id, | |||
| }); | |||
| if (response.code !== 0) { | |||
| throw new Error(response.message || 'Failed to fetch search detail'); | |||
| } | |||
| return response; | |||
| }, | |||
| }); | |||
| return { data: data?.data, isLoading, isError }; | |||
| }; | |||
| @@ -1,20 +1,65 @@ | |||
| import ListFilterBar from '@/components/list-filter-bar'; | |||
| import { Input } from '@/components/originui/input'; | |||
| import { Button } from '@/components/ui/button'; | |||
| import { | |||
| Form, | |||
| FormControl, | |||
| FormField, | |||
| FormItem, | |||
| FormLabel, | |||
| FormMessage, | |||
| } from '@/components/ui/form'; | |||
| import { Modal } from '@/components/ui/modal/modal'; | |||
| import { RAGFlowPagination } from '@/components/ui/ragflow-pagination'; | |||
| import { useTranslate } from '@/hooks/common-hooks'; | |||
| import { useFetchFlowList } from '@/hooks/flow-hooks'; | |||
| import { zodResolver } from '@hookform/resolvers/zod'; | |||
| import { pick } from 'lodash'; | |||
| import { Plus, Search } from 'lucide-react'; | |||
| import { useState } from 'react'; | |||
| import { useForm } from 'react-hook-form'; | |||
| import { z } from 'zod'; | |||
| import { useCreateSearch, useFetchSearchList } from './hooks'; | |||
| import { SearchCard } from './search-card'; | |||
| const searchFormSchema = z.object({ | |||
| name: z.string().min(1, { | |||
| message: 'Name is required', | |||
| }), | |||
| }); | |||
| type SearchFormValues = z.infer<typeof searchFormSchema>; | |||
| export default function SearchList() { | |||
| const { data } = useFetchFlowList(); | |||
| // const { data } = useFetchFlowList(); | |||
| const { t } = useTranslate('search'); | |||
| const [searchName, setSearchName] = useState(''); | |||
| const { isLoading, isError, createSearch } = useCreateSearch(); | |||
| const { | |||
| data: list, | |||
| searchParams, | |||
| setSearchListParams, | |||
| } = useFetchSearchList(); | |||
| const [openCreateModal, setOpenCreateModal] = useState(false); | |||
| const form = useForm<SearchFormValues>({ | |||
| resolver: zodResolver(searchFormSchema), | |||
| defaultValues: { | |||
| name: '', | |||
| }, | |||
| }); | |||
| const handleSearchChange = (value: string) => { | |||
| console.log(value); | |||
| }; | |||
| const onSubmit = async (values: SearchFormValues) => { | |||
| await createSearch({ name: values.name }); | |||
| if (!isLoading) { | |||
| setOpenCreateModal(false); | |||
| } | |||
| form.reset({ name: '' }); | |||
| }; | |||
| const openCreateModalFun = () => { | |||
| setOpenCreateModal(true); | |||
| }; | |||
| const handlePageChange = (page: number, pageSize: number) => { | |||
| setSearchListParams({ ...searchParams, page, page_size: pageSize }); | |||
| }; | |||
| return ( | |||
| <section> | |||
| <div className="px-8 pt-8"> | |||
| @@ -31,35 +76,7 @@ export default function SearchList() { | |||
| <Button | |||
| variant={'default'} | |||
| onClick={() => { | |||
| Modal.show({ | |||
| title: ( | |||
| <div className="rounded-sm bg-emerald-400 bg-gradient-to-t from-emerald-400 via-emerald-400 to-emerald-200 p-1 size-6 flex justify-center items-center"> | |||
| <Search size={14} className="font-bold m-auto" /> | |||
| </div> | |||
| ), | |||
| titleClassName: 'border-none', | |||
| footerClassName: 'border-none', | |||
| visible: true, | |||
| children: ( | |||
| <div> | |||
| <div>{t('createSearch')}</div> | |||
| <div>name:</div> | |||
| <Input | |||
| defaultValue={searchName} | |||
| onChange={(e) => { | |||
| console.log(e.target.value, e); | |||
| setSearchName(e.target.value); | |||
| }} | |||
| /> | |||
| </div> | |||
| ), | |||
| onOk: () => { | |||
| console.log('ok', searchName); | |||
| }, | |||
| onVisibleChange: (e) => { | |||
| Modal.hide(); | |||
| }, | |||
| }); | |||
| openCreateModalFun(); | |||
| }} | |||
| > | |||
| <Plus className="mr-2 h-4 w-4" /> | |||
| @@ -68,10 +85,72 @@ export default function SearchList() { | |||
| </ListFilterBar> | |||
| </div> | |||
| <div className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 max-h-[84vh] overflow-auto px-8"> | |||
| {data.map((x) => { | |||
| {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 | |||
| open={openCreateModal} | |||
| onOpenChange={(open) => { | |||
| setOpenCreateModal(open); | |||
| }} | |||
| title={ | |||
| <div className="rounded-sm bg-emerald-400 bg-gradient-to-t from-emerald-400 via-emerald-400 to-emerald-200 p-1 size-6 flex justify-center items-center"> | |||
| <Search size={14} className="font-bold m-auto" /> | |||
| </div> | |||
| } | |||
| className="!w-[480px] rounded-xl" | |||
| titleClassName="border-none" | |||
| footerClassName="border-none" | |||
| showfooter={false} | |||
| maskClosable={false} | |||
| > | |||
| <Form {...form}> | |||
| <form onSubmit={form.handleSubmit(onSubmit)}> | |||
| <div className="text-base mb-4">{t('createSearch')}</div> | |||
| <FormField | |||
| control={form.control} | |||
| name="name" | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel> | |||
| <span className="text-destructive mr-1"> *</span>Name | |||
| </FormLabel> | |||
| <FormControl> | |||
| <Input {...field} /> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <div className="flex justify-end gap-2 mt-8 mb-6"> | |||
| <Button | |||
| type="button" | |||
| variant="outline" | |||
| onClick={() => setOpenCreateModal(false)} | |||
| > | |||
| Cancel | |||
| </Button> | |||
| <Button type="submit" disabled={isLoading}> | |||
| {isLoading ? 'Confirm...' : 'Confirm'} | |||
| </Button> | |||
| </div> | |||
| </form> | |||
| </Form> | |||
| </Modal> | |||
| </section> | |||
| ); | |||
| } | |||
| @@ -1,53 +1,58 @@ | |||
| import { MoreButton } from '@/components/more-button'; | |||
| import { RAGFlowAvatar } from '@/components/ragflow-avatar'; | |||
| import { Button } from '@/components/ui/button'; | |||
| import { Card, CardContent } from '@/components/ui/card'; | |||
| import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks'; | |||
| import { IFlow } from '@/interfaces/database/flow'; | |||
| import { formatPureDate } from '@/utils/date'; | |||
| import { ChevronRight, Trash2 } from 'lucide-react'; | |||
| import { formatDate } from '@/utils/date'; | |||
| import { ISearchAppProps } from './hooks'; | |||
| import { SearchDropdown } from './search-dropdown'; | |||
| interface IProps { | |||
| data: IFlow; | |||
| data: ISearchAppProps; | |||
| } | |||
| export function SearchCard({ data }: IProps) { | |||
| const { navigateToSearch } = useNavigatePage(); | |||
| return ( | |||
| <Card className="border-colors-outline-neutral-standard"> | |||
| <Card | |||
| className="bg-bg-card border-colors-outline-neutral-standard" | |||
| onClick={() => { | |||
| navigateToSearch(data?.id); | |||
| }} | |||
| > | |||
| <CardContent className="p-4 flex gap-2 items-start group"> | |||
| <div className="flex justify-between mb-4"> | |||
| <RAGFlowAvatar | |||
| className="w-[70px] h-[70px]" | |||
| className="w-[32px] h-[32px]" | |||
| avatar={data.avatar} | |||
| name={data.title} | |||
| name={data.name} | |||
| /> | |||
| </div> | |||
| <div className="flex flex-col gap-1"> | |||
| <div className="flex flex-col gap-1 flex-1"> | |||
| <section className="flex justify-between"> | |||
| <div className="text-[20px] font-bold size-7 leading-5"> | |||
| {data.title} | |||
| <div className="text-[20px] font-bold w-80% leading-5"> | |||
| {data.name} | |||
| </div> | |||
| <MoreButton></MoreButton> | |||
| <SearchDropdown dataset={data}> | |||
| <MoreButton></MoreButton> | |||
| </SearchDropdown> | |||
| </section> | |||
| <div>An app that does things An app that does things</div> | |||
| <div>{data.description}</div> | |||
| <section className="flex justify-between"> | |||
| <div> | |||
| Search app | |||
| <p className="text-sm opacity-80"> | |||
| {formatPureDate(data.update_time)} | |||
| {formatDate(data.update_time)} | |||
| </p> | |||
| </div> | |||
| <div className="space-x-2 invisible group-hover:visible"> | |||
| {/* <div className="space-x-2 invisible group-hover:visible"> | |||
| <Button variant="icon" size="icon" onClick={navigateToSearch}> | |||
| <ChevronRight className="h-6 w-6" /> | |||
| </Button> | |||
| <Button variant="icon" size="icon"> | |||
| <Trash2 /> | |||
| </Button> | |||
| </div> | |||
| </div> */} | |||
| </section> | |||
| </div> | |||
| </CardContent> | |||
| @@ -0,0 +1,50 @@ | |||
| import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog'; | |||
| import { | |||
| DropdownMenu, | |||
| DropdownMenuContent, | |||
| DropdownMenuItem, | |||
| DropdownMenuTrigger, | |||
| } from '@/components/ui/dropdown-menu'; | |||
| import { Trash2 } from 'lucide-react'; | |||
| import { MouseEventHandler, PropsWithChildren, useCallback } from 'react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { ISearchAppProps, useDeleteSearch } from './hooks'; | |||
| export function SearchDropdown({ | |||
| children, | |||
| dataset, | |||
| }: PropsWithChildren & { | |||
| dataset: ISearchAppProps; | |||
| }) { | |||
| const { t } = useTranslation(); | |||
| const { deleteSearch } = useDeleteSearch(); | |||
| const handleDelete: MouseEventHandler<HTMLDivElement> = useCallback(() => { | |||
| deleteSearch({ search_id: dataset.id }); | |||
| }, [dataset.id, deleteSearch]); | |||
| return ( | |||
| <DropdownMenu> | |||
| <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> | |||
| <DropdownMenuContent> | |||
| {/* <DropdownMenuItem onClick={handleShowDatasetRenameModal}> | |||
| {t('common.rename')} <PenLine /> | |||
| </DropdownMenuItem> | |||
| <DropdownMenuSeparator /> */} | |||
| <ConfirmDeleteDialog onOk={handleDelete}> | |||
| <DropdownMenuItem | |||
| className="text-text-delete-red" | |||
| onSelect={(e) => { | |||
| e.preventDefault(); | |||
| }} | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| }} | |||
| > | |||
| {t('common.delete')} <Trash2 /> | |||
| </DropdownMenuItem> | |||
| </ConfirmDeleteDialog> | |||
| </DropdownMenuContent> | |||
| </DropdownMenu> | |||
| ); | |||
| } | |||
| @@ -230,7 +230,7 @@ const routes = [ | |||
| ], | |||
| }, | |||
| { | |||
| path: Routes.Search, | |||
| path: `${Routes.Search}/:id`, | |||
| layout: false, | |||
| component: `@/pages${Routes.Search}`, | |||
| }, | |||
| @@ -0,0 +1,23 @@ | |||
| import api from '@/utils/api'; | |||
| import registerServer from '@/utils/register-server'; | |||
| import request from '@/utils/request'; | |||
| const { createSearch, getSearchList, deleteSearch, getSearchDetail } = api; | |||
| const methods = { | |||
| createSearch: { | |||
| url: createSearch, | |||
| method: 'post', | |||
| }, | |||
| getSearchList: { | |||
| url: getSearchList, | |||
| method: 'post', | |||
| }, | |||
| deleteSearch: { url: deleteSearch, method: 'post' }, | |||
| getSearchDetail: { | |||
| url: getSearchDetail, | |||
| method: 'get', | |||
| }, | |||
| } as const; | |||
| const searchService = registerServer<keyof typeof methods>(methods, request); | |||
| export default searchService; | |||
| @@ -174,4 +174,10 @@ export default { | |||
| testMcpServerTool: `${api_host}/mcp_server/test_tool`, | |||
| cacheMcpServerTool: `${api_host}/mcp_server/cache_tools`, | |||
| testMcpServer: `${api_host}/mcp_server/test_mcp`, | |||
| // next-search | |||
| createSearch: `${api_host}/search/create`, | |||
| getSearchList: `${api_host}/search/list`, | |||
| deleteSearch: `${api_host}/search/rm`, | |||
| getSearchDetail: `${api_host}/search/detail`, | |||
| }; | |||