Feat: Integrate the name, avatar, and description of chat and search into a single component. (#14008)

### What problem does this PR solve?

Feat: Integrate the name, avatar, and description of chat and search
into a single component.
### Type of change


- [x] New Feature (non-breaking change which adds functionality)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Inline-editable avatar, name, and description fields
  * Expandable content blocks in search results
  * New RAGFlow heading/logo component

* **Refactor**
* Replaced scattered form fields with a composed Avatar/Name/Description
component
  * Mindmap drawer converted to a sheet-based drawer and layout cleanup
* Simplified search page controls and layout; improved scroll viewport
handling

* **Chores**
* Added/updated English and Chinese localization keys (placeholders,
view more/less)
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Yingfeng <yingfeng.zhang@gmail.com>
This commit is contained in:
balibabu
2026-04-09 18:51:45 +08:00
committed by GitHub
parent 1e83c8c051
commit 3c5a3e5fb4
18 changed files with 698 additions and 320 deletions

View File

@@ -47,7 +47,7 @@ if (process.env.NODE_ENV === 'development') {
whyDidYouRender(React, {
trackAllPureComponents: true,
trackExtraHooks: [],
logOnDifferentValues: true,
logOnDifferentValues: false,
exclude: [/^RouterProvider$/],
});
},

View File

@@ -0,0 +1,101 @@
'use client';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { LucidePencil } from 'lucide-react';
import { ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useEditableField } from './use-editable-field';
export interface EditableFieldProps {
/** Form field name */
name: string;
/** Placeholder text when empty */
placeholder?: string;
/** Whether field is required */
required?: boolean;
/** Whether to show edit icon */
showEditIcon?: boolean;
/** Custom className for the container */
className?: string;
/** Custom className for the input */
inputClassName?: string;
/** Custom className for the display text */
displayClassName?: string;
/** Aria label for accessibility */
ariaLabel?: string;
}
export function EditableField({
name,
placeholder,
required = true,
showEditIcon = true,
className,
inputClassName,
displayClassName,
ariaLabel,
}: EditableFieldProps) {
const { t } = useTranslation();
const { isEditing, inputRef, handleEnterEdit, handleKeyDown, handleBlur } =
useEditableField({ required });
const finalPlaceholder = placeholder ?? t('common.namePlaceholder');
return (
<RAGFlowFormItem
name={name}
className={cn('flex items-center gap-1.5', className)}
required={required}
>
{(field) =>
isEditing ? (
<Input
ref={inputRef as React.RefObject<HTMLInputElement>}
name={field.name}
value={field.value || ''}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
field.onChange(e.target.value)
}
onBlur={() => {
field.onBlur();
handleBlur(field.value || '', field.onChange);
}}
onKeyDown={handleKeyDown}
placeholder={finalPlaceholder}
className={cn(
'h-7 text-base font-medium px-2 py-0.5',
inputClassName,
)}
aria-label={ariaLabel}
/>
) : (
<div className="flex items-center gap-1.5">
<span
className={cn(
'text-base font-medium text-text-primary truncate flex-1 min-w-0',
!field.value && 'text-text-secondary italic',
displayClassName,
)}
>
{field.value || finalPlaceholder}
</span>
{showEditIcon && (
<button
type="button"
onClick={() => handleEnterEdit(field.value || '')}
className="p-1 text-text-secondary hover:text-text-primary transition-colors focus:outline-none focus:ring-1 focus:ring-accent-primary rounded shrink-0"
aria-label={ariaLabel ? `Edit ${ariaLabel}` : 'Edit'}
>
<LucidePencil className="w-3.5 h-3.5" />
</button>
)}
</div>
)
}
</RAGFlowFormItem>
);
}
export default EditableField;

View File

