Переглянути джерело

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)
tags/v0.20.2
chanx 2 місяці тому
джерело
коміт
4c7b2ef46e
Аккаунт користувача з таким Email не знайдено

+ 2
- 2
web/src/components/ragflow-avatar.tsx Переглянути файл

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

+ 50
- 23
web/src/components/ui/multi-select.tsx Переглянути файл

@@ -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) => {

+ 6
- 3
web/src/hooks/logic-hooks/navigate-hooks.ts Переглянути файл

@@ -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) => () => {

+ 108
- 0
web/src/pages/next-search/index.less Переглянути файл

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

+ 76
- 8
web/src/pages/next-search/index.tsx Переглянути файл

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

+ 93
- 0
web/src/pages/next-search/search-home.tsx Переглянути файл

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

+ 497
- 0
web/src/pages/next-search/search-setting.tsx Переглянути файл

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

+ 64
- 0
web/src/pages/next-search/searching.tsx Переглянути файл

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

+ 28
- 0
web/src/pages/next-search/spotlight.tsx Переглянути файл

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

+ 209
- 0
web/src/pages/next-searches/hooks.ts Переглянути файл

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

+ 112
- 33
web/src/pages/next-searches/index.tsx Переглянути файл

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

+ 22
- 17
web/src/pages/next-searches/search-card.tsx Переглянути файл

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

+ 50
- 0
web/src/pages/next-searches/search-dropdown.tsx Переглянути файл

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

+ 1
- 1
web/src/routes.ts Переглянути файл

@@ -230,7 +230,7 @@ const routes = [
],
},
{
path: Routes.Search,
path: `${Routes.Search}/:id`,
layout: false,
component: `@/pages${Routes.Search}`,
},

+ 23
- 0
web/src/services/search-service.ts Переглянути файл

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

+ 6
- 0
web/src/utils/api.ts Переглянути файл

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

Завантаження…
Відмінити
Зберегти