Feat: New search page components and features (#9344)

### 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)
This commit is contained in:
chanx
2025-08-11 10:34:22 +08:00
committed by GitHub
parent 597d88bf9a
commit 4c7b2ef46e
16 changed files with 1347 additions and 87 deletions

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 };

View File

@ -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>
);
}

View File

@ -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;