@@ -0,0 +1,144 @@
'use client';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { LucidePencil } from 'lucide-react';
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
export interface EditableTextareaProps {
/** Form field name */
name: string;
/** Placeholder text when empty */
placeholder?: string;
/** Whether to show edit icon */
showEditIcon?: boolean;
/** Custom className for the container */
className?: string;
/** Custom className for the textarea */
textareaClassName?: string;
/** Custom className for the display text */
displayClassName?: string;
/** Aria label for accessibility */
ariaLabel?: string;
/** Minimum number of rows for textarea */
minRows?: number;
/** Maximum number of rows for textarea */
maxRows?: number;
}
export function EditableTextarea({
name,
placeholder,
showEditIcon = true,
className,
textareaClassName,
displayClassName,
ariaLabel,
minRows = 2,
maxRows = 3,
}: EditableTextareaProps) {
const { t } = useTranslation();
const [isEditing, setIsEditing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const finalPlaceholder = placeholder ?? t('common.descriptionPlaceholder');
// Auto-focus when entering edit mode and move cursor to end
useEffect(() => {
if (isEditing) {
const frameId = requestAnimationFrame(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.focus();
const length = textarea.value.length;
textarea.setSelectionRange(length, length);
}
});
return () => cancelAnimationFrame(frameId);
}
}, [isEditing]);
const handleEnterEdit = useCallback(() => {
setIsEditing(true);
}, []);
const handleExitEdit = useCallback(() => {
setIsEditing(false);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape') {
setIsEditing(false);
}
},
[],
);
return (
<div className={cn('flex items-center gap-1.5 group', className)}>
<RAGFlowFormItem
name={name}
className={cn(isEditing ? 'flex-1 w-full' : 'w-auto min-w-0')}
>
{(field) =>
isEditing ? (
<Textarea
ref={textareaRef}
name={field.name}
value={field.value || ''}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) =>
field.onChange(e.target.value)
}
onBlur={() => {
field.onBlur();
handleExitEdit();
}}
onKeyDown={handleKeyDown}
placeholder={finalPlaceholder}
className={cn(
'min-h-[28px] text-sm text-text-secondary resize-none px-2 py-0.5',
textareaClassName,
)}
autoSize={{ minRows, maxRows }}
aria-label={ariaLabel}
/>
) : (
<p
className={cn(
'block w-full text-sm text-text-secondary line-clamp-2 text-left',
!field.value && 'italic',
displayClassName,
)}
onClick={handleEnterEdit}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleEnterEdit();
}
}}
>
{field.value || finalPlaceholder}
</p>
)
}
</RAGFlowFormItem>
{!isEditing && showEditIcon && (
<button
type="button"
onClick={handleEnterEdit}
className="p-1 text-text-secondary hover:text-text-primary transition-colors focus:outline-none focus:ring-1 focus:ring-accent-primary rounded shrink-0 opacity-0 group-hover:opacity-100"
aria-label={ariaLabel ? `Edit ${ariaLabel}` : 'Edit'}
>
<LucidePencil className="w-3.5 h-3.5" />
</button>
)}
</div>
);
}
export default EditableTextarea;

View File

@@ -0,0 +1,80 @@
'use client';
import { AvatarUpload } from '@/components/avatar-upload';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { cn } from '@/lib/utils';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { EditableField } from './editable-field';
import { EditableTextarea } from './editable-textarea';
export interface AvatarNameDescriptionProps {
/** Form field name for avatar/icon (default: 'icon') */
avatarField?: string;
/** Form field name for name/title (default: 'name') */
nameField?: string;
/** Form field name for description (default: 'description') */
descriptionField?: string;
/** Label for name field */
nameLabel?: string;
/** Label for description field */
descriptionLabel?: string;
/** Placeholder for name input */
namePlaceholder?: string;
/** Placeholder for description input */
descriptionPlaceholder?: string;
/** Custom className for the container */
className?: string;
/** Whether name is required */
nameRequired?: boolean;
/** Whether to show edit icons */
showEditIcons?: boolean;
}
export function AvatarNameDescription({
avatarField = 'icon',
nameField = 'name',
descriptionField = 'description',
nameLabel,
descriptionLabel,
namePlaceholder,
descriptionPlaceholder,
className,
nameRequired = true,
showEditIcons = true,
}: AvatarNameDescriptionProps) {
const { t } = useTranslation();
useFormContext(); // Ensure component is used within FormProvider
return (
<div className={cn('flex gap-3', className)}>
<RAGFlowFormItem name={avatarField}>
<AvatarUpload tips={''} />
</RAGFlowFormItem>
{/* Name & Description Section */}
<div className="flex-1 min-w-0 pt-1 space-y-1">
{/* Name Row - Using EditableField component */}
<EditableField
name={nameField}
placeholder={namePlaceholder}
required={nameRequired}
showEditIcon={showEditIcons}
ariaLabel={nameLabel ?? t('common.name')}
/>
{/* Description Row - Using EditableTextarea component */}
<div className="mt-0.5">
<EditableTextarea
name={descriptionField}
placeholder={descriptionPlaceholder}
showEditIcon={showEditIcons}
ariaLabel={descriptionLabel ?? t('common.description')}
/>
</div>
</div>
</div>
);
}
export default AvatarNameDescription;

View File

@@ -0,0 +1,80 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
interface UseEditableFieldOptions {
required?: boolean;
}
interface UseEditableFieldReturn {
isEditing: boolean;
inputRef: React.RefObject<HTMLInputElement | null>;
previousValueRef: React.RefObject<string>;
handleEnterEdit: (currentValue: string) => void;
handleExitEdit: () => void;
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
handleBlur: (currentValue: string, onChange: (value: string) => void) => void;
}
export function useEditableField(
options: UseEditableFieldOptions = {},
): UseEditableFieldReturn {
const { required = true } = options;
const [isEditing, setIsEditing] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const previousValueRef = useRef<string>('');
// Auto-focus when entering edit mode
useEffect(() => {
if (isEditing) {
const frameId = requestAnimationFrame(() => {
inputRef.current?.focus();
});
return () => cancelAnimationFrame(frameId);
}
}, [isEditing]);
const handleEnterEdit = useCallback((currentValue: string) => {
previousValueRef.current = currentValue;
setIsEditing(true);
}, []);
const handleExitEdit = useCallback(() => {
setIsEditing(false);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
setIsEditing(false);
}
if (e.key === 'Escape') {
setIsEditing(false);
}
},
[],
);
const handleBlur = useCallback(
(currentValue: string, onChange: (value: string) => void) => {
// If required and value is empty, restore to previous value
if (required && !currentValue?.trim()) {
onChange(previousValueRef.current);
}
setIsEditing(false);
},
[required],
);
return {
isEditing,
inputRef,
previousValueRef,
handleEnterEdit,
handleExitEdit,
handleKeyDown,
handleBlur,
};
}

View File

@@ -245,28 +245,6 @@ export const AvatarUpload = forwardRef<HTMLInputElement, AvatarUploadProps>(
}
}, [value]);
/*
useEffect(() => {
const container = containerRef.current;
setTimeout(() => {
// initCropArea();
if (imageToCrop && container && isCropModalOpen) {
container.addEventListener(
'wheel',
handleWheel as unknown as EventListener,
{ passive: false },
);
return () => {
container.removeEventListener(
'wheel',
handleWheel as unknown as EventListener,
);
};
}
}, 100);
}, [handleWheel, imageToCrop, isCropModalOpen]);
*/
return (
<div className="flex justify-start items-end space-x-2">
<div className="relative group">

View File

@@ -28,16 +28,24 @@ const ScrollBar = React.forwardRef<
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
interface ScrollAreaProps extends React.ComponentPropsWithoutRef<
typeof ScrollAreaPrimitive.Root
> {
viewportClassName?: string;
}
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
ScrollAreaProps
>(({ className, viewportClassName, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
<ScrollAreaPrimitive.Viewport
className={cn('h-full w-full rounded-[inherit]', viewportClassName)}
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar orientation="horizontal" />

View File

@@ -18,6 +18,7 @@ export default {
name: 'Name',
save: 'Save',
namePlaceholder: 'Please input name',
descriptionPlaceholder: 'Enter description',
next: 'Next',
create: 'Create',
edit: 'Edit',
@@ -43,6 +44,8 @@ export default {
languagePlaceholder: 'select your language',
copy: 'Copy',
copied: 'Copied',
viewMore: 'View more',
viewLess: 'View less',
comingSoon: 'Coming soon',
download: 'Download',
close: 'Close',

View File

@@ -18,6 +18,7 @@ export default {
name: '名称',
save: '保存',
namePlaceholder: '请输入名称',
descriptionPlaceholder: '请输入描述',
next: '下一步',
create: '创建',
edit: '编辑',
@@ -33,6 +34,8 @@ export default {
languagePlaceholder: '请选择语言',
copy: '复制',
copied: '复制成功',
viewMore: '查看更多',
viewLess: '收起',
comingSoon: '即将推出',
download: '下载',
close: '关闭',

View File

@@ -1,6 +1,6 @@
'use client';
import { AvatarUpload } from '@/components/avatar-upload';
import { AvatarNameDescription } from '@/components/avatar-name-description';
import { KnowledgeBaseFormField } from '@/components/knowledge-base-item';
import { MetadataFilter } from '@/components/metadata-filter';
import { SwitchFormField } from '@/components/switch-fom-field';
@@ -13,7 +13,6 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { useTranslate } from '@/hooks/common-hooks';
import { getDirAttribute } from '@/utils/text-direction';
@@ -22,58 +21,12 @@ import { useFormContext } from 'react-hook-form';
export default function ChatBasicSetting() {
const { t } = useTranslate('chat');
const form = useFormContext();
const nameValue = form.watch('name');
const descriptionValue = form.watch('description');
const emptyResponseValue = form.watch('prompt_config.empty_response');
const prologueValue = form.watch('prompt_config.prologue');
return (
<div className="space-y-8">
<FormField
control={form.control}
name={'icon'}
render={({ field }) => (
<div className="space-y-6">
<FormItem className="w-full">
<FormLabel>{t('assistantAvatar')}</FormLabel>
<FormControl>
<AvatarUpload {...field}></AvatarUpload>
</FormControl>
<FormMessage />
</FormItem>
</div>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel required>{t('assistantName')}</FormLabel>
<FormControl>
<Input {...field} dir={getDirAttribute(nameValue || '')}></Input>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('description')}</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={t('descriptionPlaceholder')}
dir={getDirAttribute(descriptionValue || '')}
></Textarea>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<AvatarNameDescription />
<FormField
control={form.control}
name={'prompt_config.empty_response'}

View File

@@ -158,7 +158,7 @@ export function ChatSettings({ hasSingleChatBox }: ChatSettingsProps) {
onSubmit={form.handleSubmit(onSubmit, onInvalid)}
className="flex-1 flex flex-col min-h-0"
>
<ScrollArea>
<ScrollArea viewportClassName="[&>div]:!block">
<section className="p-5 space-y-6 overflow-auto flex-1 min-h-0">
<ChatBasicSetting></ChatBasicSetting>
<Separator />

View File

@@ -0,0 +1,108 @@
import { Button } from '@/components/ui/button';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { ReactNode, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
interface ExpandableContentProps {
children: ReactNode;
maxHeight?: number;
className?: string;
}
export default function ExpandableContent({
children,
maxHeight = 208, // 52 * 4 = 208px (max-h-52)
className = '',
}: ExpandableContentProps) {
const { t } = useTranslation();
const contentRef = useRef<HTMLDivElement>(null);
const [isExpanded, setIsExpanded] = useState(false);
const [isOverflowing, setIsOverflowing] = useState(false);
useEffect(() => {
const element = contentRef.current;
if (!element) return;
const checkOverflow = () => {
setIsOverflowing(element.scrollHeight > maxHeight);
};
checkOverflow();
// ResizeObserver handles element size changes
const resizeObserver = new ResizeObserver(checkOverflow);
resizeObserver.observe(element);
// MutationObserver handles DOM changes (useful for async markdown content)
const mutationObserver = new MutationObserver(checkOverflow);
mutationObserver.observe(element, {
childList: true,
subtree: true,
characterData: true,
});
// Listen for images to load (they affect scrollHeight)
const images = element.querySelectorAll('img');
const imageLoadPromises = Array.from(images).map(
(img) =>
new Promise<void>((resolve) => {
if (img.complete) {
resolve();
} else {
img.addEventListener('load', () => resolve(), { once: true });
img.addEventListener('error', () => resolve(), { once: true });
}
}),
);
// Re-check after all images are loaded
Promise.all(imageLoadPromises).then(checkOverflow);
return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, [maxHeight, children]);
const toggleExpand = () => {
setIsExpanded(!isExpanded);
};
return (
<div className="relative">
<div
ref={contentRef}
className={`overflow-hidden scrollbar-thin transition-all duration-300 ${className}`}
style={{
maxHeight: isExpanded ? contentRef.current?.scrollHeight : maxHeight,
}}
>
{children}
</div>
{!isExpanded && isOverflowing && (
<div className="absolute bottom-0 left-0 right-0 h-20 bg-gradient-to-t from-bg-base to-transparent pointer-events-none" />
)}
{isOverflowing && (
<Button
size="sm"
onClick={toggleExpand}
className="absolute bottom-2 left-1/2 -translate-x-1/2"
>
{isExpanded ? (
<>
<ChevronUp size={14} />
<span>{t('common.viewLess')}</span>
</>
) : (
<>
<ChevronDown size={14} />
<span>{t('common.viewMore')}</span>
</>
)}
</Button>
)}
</div>
);
}

View File

@@ -1,20 +1,11 @@
import { useFetchTokenListBeforeOtherStep } from '@/components/embed-dialog/use-show-embed-dialog';
import { Button } from '@/components/ui/button';
import { SharedFrom } from '@/constants/chat';
import {
useFetchTenantInfo,
useFetchUserInfo,
} from '@/hooks/use-user-setting-request';
import { Routes } from '@/routes';
import { Send, Settings } from 'lucide-react';
import { useFetchUserInfo } from '@/hooks/use-user-setting-request';
import { Settings } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ISearchAppDetailProps,
useFetchSearchDetail,
} from '../next-searches/hooks';
import EmbedAppModal from './embed-app-modal';
import { useCheckSettings } from './hooks';
import './index.less';
import SearchHome from './search-home';
@@ -24,15 +15,10 @@ import SearchingPage from './searching';
export default function SearchPage() {
const [isSearching, setIsSearching] = useState(false);
const { data: SearchData } = useFetchSearchDetail();
const { beta, handleOperate } = useFetchTokenListBeforeOtherStep();
const [openSetting, setOpenSetting] = useState(false);
const [openEmbed, setOpenEmbed] = useState(false);
const [searchText, setSearchText] = useState('');
const { data: tenantInfo } = useFetchTenantInfo();
const { data: userInfo } = useFetchUserInfo();
const tenantId = tenantInfo.tenant_id;
const { t } = useTranslation();
const { openSetting: checkOpenSetting } = useCheckSettings(
SearchData as ISearchAppDetailProps,
);
@@ -47,9 +33,12 @@ export default function SearchPage() {
}, [isSearching]);
return (
<section className="size-full relative" data-testid="search-detail">
<div className="flex gap-3 w-full bg-bg-base">
<div className="flex-1">
<section
className="size-full flex-1 relative px-5 pb-5 flex pt-4"
data-testid="search-detail"
>
<div className="flex gap-3 w-full bg-bg-base border-0.5 border-border-button">
<div className="flex-1 min-w-0">
{!isSearching && (
<div className="animate-fade-in-down">
<SearchHome
@@ -63,7 +52,7 @@ export default function SearchPage() {
</div>
)}
{isSearching && (
<div className="animate-fade-in-up">
<div className="animate-fade-in-up h-full">
<SearchingPage
setIsSearching={setIsSearching}
searchText={searchText}
@@ -81,57 +70,15 @@ export default function SearchPage() {
data={SearchData as ISearchAppDetailProps}
/>
)}
{
<EmbedAppModal
open={openEmbed}
setOpen={setOpenEmbed}
url={Routes.SearchShare}
token={SearchData?.id as string}
from={SharedFrom.Search}
tenantId={tenantId}
beta={beta}
/>
}
{
// <EmbedDialog
// visible={openEmbed}
// hideModal={setOpenEmbed}
// token={SearchData?.id as string}
// from={SharedFrom.Search}
// beta={beta}
// isAgent={false}
// ></EmbedDialog>
}
</div>
<div className="absolute end-5 top-4">
<Button
onClick={() => {
handleOperate().then((res) => {
console.log(res, 'res');
if (res) {
setOpenEmbed(!openEmbed);
}
});
}}
>
<Send />
<div>{t('search.embedApp')}</div>
</Button>
</div>
{!isSearching && (
<div className="absolute start-5 bottom-12 ">
<Button
variant="transparent"
className="bg-bg-card"
onClick={() => setOpenSetting(!openSetting)}
>
<Settings className="text-text-secondary" />
<div className="text-text-secondary">
{t('search.searchSettings')}
</div>
</Button>
</div>
)}
<Button
variant="transparent"
className="bg-bg-card ml-5"
onClick={() => setOpenSetting(!openSetting)}
>
<Settings className="text-text-secondary" />
</Button>
</section>
);
}

View File

@@ -1,5 +1,11 @@
import IndentedTree from '@/components/indented-tree/indented-tree';
import { Progress } from '@/components/ui/progress';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { IModalProps } from '@/interfaces/common';
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -9,41 +15,51 @@ interface IProps extends IModalProps<any> {
data: any;
}
const MindMapDrawer = ({ data, hideModal, loading }: IProps) => {
const MindMapDrawer = ({ data, hideModal, loading, visible }: IProps) => {
const { t } = useTranslation();
const percent = usePendingMindMap();
return (
<div className="w-full h-full">
<div className="flex w-full justify-between items-center mb-2">
<div className="text-text-primary font-medium text-base">
{t('chunk.mind')}
<Sheet open={visible} modal={false}>
<SheetContent
className="top-16 p-0 flex flex-col gap-0"
closeIcon={false}
>
<SheetHeader className="border-b py-2 px-4">
<SheetTitle className="hidden"></SheetTitle>
<div className="flex w-full justify-between items-center">
<div className="text-text-primary font-medium text-base">
{t('chunk.mind')}
</div>
<X
className="text-text-primary cursor-pointer"
size={16}
onClick={() => {
hideModal?.();
}}
/>
</div>
</SheetHeader>
<div className="flex-1 p-4 overflow-hidden">
{loading && (
<div className="rounded-lg w-full h-full">
<Progress value={percent} className="h-1 flex-1 min-w-10" />
</div>
)}
{!loading && (
<div className="bg-bg-card rounded-lg w-full h-full">
<IndentedTree
data={data}
show
style={{
width: '100%',
height: '100%',
}}
></IndentedTree>
</div>
)}
</div>
<X
className="text-text-primary cursor-pointer"
size={16}
onClick={() => {
hideModal?.();
}}
/>
</div>
{loading && (
<div className=" rounded-lg p-4 w-full h-full">
<Progress value={percent} className="h-1 flex-1 min-w-10" />
</div>
)}
{!loading && (
<div className="bg-bg-card rounded-lg p-4 w-full h-full">
<IndentedTree
data={data}
show
style={{
width: '100%',
height: '100%',
}}
></IndentedTree>
</div>
)}
</div>
</SheetContent>
</Sheet>
);
};

View File

@@ -0,0 +1,56 @@
import { useFetchTokenListBeforeOtherStep } from '@/components/embed-dialog/use-show-embed-dialog';
import { Button } from '@/components/ui/button';
import { SharedFrom } from '@/constants/chat';
import { useFetchTenantInfo } from '@/hooks/use-user-setting-request';
import { cn } from '@/lib/utils';
import { Routes } from '@/routes';
import { Send } from 'lucide-react';
import { useState } from 'react';
import { useFetchSearchDetail } from '../next-searches/hooks';
import EmbedAppModal from './embed-app-modal';
export function RAGFlowLogo({
onClick,
}: {
onClick?: React.MouseEventHandler<HTMLHeadingElement>;
}) {
const [openEmbed, setOpenEmbed] = useState(false);
const { beta, handleOperate } = useFetchTokenListBeforeOtherStep();
const { data: tenantInfo } = useFetchTenantInfo();
const tenantId = tenantInfo.tenant_id;
const { data: SearchData } = useFetchSearchDetail();
return (
<div className="flex gap-4 items-center">
<h1
onClick={onClick}
className={cn(
'text-4xl font-bold bg-gradient-to-l from-[#40EBE3] to-[#4A51FF] bg-clip-text',
)}
>
RAGFlow
</h1>
<Button
variant={'outline'}
onClick={() => {
handleOperate().then((res) => {
if (res) {
setOpenEmbed(!openEmbed);
}
});
}}
>
<Send />
</Button>
<EmbedAppModal
open={openEmbed}
setOpen={setOpenEmbed}
url={Routes.SearchShare}
token={SearchData?.id as string}
from={SharedFrom.Search}
tenantId={tenantId}
beta={beta}
/>
</div>
);
}

View File

@@ -2,11 +2,11 @@ import { Input } from '@/components/originui/input';
import Spotlight from '@/components/spotlight';
import message from '@/components/ui/message';
import { IUserInfo } from '@/interfaces/database/user-setting';
import { cn } from '@/lib/utils';
import { Search } from 'lucide-react';
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import './index.less';
import { RAGFlowLogo } from './ragflow-log';
export default function SearchPage({
isSearching,
@@ -28,14 +28,7 @@ export default function SearchPage({
return (
<section className="relative w-full flex transition-all justify-center items-center mt-[15vh]">
<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-l from-[#40EBE3] to-[#4A51FF] bg-clip-text',
)}
>
RAGFlow
</h1>
<RAGFlowLogo></RAGFlowLogo>
<div className="rounded-lg text-primary text-xl sticky flex justify-center w-full transform scale-100 mt-8 p-6 h-[240px] border">
{!isSearching && <Spotlight className="z-0" />}
<div className="flex flex-col justify-center items-center w-2/3">

View File

@@ -1,6 +1,6 @@
// src/pages/next-search/search-setting.tsx
import { AvatarUpload } from '@/components/avatar-upload';
import AvatarNameDescription from '@/components/avatar-name-description';
import { KnowledgeBaseFormField } from '@/components/knowledge-base-item';
import {
LlmSettingFieldItems,
@@ -25,7 +25,6 @@ import { Input } from '@/components/ui/input';
import { RAGFlowSelect } from '@/components/ui/select';
import { Spin } from '@/components/ui/spin';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
useComposeLlmOptionsByModelTypes,
useSelectLlmOptionsByModelType,
@@ -45,10 +44,6 @@ import {
IllmSettingProps,
useUpdateSearch,
} from '../next-searches/hooks';
// import {
// LlmSettingFieldItems,
// LlmSettingSchema,
// } from './search-setting-aisummery-config';
interface SearchSettingProps {
open: boolean;
@@ -73,9 +68,17 @@ const SearchSettingFormSchema = z
use_rerank: z.boolean(),
top_k: z.number(),
summary: z.boolean(),
llm_setting: z.object(LlmSettingSchema),
llm_setting: z.object({
...LlmSettingSchema,
parameter: z.string().optional(),
}),
related_search: z.boolean(),
query_mindmap: z.boolean(),
doc_ids: z.array(z.string()),
chat_id: z.string(),
highlight: z.boolean(),
keyword: z.boolean(),
chat_settingcross_languages: z.array(z.string()),
...MetadataFilterSchema,
}),
})
@@ -135,7 +138,7 @@ const SearchSetting: React.FC<SearchSettingProps> = ({
chat_id: search_config?.chat_id || '',
llm_setting: {
llm_id: search_config?.chat_id || '',
parameter: llm_setting?.parameter,
parameter: llm_setting?.parameter || '',
temperature: llm_setting?.temperature || 0,
top_p: llm_setting?.top_p || 0,
frequency_penalty: llm_setting?.frequency_penalty || 0,
@@ -246,7 +249,7 @@ const SearchSetting: React.FC<SearchSettingProps> = ({
return (
<div
className={cn(
'text-text-primary border p-4 pb-12 rounded-lg',
'text-text-primary border p-4 pb-12 rounded-lg ',
{
'animate-fade-in-right': open,
'animate-fade-out-right': !open,
@@ -279,64 +282,8 @@ const SearchSetting: React.FC<SearchSettingProps> = ({
)}
className="space-y-6"
>
{/* Name */}
<FormField
control={formMethods.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<span className="text-destructive mr-1"> *</span>
{t('search.name')}
</FormLabel>
<FormControl>
<Input placeholder={t('search.name')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Avatar */}
<FormField
control={formMethods.control}
name="avatar"
render={({ field }) => (
<FormItem>
<FormLabel>{t('search.avatar')}</FormLabel>
<FormControl>
<AvatarUpload {...field}></AvatarUpload>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Description */}
<FormField
control={formMethods.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('search.description')}</FormLabel>
<FormControl>
<Textarea
placeholder={descriptionDefaultValue}
{...field}
onFocus={() => {
if (field.value === descriptionDefaultValue) {
field.onChange('');
}
}}
onBlur={() => {
if (field.value === '') {
field.onChange(descriptionDefaultValue);
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<AvatarNameDescription avatarField="avatar" />
<KnowledgeBaseFormField
name="search_config.kb_ids"
required

View File

@@ -14,24 +14,20 @@ import {
import { RAGFlowPagination } from '@/components/ui/ragflow-pagination';
import { IReference } from '@/interfaces/database/chat';
import { cn } from '@/lib/utils';
import { citationMarkerReg } from '@/utils/citation-utils';
import { getDirAttribute } from '@/utils/text-direction';
import DOMPurify from 'dompurify';
import { isEmpty } from 'lodash';
import { BrainCircuit, Search, X } from 'lucide-react';
import { ListTree, Search, X } from 'lucide-react';
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ISearchAppDetailProps } from '../next-searches/hooks';
import PdfDrawer from './document-preview-modal';
import ExpandableContent from './expandable-content';
import { ISearchReturnProps } from './hooks';
import './index.less';
import MarkdownContent from './markdown-content';
import MindMapDrawer from './mindmap-drawer';
import { RAGFlowLogo } from './ragflow-log';
import RetrievalDocuments from './retrieval-documents';
const getDirectionText = (content: string) =>
content.replace(/<[^>]+>/g, ' ').replace(citationMarkerReg, '');
export default function SearchingView({
setIsSearching,
searchData,
@@ -66,12 +62,7 @@ export default function SearchingView({
searchData: ISearchAppDetailProps;
}) {
const { t } = useTranslation();
// useEffect(() => {
// const changeLanguage = async () => {
// await i18n.changeLanguage('zh');
// };
// changeLanguage();
// }, [i18n]);
const [searchtext, setSearchtext] = useState<string>('');
const [retrievalLoading, setRetrievalLoading] = useState(false);
@@ -81,28 +72,23 @@ export default function SearchingView({
return (
<section
className={cn(
'relative w-full flex transition-all justify-start items-center',
'relative w-full flex transition-all justify-start items-center h-full',
)}
>
{/* search header */}
<div
className={cn(
'relative z-10 px-8 pt-8 flex text-transparent justify-start items-start w-full',
'relative z-10 px-8 pt-8 flex text-transparent justify-start items-start w-full h-full',
)}
>
<h1
className={cn(
'text-4xl font-bold bg-gradient-to-l from-[#40EBE3] to-[#4A51FF] bg-clip-text cursor-pointer',
)}
<RAGFlowLogo
onClick={() => {
setIsSearching?.(false);
}}
>
RAGFlow
</h1>
></RAGFlowLogo>
<div
className={cn(
' rounded-lg text-primary text-xl sticky flex flex-col justify-center w-2/3 max-w-[780px] transform scale-100 ml-16 ',
' rounded-lg text-primary text-xl sticky flex flex-col justify-center w-2/3 transform scale-100 ml-16 h-full',
)}
>
<div className={cn('flex flex-col justify-start items-start w-full')}>
@@ -156,7 +142,7 @@ export default function SearchingView({
</div>
{/* search body */}
<div
className="w-full mt-5 overflow-auto scrollbar-none "
className="w-full mt-5 overflow-auto scrollbar-thin "
style={{ height: 'calc(100vh - 250px)' }}
>
{searchData.search_config.summary && !isSearchStrEmpty && (
@@ -168,13 +154,15 @@ export default function SearchingView({
<SkeletonCard className=" mt-2" />
) : (
answer.answer && (
<div className="border rounded-lg p-4 mt-3 max-h-52 overflow-auto scrollbar-none">
<MarkdownContent
loading={sendingLoading}
content={answer.answer}
reference={answer.reference ?? ({} as IReference)}
clickDocumentButton={clickDocumentButton}
></MarkdownContent>
<div className="border rounded-lg p-4 mt-3">
<ExpandableContent maxHeight={208}>
<MarkdownContent
loading={sendingLoading}
content={answer.answer}
reference={answer.reference ?? ({} as IReference)}
clickDocumentButton={clickDocumentButton}
/>
</ExpandableContent>
</div>
)
)}
@@ -206,42 +194,15 @@ export default function SearchingView({
return (
<div key={index}>
<div className="w-full flex flex-col">
<div className="w-full highlightContent">
<div className="w-full">
{(chunk.image_id || chunk.img_id) && (
<ImageWithPopover
id={chunk.image_id || chunk.img_id}
></ImageWithPopover>
)}
<Popover>
<PopoverTrigger asChild>
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(
`${
chunk.highlight ??
chunk.content_with_weight ??
''
}...`,
),
}}
className="text-sm text-text-primary mb-1"
dir={getDirAttribute(
getDirectionText(
chunk.highlight ??
chunk.content_with_weight ??
'',
),
)}
></div>
</PopoverTrigger>
<PopoverContent className="text-text-primary !w-full max-w-lg ">
<div className="max-h-96 overflow-auto scrollbar-thin">
<HighLightMarkdown>
{chunk.content_with_weight}
</HighLightMarkdown>
</div>
</PopoverContent>
</Popover>
<HighLightMarkdown>
{chunk.content_with_weight}
</HighLightMarkdown>
</div>
<div
className="flex gap-2 items-center text-xs text-text-secondary border p-1 rounded-lg w-fit mt-3"
@@ -331,13 +292,13 @@ export default function SearchingView({
searchData.search_config.query_mindmap && (
<Popover>
<PopoverTrigger asChild>
<div
className="rounded-lg h-16 w-16 p-0 absolute top-28 right-3 z-30 border cursor-pointer flex justify-center items-center bg-bg-card"
<Button
onClick={showMindMapModal}
variant={'outline'}
className="absolute top-28 right-3 z-30 rounded-full size-6"
>
{/* <SvgIcon name="paper-clip" width={24} height={30}></SvgIcon> */}
<BrainCircuit size={36} />
</div>
<ListTree />
</Button>
</PopoverTrigger>
<PopoverContent className="w-fit">{t('chunk.mind')}</PopoverContent>
</Popover>