feat: add ragflow web project & add pnpm workspace file

This commit is contained in:
2025-11-09 11:18:58 +08:00
parent ed6e0ab282
commit b2053760be
1566 changed files with 218623 additions and 57 deletions

View File

@@ -0,0 +1,75 @@
import CopyToClipboard from '@/components/copy-to-clipboard';
import { useTranslate } from '@/hooks/common-hooks';
import { IModalProps } from '@/interfaces/common';
import { IToken } from '@/interfaces/database/chat';
import { formatDate } from '@/utils/date';
import { DeleteOutlined } from '@ant-design/icons';
import type { TableProps } from 'antd';
import { Button, Modal, Space, Table } from 'antd';
import { useOperateApiKey } from '../hooks';
const ChatApiKeyModal = ({
dialogId,
hideModal,
idKey,
}: IModalProps<any> & { dialogId?: string; idKey: string }) => {
const { createToken, removeToken, tokenList, listLoading, creatingLoading } =
useOperateApiKey(idKey, dialogId);
const { t } = useTranslate('chat');
const columns: TableProps<IToken>['columns'] = [
{
title: 'Token',
dataIndex: 'token',
key: 'token',
render: (text) => <a>{text}</a>,
},
{
title: t('created'),
dataIndex: 'create_date',
key: 'create_date',
render: (text) => formatDate(text),
},
{
title: t('action'),
key: 'action',
render: (_, record) => (
<Space size="middle">
<CopyToClipboard text={record.token}></CopyToClipboard>
<DeleteOutlined onClick={() => removeToken(record.token)} />
</Space>
),
},
];
return (
<>
<Modal
title={t('apiKey')}
open
onCancel={hideModal}
cancelButtonProps={{ style: { display: 'none' } }}
style={{ top: 300 }}
onOk={hideModal}
width={'50vw'}
>
<Table
columns={columns}
dataSource={tokenList}
rowKey={'token'}
loading={listLoading}
pagination={false}
/>
<Button
onClick={createToken}
loading={creatingLoading}
disabled={tokenList?.length > 0}
>
{t('createNewKey')}
</Button>
</Modal>
</>
);
};
export default ChatApiKeyModal;

View File

@@ -0,0 +1,71 @@
import { useSetModalState, useTranslate } from '@/hooks/common-hooks';
import { LangfuseCard } from '@/pages/user-setting/setting-model/langfuse';
import apiDoc from '@parent/docs/references/http_api_reference.md';
import MarkdownPreview from '@uiw/react-markdown-preview';
import { Button, Card, Flex, Space } from 'antd';
import ChatApiKeyModal from '../chat-api-key-modal';
import { usePreviewChat } from '../hooks';
import BackendServiceApi from './backend-service-api';
import MarkdownToc from './markdown-toc';
const ApiContent = ({
id,
idKey,
hideChatPreviewCard = false,
}: {
id?: string;
idKey: string;
hideChatPreviewCard?: boolean;
}) => {
const { t } = useTranslate('chat');
const {
visible: apiKeyVisible,
hideModal: hideApiKeyModal,
showModal: showApiKeyModal,
} = useSetModalState();
// const { embedVisible, hideEmbedModal, showEmbedModal, embedToken } =
// useShowEmbedModal(idKey);
const { handlePreview } = usePreviewChat(idKey);
return (
<div className="pb-2">
<Flex vertical gap={'middle'}>
<BackendServiceApi show={showApiKeyModal}></BackendServiceApi>
{!hideChatPreviewCard && (
<Card title={`${name} Web App`}>
<Flex gap={8} vertical>
<Space size={'middle'}>
<Button onClick={handlePreview}>{t('preview')}</Button>
{/* <Button onClick={() => showEmbedModal(id)}>
{t('embedded')}
</Button> */}
</Space>
</Flex>
</Card>
)}
<div style={{ position: 'relative' }}>
<MarkdownToc content={apiDoc} />
</div>
<MarkdownPreview source={apiDoc}></MarkdownPreview>
</Flex>
{apiKeyVisible && (
<ChatApiKeyModal
hideModal={hideApiKeyModal}
dialogId={id}
idKey={idKey}
></ChatApiKeyModal>
)}
{/* {embedVisible && (
<EmbedModal
token={embedToken}
visible={embedVisible}
hideModal={hideEmbedModal}
></EmbedModal>
)} */}
<LangfuseCard></LangfuseCard>
</div>
);
};
export default ApiContent;

View File

@@ -0,0 +1,35 @@
import { Button, Card, Flex, Space, Typography } from 'antd';
import { useTranslate } from '@/hooks/common-hooks';
import styles from './index.less';
const { Paragraph } = Typography;
const BackendServiceApi = ({ show }: { show(): void }) => {
const { t } = useTranslate('chat');
return (
<Card
title={
<Space size={'large'}>
<span>RAGFlow API</span>
<Button onClick={show} type="primary">
{t('apiKey')}
</Button>
</Space>
}
>
<Flex gap={8} align="center">
<b>{t('backendServiceApi')}</b>
<Paragraph
copyable={{ text: `${location.origin}` }}
className={styles.apiLinkText}
>
{location.origin}
</Paragraph>
</Flex>
</Card>
);
};
export default BackendServiceApi;

View File

@@ -0,0 +1,20 @@
.chartWrapper {
height: 40vh;
overflow: auto;
}
.chartItem {
height: 300px;
padding: 10px 0 50px;
}
.chartLabel {
display: inline-block;
padding-left: 60px;
padding-bottom: 20px;
}
.apiLinkText {
.linkText();
margin: 0 !important;
background-color: rgba(255, 255, 255, 0.1);
}

View File

@@ -0,0 +1,31 @@
import { useTranslate } from '@/hooks/common-hooks';
import { IModalProps } from '@/interfaces/common';
import { Modal } from 'antd';
import ApiContent from './api-content';
const ChatOverviewModal = ({
visible,
hideModal,
id,
idKey,
}: IModalProps<any> & { id: string; name?: string; idKey: string }) => {
const { t } = useTranslate('chat');
return (
<>
<Modal
title={t('overview')}
open={visible}
onCancel={hideModal}
cancelButtonProps={{ style: { display: 'none' } }}
onOk={hideModal}
width={'100vw'}
okText={t('close', { keyPrefix: 'common' })}
>
<ApiContent id={id} idKey={idKey}></ApiContent>
</Modal>
</>
);
};
export default ChatOverviewModal;

View File

@@ -0,0 +1,74 @@
import { Anchor } from 'antd';
import type { AnchorLinkItemProps } from 'antd/es/anchor/Anchor';
import React, { useEffect, useState } from 'react';
interface MarkdownTocProps {
content: string;
}
const MarkdownToc: React.FC<MarkdownTocProps> = ({ content }) => {
const [items, setItems] = useState<AnchorLinkItemProps[]>([]);
useEffect(() => {
const generateTocItems = () => {
const headings = document.querySelectorAll(
'.wmde-markdown h2, .wmde-markdown h3',
);
const tocItems: AnchorLinkItemProps[] = [];
let currentH2Item: AnchorLinkItemProps | null = null;
headings.forEach((heading) => {
const title = heading.textContent || '';
const id = heading.id;
const isH2 = heading.tagName.toLowerCase() === 'h2';
if (id && title) {
const item: AnchorLinkItemProps = {
key: id,
href: `#${id}`,
title,
};
if (isH2) {
currentH2Item = item;
tocItems.push(item);
} else {
if (currentH2Item) {
if (!currentH2Item.children) {
currentH2Item.children = [];
}
currentH2Item.children.push(item);
} else {
tocItems.push(item);
}
}
}
});
setItems(tocItems.slice(1));
};
setTimeout(generateTocItems, 100);
}, [content]);
return (
<div
className="markdown-toc bg-bg-base text-text-primary shadow shadow-text-secondary"
style={{
position: 'fixed',
right: 20,
top: 100,
bottom: 150,
width: 200,
padding: '10px',
maxHeight: 'calc(100vh - 170px)',
overflowY: 'auto',
zIndex: 1000,
}}
>
<Anchor items={items} affix={false} />
</div>
);
};
export default MarkdownToc;

View File

@@ -0,0 +1,40 @@
import LineChart from '@/components/line-chart';
import { useTranslate } from '@/hooks/common-hooks';
import { IStats } from '@/interfaces/database/chat';
import { formatDate } from '@/utils/date';
import camelCase from 'lodash/camelCase';
import { useSelectChartStatsList } from '../hooks';
import styles from './index.less';
const StatsLineChart = ({ statsType }: { statsType: keyof IStats }) => {
const { t } = useTranslate('chat');
const chartList = useSelectChartStatsList();
const list =
chartList[statsType]?.map((x) => ({
...x,
xAxis: formatDate(x.xAxis),
})) ?? [];
return (
<div className={styles.chartItem}>
<b className={styles.chartLabel}>{t(camelCase(statsType))}</b>
<LineChart data={list}></LineChart>
</div>
);
};
const StatsChart = () => {
return (
<div className={styles.chartWrapper}>
<StatsLineChart statsType={'pv'}></StatsLineChart>
<StatsLineChart statsType={'round'}></StatsLineChart>
<StatsLineChart statsType={'speed'}></StatsLineChart>
<StatsLineChart statsType={'thumb_up'}></StatsLineChart>
<StatsLineChart statsType={'tokens'}></StatsLineChart>
<StatsLineChart statsType={'uv'}></StatsLineChart>
</div>
);
};
export default StatsChart;

View File

@@ -0,0 +1,21 @@
.codeCard {
.clearCardBody();
}
.codeText {
padding: 10px;
background-color: #ffffff09;
}
.id {
.linkText();
}
.darkBg {
background-color: rgb(69, 68, 68);
}
.darkId {
color: white;
.darkBg();
}

View File

@@ -0,0 +1,170 @@
import CopyToClipboard from '@/components/copy-to-clipboard';
import HightLightMarkdown from '@/components/highlight-markdown';
import { SharedFrom } from '@/constants/chat';
import { useTranslate } from '@/hooks/common-hooks';
import { IModalProps } from '@/interfaces/common';
import {
Card,
Checkbox,
Form,
Modal,
Select,
Tabs,
TabsProps,
Typography,
} from 'antd';
import { useMemo, useState } from 'react';
import { useIsDarkTheme } from '@/components/theme-provider';
import {
LanguageAbbreviation,
LanguageAbbreviationMap,
} from '@/constants/common';
import { cn } from '@/lib/utils';
import styles from './index.less';
const { Paragraph, Link } = Typography;
const EmbedModal = ({
visible,
hideModal,
token = '',
form,
beta = '',
isAgent,
}: IModalProps<any> & {
token: string;
form: SharedFrom;
beta: string;
isAgent: boolean;
}) => {
const { t } = useTranslate('chat');
const isDarkTheme = useIsDarkTheme();
const [visibleAvatar, setVisibleAvatar] = useState(false);
const [locale, setLocale] = useState('');
const languageOptions = useMemo(() => {
return Object.values(LanguageAbbreviation).map((x) => ({
label: LanguageAbbreviationMap[x],
value: x,
}));
}, []);
const generateIframeSrc = () => {
let src = `${location.origin}/chat/share?shared_id=${token}&from=${form}&auth=${beta}`;
if (visibleAvatar) {
src += '&visible_avatar=1';
}
if (locale) {
src += `&locale=${locale}`;
}
return src;
};
const iframeSrc = generateIframeSrc();
const text = `
~~~ html
<iframe
src="${iframeSrc}"
style="width: 100%; height: 100%; min-height: 600px"
frameborder="0"
>
</iframe>
~~~
`;
const items: TabsProps['items'] = [
{
key: '1',
label: t('fullScreenTitle'),
children: (
<Card
title={t('fullScreenDescription')}
extra={<CopyToClipboard text={text}></CopyToClipboard>}
className={styles.codeCard}
>
<div className="p-2">
<h2 className="mb-3">Option:</h2>
<Form.Item
label={t('avatarHidden')}
labelCol={{ span: 6 }}
wrapperCol={{ span: 18 }}
>
<Checkbox
checked={visibleAvatar}
onChange={(e) => setVisibleAvatar(e.target.checked)}
></Checkbox>
</Form.Item>
<Form.Item
label={t('locale')}
labelCol={{ span: 6 }}
wrapperCol={{ span: 18 }}
>
<Select
placeholder="Select a locale"
onChange={(value) => setLocale(value)}
options={languageOptions}
style={{ width: '100%' }}
/>
</Form.Item>
</div>
<HightLightMarkdown>{text}</HightLightMarkdown>
</Card>
),
},
{
key: '2',
label: t('partialTitle'),
children: t('comingSoon'),
},
{
key: '3',
label: t('extensionTitle'),
children: t('comingSoon'),
},
];
const onChange = (key: string) => {
console.log(key);
};
return (
<Modal
title={t('embedIntoSite', { keyPrefix: 'common' })}
open={visible}
style={{ top: 300 }}
width={'50vw'}
onOk={hideModal}
onCancel={hideModal}
>
<Tabs defaultActiveKey="1" items={items} onChange={onChange} />
<div className="text-base font-medium mt-4 mb-1">
{t(isAgent ? 'flow' : 'chat', { keyPrefix: 'header' })}
<span className="ml-1 inline-block">ID</span>
</div>
<Paragraph
copyable={{ text: token }}
className={cn(styles.id, {
[styles.darkId]: isDarkTheme,
})}
>
{token}
</Paragraph>
<Link
href={
isAgent
? 'https://ragflow.io/docs/dev/http_api_reference#create-session-with-agent'
: 'https://ragflow.io/docs/dev/http_api_reference#create-session-with-chat-assistant'
}
target="_blank"
>
{t('howUseId', { keyPrefix: isAgent ? 'flow' : 'chat' })}
</Link>
</Modal>
);
};
export default EmbedModal;

View File

@@ -0,0 +1,179 @@
import { SharedFrom } from '@/constants/chat';
import {
useSetModalState,
useShowDeleteConfirm,
useTranslate,
} from '@/hooks/common-hooks';
import {
useCreateSystemToken,
useFetchManualSystemTokenList,
useFetchSystemTokenList,
useRemoveSystemToken,
} from '@/hooks/user-setting-hooks';
import { IStats } from '@/interfaces/database/chat';
import { useQueryClient } from '@tanstack/react-query';
import { message } from 'antd';
import { useCallback } from 'react';
export const useOperateApiKey = (idKey: string, dialogId?: string) => {
const { removeToken } = useRemoveSystemToken();
const { createToken, loading: creatingLoading } = useCreateSystemToken();
const { data: tokenList, loading: listLoading } = useFetchSystemTokenList();
const showDeleteConfirm = useShowDeleteConfirm();
const onRemoveToken = (token: string) => {
showDeleteConfirm({
onOk: () => removeToken(token),
});
};
const onCreateToken = useCallback(() => {
createToken({ [idKey]: dialogId });
}, [createToken, idKey, dialogId]);
return {
removeToken: onRemoveToken,
createToken: onCreateToken,
tokenList,
creatingLoading,
listLoading,
};
};
type ChartStatsType = {
[k in keyof IStats]: Array<{ xAxis: string; yAxis: number }>;
};
export const useSelectChartStatsList = (): ChartStatsType => {
const queryClient = useQueryClient();
const data = queryClient.getQueriesData({ queryKey: ['fetchStats'] });
const stats: IStats = (data.length > 0 ? data[0][1] : {}) as IStats;
return Object.keys(stats).reduce((pre, cur) => {
const item = stats[cur as keyof IStats];
if (item.length > 0) {
pre[cur as keyof IStats] = item.map((x) => ({
xAxis: x[0] as string,
yAxis: x[1] as number,
}));
}
return pre;
}, {} as ChartStatsType);
};
export const useShowTokenEmptyError = () => {
const { t } = useTranslate('chat');
const showTokenEmptyError = useCallback(() => {
message.error(t('tokenError'));
}, [t]);
return { showTokenEmptyError };
};
export const useShowBetaEmptyError = () => {
const { t } = useTranslate('chat');
const showBetaEmptyError = useCallback(() => {
message.error(t('betaError'));
}, [t]);
return { showBetaEmptyError };
};
const getUrlWithToken = (token: string, from: string = 'chat') => {
const { protocol, host } = window.location;
return `${protocol}//${host}/chat/share?shared_id=${token}&from=${from}`;
};
const useFetchTokenListBeforeOtherStep = () => {
const { showTokenEmptyError } = useShowTokenEmptyError();
const { showBetaEmptyError } = useShowBetaEmptyError();
const { data: tokenList, fetchSystemTokenList } =
useFetchManualSystemTokenList();
let token = '',
beta = '';
if (Array.isArray(tokenList) && tokenList.length > 0) {
token = tokenList[0].token;
beta = tokenList[0].beta;
}
token =
Array.isArray(tokenList) && tokenList.length > 0 ? tokenList[0].token : '';
const handleOperate = useCallback(async () => {
const ret = await fetchSystemTokenList();
const list = ret;
if (Array.isArray(list) && list.length > 0) {
if (!list[0].beta) {
showBetaEmptyError();
return false;
}
return list[0]?.token;
} else {
showTokenEmptyError();
return false;
}
}, [fetchSystemTokenList, showBetaEmptyError, showTokenEmptyError]);
return {
token,
beta,
handleOperate,
};
};
export const useShowEmbedModal = () => {
const {
visible: embedVisible,
hideModal: hideEmbedModal,
showModal: showEmbedModal,
} = useSetModalState();
const { handleOperate, token, beta } = useFetchTokenListBeforeOtherStep();
const handleShowEmbedModal = useCallback(async () => {
const succeed = await handleOperate();
if (succeed) {
showEmbedModal();
}
}, [handleOperate, showEmbedModal]);
return {
showEmbedModal: handleShowEmbedModal,
hideEmbedModal,
embedVisible,
embedToken: token,
beta,
};
};
export const usePreviewChat = (idKey: string) => {
const { handleOperate } = useFetchTokenListBeforeOtherStep();
const open = useCallback(
(t: string) => {
window.open(
getUrlWithToken(
t,
idKey === 'canvasId' ? SharedFrom.Agent : SharedFrom.Chat,
),
'_blank',
);
},
[idKey],
);
const handlePreview = useCallback(async () => {
const token = await handleOperate();
if (token) {
open(token);
}
}, [handleOperate, open]);
return {
handlePreview,
};
};

View File

@@ -0,0 +1,33 @@
import { FormLayout } from '@/constants/form';
import { useTranslate } from '@/hooks/common-hooks';
import { SliderInputFormField } from './slider-input-form-field';
export function AutoKeywordsFormField() {
const { t } = useTranslate('knowledgeDetails');
return (
<SliderInputFormField
name={'parser_config.auto_keywords'}
label={t('autoKeywords')}
max={30}
min={0}
tooltip={t('autoKeywordsTip')}
layout={FormLayout.Horizontal}
></SliderInputFormField>
);
}
export function AutoQuestionsFormField() {
const { t } = useTranslate('knowledgeDetails');
return (
<SliderInputFormField
name={'parser_config.auto_questions'}
label={t('autoQuestions')}
max={10}
min={0}
tooltip={t('autoQuestionsTip')}
layout={FormLayout.Horizontal}
></SliderInputFormField>
);
}

View File

@@ -0,0 +1,48 @@
import { useTranslate } from '@/hooks/common-hooks';
import { Flex, Form, InputNumber, Slider } from 'antd';
export const AutoKeywordsItem = () => {
const { t } = useTranslate('knowledgeDetails');
return (
<Form.Item label={t('autoKeywords')} tooltip={t('autoKeywordsTip')}>
<Flex gap={20} align="center">
<Flex flex={1}>
<Form.Item
name={['parser_config', 'auto_keywords']}
noStyle
initialValue={0}
>
<Slider max={30} style={{ width: '100%' }} />
</Form.Item>
</Flex>
<Form.Item name={['parser_config', 'auto_keywords']} noStyle>
<InputNumber max={30} min={0} />
</Form.Item>
</Flex>
</Form.Item>
);
};
export const AutoQuestionsItem = () => {
const { t } = useTranslate('knowledgeDetails');
return (
<Form.Item label={t('autoQuestions')} tooltip={t('autoQuestionsTip')}>
<Flex gap={20} align="center">
<Flex flex={1}>
<Form.Item
name={['parser_config', 'auto_questions']}
noStyle
initialValue={0}
>
<Slider max={10} style={{ width: '100%' }} />
</Form.Item>
</Flex>
<Form.Item name={['parser_config', 'auto_questions']} noStyle>
<InputNumber max={10} min={0} />
</Form.Item>
</Flex>
</Form.Item>
);
};

View File

@@ -0,0 +1,99 @@
import { transformFile2Base64 } from '@/utils/file-util';
import { Pencil, Plus, XIcon } from 'lucide-react';
import {
ChangeEventHandler,
forwardRef,
useCallback,
useEffect,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Button } from './ui/button';
import { Input } from './ui/input';
type AvatarUploadProps = {
value?: string;
onChange?: (value: string) => void;
tips?: string;
};
export const AvatarUpload = forwardRef<HTMLInputElement, AvatarUploadProps>(
function AvatarUpload({ value, onChange, tips }, ref) {
const { t } = useTranslation();
const [avatarBase64Str, setAvatarBase64Str] = useState(''); // Avatar Image base64
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
async (ev) => {
const file = ev.target?.files?.[0];
if (/\.(jpg|jpeg|png|webp|bmp)$/i.test(file?.name ?? '')) {
const str = await transformFile2Base64(file!);
setAvatarBase64Str(str);
onChange?.(str);
}
ev.target.value = '';
},
[onChange],
);
const handleRemove = useCallback(() => {
setAvatarBase64Str('');
onChange?.('');
}, [onChange]);
useEffect(() => {
if (value) {
setAvatarBase64Str(value);
}
}, [value]);
return (
<div className="flex justify-start items-end space-x-2">
<div className="relative group">
{!avatarBase64Str ? (
<div className="w-[64px] h-[64px] grid place-content-center border border-dashed bg-bg-input rounded-md">
<div className="flex flex-col items-center">
<Plus />
<p>{t('common.upload')}</p>
</div>
</div>
) : (
<div className="w-[64px] h-[64px] relative grid place-content-center">
<Avatar className="w-[64px] h-[64px] rounded-md">
<AvatarImage className=" block" src={avatarBase64Str} alt="" />
<AvatarFallback></AvatarFallback>
</Avatar>
<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>
<Button
onClick={handleRemove}
size="icon"
className="border-background focus-visible:border-background absolute -top-2 -right-2 size-6 rounded-full border-2 shadow-none z-10"
aria-label="Remove image"
type="button"
>
<XIcon className="size-3.5" />
</Button>
</div>
)}
<Input
placeholder=""
type="file"
title=""
accept="image/*"
className="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer"
onChange={handleChange}
ref={ref}
/>
</div>
<div className="margin-1 text-text-secondary">
{tips ?? t('knowledgeConfiguration.photoTip')}
</div>
</div>
);
},
);

View File

@@ -0,0 +1,62 @@
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { BrushCleaning } from 'lucide-react';
import { ReactNode, useCallback } from 'react';
import { ConfirmDeleteDialog } from './confirm-delete-dialog';
import { Separator } from './ui/separator';
export type BulkOperateItemType = {
id: string;
label: ReactNode;
icon: ReactNode;
onClick(): void;
};
type BulkOperateBarProps = {
list: BulkOperateItemType[];
count: number;
className?: string;
};
export function BulkOperateBar({
list,
count,
className,
}: BulkOperateBarProps) {
const isDeleteItem = useCallback((id: string) => {
return id === 'delete';
}, []);
return (
<Card className={cn('mb-4', className)}>
<CardContent className="p-1 pl-5 flex items-center gap-6">
<section className="text-text-sub-title-invert flex items-center gap-2">
<span>Selected: {count} Files</span>
<BrushCleaning className="size-3" />
</section>
<Separator orientation={'vertical'} className="h-3"></Separator>
<ul className="flex gap-2">
{list.map((x) => (
<li
key={x.id}
className={cn({ ['text-state-error']: isDeleteItem(x.id) })}
>
<ConfirmDeleteDialog
hidden={!isDeleteItem(x.id)}
onOk={x.onClick}
>
<Button
variant={'ghost'}
onClick={isDeleteItem(x.id) ? () => {} : x.onClick}
>
{x.icon} {x.label}
</Button>
</ConfirmDeleteDialog>
</li>
))}
</ul>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,11 @@
import { Background } from '@xyflow/react';
export function AgentBackground() {
return (
<Background
color="var(--text-primary)"
bgColor="rgb(var(--bg-canvas))"
className="rounded-lg"
/>
);
}

View File

@@ -0,0 +1,17 @@
import { cn } from '@/lib/utils';
import { PropsWithChildren } from 'react';
type CardContainerProps = { className?: string } & PropsWithChildren;
export function CardContainer({ children, className }: CardContainerProps) {
return (
<section
className={cn(
'grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
className,
)}
>
{children}
</section>
);
}

View File

@@ -0,0 +1,90 @@
'use client';
import { Button } from '@/components/ui/button';
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Plus, X } from 'lucide-react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Separator } from '../ui/separator';
export function DynamicPageRange() {
const { t } = useTranslation();
const form = useFormContext();
const { fields, remove, append } = useFieldArray({
name: 'parser_config.pages',
control: form.control,
});
return (
<div>
<FormLabel tooltip={t('knowledgeDetails.pageRangesTip')}>
{t('knowledgeDetails.pageRanges')}
</FormLabel>
{fields.map((field, index) => {
const typeField = `parser_config.pages.${index}.from`;
return (
<div key={field.id} className="flex items-center gap-2 pt-2">
<FormField
control={form.control}
name={typeField}
render={({ field }) => (
<FormItem className="w-2/5">
<FormDescription />
<FormControl>
<Input
type="number"
placeholder={t('common.pleaseInput')}
className="!m-0"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Separator className="w-3 "></Separator>
<FormField
control={form.control}
name={`parser_config.pages.${index}.to`}
render={({ field }) => (
<FormItem className="flex-1">
<FormDescription />
<FormControl>
<Input
type="number"
placeholder={t('common.pleaseInput')}
className="!m-0"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button variant={'ghost'} onClick={() => remove(index)}>
<X />
</Button>
</div>
);
})}
<Button
onClick={() => append({ from: 1, to: 100 })}
className="mt-4 border-dashed w-full"
variant={'outline'}
type="button"
>
<Plus />
{t('knowledgeDetails.addPage')}
</Button>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { useSelectParserList } from '@/hooks/user-setting-hooks';
import { useCallback, useMemo } from 'react';
const ParserListMap = new Map([
[
['pdf'],
[
'naive',
'resume',
'manual',
'paper',
'book',
'laws',
'presentation',
'one',
'qa',
'knowledge_graph',
],
],
[
['doc', 'docx'],
[
'naive',
'resume',
'book',
'laws',
'one',
'qa',
'manual',
'knowledge_graph',
],
],
[
['xlsx', 'xls'],
['naive', 'qa', 'table', 'one', 'knowledge_graph'],
],
[['ppt', 'pptx'], ['presentation']],
[
['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tif', 'tiff', 'webp', 'svg', 'ico'],
['picture'],
],
[
['txt'],
[
'naive',
'resume',
'book',
'laws',
'one',
'qa',
'table',
'knowledge_graph',
],
],
[
['csv'],
[
'naive',
'resume',
'book',
'laws',
'one',
'qa',
'table',
'knowledge_graph',
],
],
[['md'], ['naive', 'qa', 'knowledge_graph']],
[['json'], ['naive', 'knowledge_graph']],
[['eml'], ['email']],
]);
const getParserList = (
values: string[],
parserList: Array<{
value: string;
label: string;
}>,
) => {
return parserList.filter((x) => values?.some((y) => y === x.value));
};
export const useFetchParserListOnMount = (documentExtension: string) => {
const parserList = useSelectParserList();
const nextParserList = useMemo(() => {
const key = [...ParserListMap.keys()].find((x) =>
x.some((y) => y === documentExtension),
);
if (key) {
const values = ParserListMap.get(key);
return getParserList(values ?? [], parserList);
}
return getParserList(
['naive', 'resume', 'book', 'laws', 'one', 'qa', 'table'],
parserList,
);
}, [parserList, documentExtension]);
return { parserList: nextParserList };
};
const hideAutoKeywords = ['qa', 'table', 'resume', 'knowledge_graph', 'tag'];
export const useShowAutoKeywords = () => {
const showAutoKeywords = useCallback((selectedTag: string) => {
return hideAutoKeywords.every((x) => selectedTag !== x);
}, []);
return showAutoKeywords;
};

View File

@@ -0,0 +1,384 @@
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { DocumentParserType } from '@/constants/knowledge';
import { useFetchKnowledgeBaseConfiguration } from '@/hooks/use-knowledge-request';
import { IModalProps } from '@/interfaces/common';
import { IParserConfig } from '@/interfaces/database/document';
import { IChangeParserConfigRequestBody } from '@/interfaces/request/document';
import {
ChunkMethodItem,
EnableTocToggle,
ParseTypeItem,
} from '@/pages/dataset/dataset-setting/configuration/common-item';
import { zodResolver } from '@hookform/resolvers/zod';
import omit from 'lodash/omit';
import {} from 'module';
import { useEffect, useMemo } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import {
AutoKeywordsFormField,
AutoQuestionsFormField,
} from '../auto-keywords-form-field';
import { DataFlowSelect } from '../data-pipeline-select';
import { DelimiterFormField } from '../delimiter-form-field';
import { EntityTypesFormField } from '../entity-types-form-field';
import { ExcelToHtmlFormField } from '../excel-to-html-form-field';
import { FormContainer } from '../form-container';
import { LayoutRecognizeFormField } from '../layout-recognize-form-field';
import { MaxTokenNumberFormField } from '../max-token-number-from-field';
import { ButtonLoading } from '../ui/button';
import { Input } from '../ui/input';
import { DynamicPageRange } from './dynamic-page-range';
import { useShowAutoKeywords } from './hooks';
import {
useDefaultParserValues,
useFillDefaultValueOnMount,
} from './use-default-parser-values';
const FormId = 'ChunkMethodDialogForm';
interface IProps
extends IModalProps<{
parserId: string;
parserConfig: IChangeParserConfigRequestBody;
}> {
loading: boolean;
parserId: string;
pipelineId?: string;
parserConfig: IParserConfig;
documentExtension: string;
documentId: string;
}
const hidePagesChunkMethods = [
DocumentParserType.Qa,
DocumentParserType.Table,
DocumentParserType.Picture,
DocumentParserType.Resume,
DocumentParserType.One,
DocumentParserType.KnowledgeGraph,
];
export function ChunkMethodDialog({
hideModal,
onOk,
parserId,
pipelineId,
documentExtension,
visible,
parserConfig,
loading,
}: IProps) {
const { t } = useTranslation();
const { data: knowledgeDetails } = useFetchKnowledgeBaseConfiguration();
const useGraphRag = useMemo(() => {
return knowledgeDetails.parser_config?.graphrag?.use_graphrag;
}, [knowledgeDetails.parser_config?.graphrag?.use_graphrag]);
const defaultParserValues = useDefaultParserValues();
const fillDefaultParserValue = useFillDefaultValueOnMount();
const FormSchema = z
.object({
parseType: z.number(),
parser_id: z
.string()
.min(1, {
message: t('common.pleaseSelect'),
})
.trim(),
pipeline_id: z.string().optional(),
parser_config: z.object({
task_page_size: z.coerce.number().optional(),
layout_recognize: z.string().optional(),
chunk_token_num: z.coerce.number().optional(),
delimiter: z.string().optional(),
auto_keywords: z.coerce.number().optional(),
auto_questions: z.coerce.number().optional(),
html4excel: z.boolean().optional(),
toc_extraction: z.boolean().optional(),
// raptor: z
// .object({
// use_raptor: z.boolean().optional(),
// prompt: z.string().optional().optional(),
// max_token: z.coerce.number().optional(),
// threshold: z.coerce.number().optional(),
// max_cluster: z.coerce.number().optional(),
// random_seed: z.coerce.number().optional(),
// })
// .optional(),
// graphrag: z.object({
// use_graphrag: z.boolean().optional(),
// }),
entity_types: z.array(z.string()).optional(),
pages: z
.array(z.object({ from: z.coerce.number(), to: z.coerce.number() }))
.optional(),
}),
})
.superRefine((data, ctx) => {
if (data.parseType === 2 && !data.pipeline_id) {
ctx.addIssue({
path: ['pipeline_id'],
message: t('common.pleaseSelect'),
code: 'custom',
});
}
});
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
parser_id: parserId || '',
pipeline_id: pipelineId || '',
parseType: pipelineId ? 2 : 1,
parser_config: defaultParserValues,
},
});
const layoutRecognize = useWatch({
name: 'parser_config.layout_recognize',
control: form.control,
});
const selectedTag = useWatch({
name: 'parser_id',
control: form.control,
});
const isPdf = documentExtension === 'pdf';
const showPages = useMemo(() => {
return isPdf && hidePagesChunkMethods.every((x) => x !== selectedTag);
}, [selectedTag, isPdf]);
const showOne = useMemo(() => {
return (
isPdf &&
hidePagesChunkMethods
.filter((x) => x !== DocumentParserType.One)
.every((x) => x !== selectedTag)
);
}, [selectedTag, isPdf]);
const showMaxTokenNumber =
selectedTag === DocumentParserType.Naive ||
selectedTag === DocumentParserType.KnowledgeGraph;
const showEntityTypes = selectedTag === DocumentParserType.KnowledgeGraph;
const showExcelToHtml =
selectedTag === DocumentParserType.Naive && documentExtension === 'xlsx';
const showAutoKeywords = useShowAutoKeywords();
async function onSubmit(data: z.infer<typeof FormSchema>) {
console.log('🚀 ~ onSubmit ~ data:', data);
const nextData = {
...data,
parser_config: {
...data.parser_config,
pages: data.parser_config?.pages?.map((x: any) => [x.from, x.to]) ?? [],
},
};
console.log('🚀 ~ onSubmit ~ nextData:', nextData);
const ret = await onOk?.(nextData);
if (ret) {
hideModal?.();
}
}
useEffect(() => {
if (visible) {
const pages =
parserConfig?.pages?.map((x) => ({ from: x[0], to: x[1] })) ?? [];
form.reset({
parser_id: parserId || '',
pipeline_id: pipelineId || '',
parseType: pipelineId ? 2 : 1,
parser_config: fillDefaultParserValue({
pages: pages.length > 0 ? pages : [{ from: 1, to: 1024 }],
...omit(parserConfig, 'pages'),
// graphrag: {
// use_graphrag: get(
// parserConfig,
// 'graphrag.use_graphrag',
// useGraphRag,
// ),
// },
}),
});
}
}, [
fillDefaultParserValue,
form,
knowledgeDetails.parser_config,
parserConfig,
parserId,
pipelineId,
useGraphRag,
visible,
]);
const parseType = useWatch({
control: form.control,
name: 'parseType',
defaultValue: pipelineId ? 2 : 1,
});
useEffect(() => {
if (parseType === 1) {
form.setValue('pipeline_id', '');
}
}, [parseType, form]);
return (
<Dialog open onOpenChange={hideModal}>
<DialogContent className="max-w-[50vw] text-text-primary">
<DialogHeader>
<DialogTitle>{t('knowledgeDetails.chunkMethod')}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6 max-h-[70vh] overflow-auto"
id={FormId}
>
<FormContainer>
<ParseTypeItem />
{parseType === 1 && <ChunkMethodItem></ChunkMethodItem>}
{parseType === 2 && (
<DataFlowSelect
isMult={false}
// toDataPipeline={navigateToAgents}
formFieldName="pipeline_id"
/>
)}
{/* <FormField
control={form.control}
name="parser_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t('knowledgeDetails.chunkMethod')}</FormLabel>
<FormControl>
<RAGFlowSelect
{...field}
options={parserList}
></RAGFlowSelect>
</FormControl>
<FormMessage />
</FormItem>
)}
/> */}
{showPages && parseType === 1 && (
<DynamicPageRange></DynamicPageRange>
)}
{showPages && parseType === 1 && layoutRecognize && (
<FormField
control={form.control}
name="parser_config.task_page_size"
render={({ field }) => (
<FormItem>
<FormLabel
tooltip={t('knowledgeDetails.taskPageSizeTip')}
>
{t('knowledgeDetails.taskPageSize')}
</FormLabel>
<FormControl>
<Input
{...field}
type={'number'}
min={1}
max={128}
></Input>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</FormContainer>
{parseType === 1 && (
<>
<FormContainer
show={showOne || showMaxTokenNumber}
className="space-y-3"
>
{showOne && (
<LayoutRecognizeFormField></LayoutRecognizeFormField>
)}
{showMaxTokenNumber && (
<>
<MaxTokenNumberFormField
max={
selectedTag === DocumentParserType.KnowledgeGraph
? 8192 * 2
: 2048
}
></MaxTokenNumberFormField>
<DelimiterFormField></DelimiterFormField>
</>
)}
</FormContainer>
<FormContainer
show={showAutoKeywords(selectedTag) || showExcelToHtml}
className="space-y-3"
>
{selectedTag === DocumentParserType.Naive && (
<EnableTocToggle />
)}
{showAutoKeywords(selectedTag) && (
<>
<AutoKeywordsFormField></AutoKeywordsFormField>
<AutoQuestionsFormField></AutoQuestionsFormField>
</>
)}
{showExcelToHtml && (
<ExcelToHtmlFormField></ExcelToHtmlFormField>
)}
</FormContainer>
{/* {showRaptorParseConfiguration(
selectedTag as DocumentParserType,
) && (
<FormContainer>
<RaptorFormFields></RaptorFormFields>
</FormContainer>
)} */}
{/* {showGraphRagItems(selectedTag as DocumentParserType) &&
useGraphRag && (
<FormContainer>
<UseGraphRagFormField></UseGraphRagFormField>
</FormContainer>
)} */}
{showEntityTypes && (
<EntityTypesFormField></EntityTypesFormField>
)}
</>
)}
</form>
</Form>
<DialogFooter>
<ButtonLoading type="submit" form={FormId} loading={loading}>
{t('common.save')}
</ButtonLoading>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,61 @@
import { IParserConfig } from '@/interfaces/database/document';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ParseDocumentType } from '../layout-recognize-form-field';
export function useDefaultParserValues() {
const { t } = useTranslation();
const defaultParserValues = useMemo(() => {
const defaultParserValues = {
task_page_size: 12,
layout_recognize: ParseDocumentType.DeepDOC,
chunk_token_num: 512,
delimiter: '\n',
auto_keywords: 0,
auto_questions: 0,
html4excel: false,
toc_extraction: false,
// raptor: {
// use_raptor: false,
// prompt: t('knowledgeConfiguration.promptText'),
// max_token: 256,
// threshold: 0.1,
// max_cluster: 64,
// random_seed: 0,
// },
// graphrag: {
// use_graphrag: false,
// },
entity_types: [],
pages: [],
};
return defaultParserValues;
}, [t]);
return defaultParserValues;
}
export function useFillDefaultValueOnMount() {
const defaultParserValues = useDefaultParserValues();
const fillDefaultValue = useCallback(
(parserConfig: IParserConfig) => {
return Object.entries(defaultParserValues).reduce<Record<string, any>>(
(pre, [key, value]) => {
if (key in parserConfig) {
pre[key] = parserConfig[key as keyof IParserConfig];
} else {
pre[key] = value;
}
return pre;
},
{},
);
},
[defaultParserValues],
);
return fillDefaultValue;
}

View File

@@ -0,0 +1,161 @@
import { DocumentParserType } from '@/constants/knowledge';
import { useHandleChunkMethodSelectChange } from '@/hooks/logic-hooks';
import { useSelectParserList } from '@/hooks/user-setting-hooks';
import { FormInstance } from 'antd';
import { useCallback, useEffect, useMemo, useState } from 'react';
const ParserListMap = new Map([
[
['pdf'],
[
DocumentParserType.Naive,
DocumentParserType.Resume,
DocumentParserType.Manual,
DocumentParserType.Paper,
DocumentParserType.Book,
DocumentParserType.Laws,
DocumentParserType.Presentation,
DocumentParserType.One,
DocumentParserType.Qa,
DocumentParserType.KnowledgeGraph,
],
],
[
['doc', 'docx'],
[
DocumentParserType.Naive,
DocumentParserType.Resume,
DocumentParserType.Book,
DocumentParserType.Laws,
DocumentParserType.One,
DocumentParserType.Qa,
DocumentParserType.Manual,
DocumentParserType.KnowledgeGraph,
],
],
[
['xlsx', 'xls'],
[
DocumentParserType.Naive,
DocumentParserType.Qa,
DocumentParserType.Table,
DocumentParserType.One,
DocumentParserType.KnowledgeGraph,
],
],
[['ppt', 'pptx'], [DocumentParserType.Presentation]],
[
['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tif', 'tiff', 'webp', 'svg', 'ico'],
[DocumentParserType.Picture],
],
[
['txt'],
[
DocumentParserType.Naive,
DocumentParserType.Resume,
DocumentParserType.Book,
DocumentParserType.Laws,
DocumentParserType.One,
DocumentParserType.Qa,
DocumentParserType.Table,
DocumentParserType.KnowledgeGraph,
],
],
[
['csv'],
[
DocumentParserType.Naive,
DocumentParserType.Resume,
DocumentParserType.Book,
DocumentParserType.Laws,
DocumentParserType.One,
DocumentParserType.Qa,
DocumentParserType.Table,
DocumentParserType.KnowledgeGraph,
],
],
[
['md'],
[
DocumentParserType.Naive,
DocumentParserType.Qa,
DocumentParserType.KnowledgeGraph,
],
],
[['json'], [DocumentParserType.Naive, DocumentParserType.KnowledgeGraph]],
[['eml'], [DocumentParserType.Email]],
]);
const getParserList = (
values: string[],
parserList: Array<{
value: string;
label: string;
}>,
) => {
return parserList.filter((x) => values?.some((y) => y === x.value));
};
export const useFetchParserListOnMount = (
documentId: string,
parserId: DocumentParserType,
documentExtension: string,
form: FormInstance,
) => {
const [selectedTag, setSelectedTag] = useState<DocumentParserType>();
const parserList = useSelectParserList();
const handleChunkMethodSelectChange = useHandleChunkMethodSelectChange(form);
const nextParserList = useMemo(() => {
const key = [...ParserListMap.keys()].find((x) =>
x.some((y) => y === documentExtension),
);
if (key) {
const values = ParserListMap.get(key);
return getParserList(values ?? [], parserList);
}
return getParserList(
[
DocumentParserType.Naive,
DocumentParserType.Resume,
DocumentParserType.Book,
DocumentParserType.Laws,
DocumentParserType.One,
DocumentParserType.Qa,
DocumentParserType.Table,
],
parserList,
);
}, [parserList, documentExtension]);
useEffect(() => {
setSelectedTag(parserId);
}, [parserId, documentId]);
const handleChange = (tag: string) => {
handleChunkMethodSelectChange(tag);
setSelectedTag(tag as DocumentParserType);
};
return { parserList: nextParserList, handleChange, selectedTag };
};
const hideAutoKeywords = [
DocumentParserType.Qa,
DocumentParserType.Table,
DocumentParserType.Resume,
DocumentParserType.KnowledgeGraph,
DocumentParserType.Tag,
];
export const useShowAutoKeywords = () => {
const showAutoKeywords = useCallback(
(selectedTag: DocumentParserType | undefined) => {
return hideAutoKeywords.every((x) => selectedTag !== x);
},
[],
);
return showAutoKeywords;
};

View File

@@ -0,0 +1,14 @@
.pageInputNumber {
width: 220px;
}
.questionIcon {
margin-inline-start: 4px;
color: rgba(0, 0, 0, 0.45);
cursor: help;
writing-mode: horizontal-tb;
}
.chunkMethod {
margin-bottom: 0;
}

View File

@@ -0,0 +1,350 @@
import MaxTokenNumber from '@/components/max-token-number';
import { IModalManagerChildrenProps } from '@/components/modal-manager';
import {
MinusCircleOutlined,
PlusOutlined,
QuestionCircleOutlined,
} from '@ant-design/icons';
import {
Button,
Divider,
Form,
InputNumber,
Modal,
Select,
Space,
Tooltip,
} from 'antd';
import omit from 'lodash/omit';
import React, { useEffect, useMemo } from 'react';
import { useFetchParserListOnMount, useShowAutoKeywords } from './hooks';
import { DocumentParserType } from '@/constants/knowledge';
import { useTranslate } from '@/hooks/common-hooks';
import { useFetchKnowledgeBaseConfiguration } from '@/hooks/knowledge-hooks';
import { IParserConfig } from '@/interfaces/database/document';
import { IChangeParserConfigRequestBody } from '@/interfaces/request/document';
import { get } from 'lodash';
import { AutoKeywordsItem, AutoQuestionsItem } from '../auto-keywords-item';
import { DatasetConfigurationContainer } from '../dataset-configuration-container';
import Delimiter from '../delimiter';
import EntityTypesItem from '../entity-types-item';
import ExcelToHtml from '../excel-to-html';
import LayoutRecognize from '../layout-recognize';
import ParseConfiguration, {
showRaptorParseConfiguration,
} from '../parse-configuration';
import {
UseGraphRagItem,
showGraphRagItems,
} from '../parse-configuration/graph-rag-items';
import styles from './index.less';
interface IProps extends Omit<IModalManagerChildrenProps, 'showModal'> {
loading: boolean;
onOk: (
parserId: DocumentParserType | undefined,
parserConfig: IChangeParserConfigRequestBody,
) => void;
showModal?(): void;
parserId: DocumentParserType;
parserConfig: IParserConfig;
documentExtension: string;
documentId: string;
}
const hidePagesChunkMethods = [
DocumentParserType.Qa,
DocumentParserType.Table,
DocumentParserType.Picture,
DocumentParserType.Resume,
DocumentParserType.One,
DocumentParserType.KnowledgeGraph,
];
const ChunkMethodModal: React.FC<IProps> = ({
documentId,
parserId,
onOk,
hideModal,
visible,
documentExtension,
parserConfig,
loading,
}) => {
const [form] = Form.useForm();
const { parserList, handleChange, selectedTag } = useFetchParserListOnMount(
documentId,
parserId,
documentExtension,
form,
);
const { t } = useTranslate('knowledgeDetails');
const { data: knowledgeDetails } = useFetchKnowledgeBaseConfiguration();
const useGraphRag = useMemo(() => {
return knowledgeDetails.parser_config?.graphrag?.use_graphrag;
}, [knowledgeDetails.parser_config?.graphrag?.use_graphrag]);
const handleOk = async () => {
const values = await form.validateFields();
const parser_config = {
...values.parser_config,
pages: values.pages?.map((x: any) => [x.from, x.to]) ?? [],
};
onOk(selectedTag, parser_config);
};
const isPdf = documentExtension === 'pdf';
const showPages = useMemo(() => {
return isPdf && hidePagesChunkMethods.every((x) => x !== selectedTag);
}, [selectedTag, isPdf]);
const showOne = useMemo(() => {
return (
isPdf &&
hidePagesChunkMethods
.filter((x) => x !== DocumentParserType.One)
.every((x) => x !== selectedTag)
);
}, [selectedTag, isPdf]);
const showMaxTokenNumber =
selectedTag === DocumentParserType.Naive ||
selectedTag === DocumentParserType.KnowledgeGraph;
const showEntityTypes = selectedTag === DocumentParserType.KnowledgeGraph;
const showExcelToHtml =
selectedTag === DocumentParserType.Naive && documentExtension === 'xlsx';
const showAutoKeywords = useShowAutoKeywords();
const afterClose = () => {
form.resetFields();
};
useEffect(() => {
if (visible) {
const pages =
parserConfig?.pages?.map((x) => ({ from: x[0], to: x[1] })) ?? [];
form.setFieldsValue({
pages: pages.length > 0 ? pages : [{ from: 1, to: 1024 }],
parser_config: {
...omit(parserConfig, 'pages'),
graphrag: {
use_graphrag: get(
parserConfig,
'graphrag.use_graphrag',
useGraphRag,
),
},
},
});
}
}, [
form,
knowledgeDetails.parser_config,
parserConfig,
useGraphRag,
visible,
]);
return (
<Modal
title={t('chunkMethod')}
open={visible}
onOk={handleOk}
onCancel={hideModal}
afterClose={afterClose}
confirmLoading={loading}
width={700}
>
<Space size={[0, 8]} wrap>
<Form.Item label={t('chunkMethod')} className={styles.chunkMethod}>
<Select
style={{ width: 160 }}
onChange={handleChange}
value={selectedTag}
options={parserList}
/>
</Form.Item>
</Space>
<Divider></Divider>
<Form
name="dynamic_form_nest_item"
autoComplete="off"
form={form}
className="space-y-4"
>
{showPages && (
<>
<Space>
<p>{t('pageRanges')}:</p>
<Tooltip title={t('pageRangesTip')}>
<QuestionCircleOutlined
className={styles.questionIcon}
></QuestionCircleOutlined>
</Tooltip>
</Space>
<Form.List name="pages">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space
key={key}
style={{
display: 'flex',
}}
align="baseline"
>
<Form.Item
{...restField}
name={[name, 'from']}
dependencies={name > 0 ? [name - 1, 'to'] : []}
rules={[
{
required: true,
message: t('fromMessage'),
},
({ getFieldValue }) => ({
validator(_, value) {
if (
name === 0 ||
!value ||
getFieldValue(['pages', name - 1, 'to']) < value
) {
return Promise.resolve();
}
return Promise.reject(
new Error(t('greaterThanPrevious')),
);
},
}),
]}
>
<InputNumber
placeholder={t('fromPlaceholder')}
min={0}
precision={0}
className={styles.pageInputNumber}
/>
</Form.Item>
<Form.Item
{...restField}
name={[name, 'to']}
dependencies={[name, 'from']}
rules={[
{
required: true,
message: t('toMessage'),
},
({ getFieldValue }) => ({
validator(_, value) {
if (
!value ||
getFieldValue(['pages', name, 'from']) < value
) {
return Promise.resolve();
}
return Promise.reject(
new Error(t('greaterThan')),
);
},
}),
]}
>
<InputNumber
placeholder={t('toPlaceholder')}
min={0}
precision={0}
className={styles.pageInputNumber}
/>
</Form.Item>
{name > 0 && (
<MinusCircleOutlined onClick={() => remove(name)} />
)}
</Space>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => add()}
block
icon={<PlusOutlined />}
>
{t('addPage')}
</Button>
</Form.Item>
</>
)}
</Form.List>
</>
)}
{showPages && (
<Form.Item
noStyle
dependencies={[['parser_config', 'layout_recognize']]}
>
{({ getFieldValue }) =>
getFieldValue(['parser_config', 'layout_recognize']) && (
<Form.Item
name={['parser_config', 'task_page_size']}
label={t('taskPageSize')}
tooltip={t('taskPageSizeTip')}
initialValue={12}
rules={[
{
required: true,
message: t('taskPageSizeMessage'),
},
]}
>
<InputNumber min={1} max={128} />
</Form.Item>
)
}
</Form.Item>
)}
<DatasetConfigurationContainer show={showOne || showMaxTokenNumber}>
{showOne && <LayoutRecognize></LayoutRecognize>}
{showMaxTokenNumber && (
<>
<MaxTokenNumber
max={
selectedTag === DocumentParserType.KnowledgeGraph
? 8192 * 2
: 2048
}
></MaxTokenNumber>
<Delimiter></Delimiter>
</>
)}
</DatasetConfigurationContainer>
<DatasetConfigurationContainer
show={showAutoKeywords(selectedTag) || showExcelToHtml}
>
{showAutoKeywords(selectedTag) && (
<>
<AutoKeywordsItem></AutoKeywordsItem>
<AutoQuestionsItem></AutoQuestionsItem>
</>
)}
{showExcelToHtml && <ExcelToHtml></ExcelToHtml>}
</DatasetConfigurationContainer>
{showRaptorParseConfiguration(selectedTag) && (
<DatasetConfigurationContainer>
<ParseConfiguration></ParseConfiguration>
</DatasetConfigurationContainer>
)}
{showGraphRagItems(selectedTag) && useGraphRag && (
<UseGraphRagItem></UseGraphRagItem>
)}
{showEntityTypes && <EntityTypesItem></EntityTypesItem>}
</Form>
</Modal>
);
};
export default ChunkMethodModal;

View File

@@ -0,0 +1,127 @@
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { CollapsibleProps } from '@radix-ui/react-collapsible';
import { ChevronDown, ChevronUp } from 'lucide-react';
import * as React from 'react';
import {
PropsWithChildren,
ReactNode,
useCallback,
useEffect,
useState,
} from 'react';
import { IconFontFill } from './icon-font';
type CollapseProps = Omit<CollapsibleProps, 'title'> & {
title?: ReactNode;
rightContent?: ReactNode;
} & PropsWithChildren;
export function Collapse({
title,
children,
rightContent,
open = true,
defaultOpen = false,
onOpenChange,
disabled,
}: CollapseProps) {
const [currentOpen, setCurrentOpen] = useState(open);
useEffect(() => {
setCurrentOpen(open);
}, [open]);
const handleOpenChange = useCallback(
(open: boolean) => {
setCurrentOpen(open);
onOpenChange?.(open);
},
[onOpenChange],
);
return (
<Collapsible
defaultOpen={defaultOpen}
open={currentOpen}
onOpenChange={handleOpenChange}
disabled={disabled}
>
<CollapsibleTrigger className={'w-full'}>
<section className="flex justify-between items-center">
<div className="flex items-center gap-1">
<IconFontFill
name={`more`}
className={cn('size-4', {
'rotate-90': !currentOpen,
})}
></IconFontFill>
<div
className={cn('text-text-secondary', {
'text-text-primary': open,
})}
>
{title}
</div>
</div>
<div>{rightContent}</div>
</section>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">{children}</CollapsibleContent>
</Collapsible>
);
}
export type NodeCollapsibleProps<T extends any[]> = {
items?: T;
children: (item: T[0], idx: number) => ReactNode;
className?: string;
};
export function NodeCollapsible<T extends any[]>({
items = [] as unknown as T,
children,
className,
}: NodeCollapsibleProps<T>) {
const [isOpen, setIsOpen] = React.useState(false);
const nextClassName = cn('space-y-2', className);
const nextItems = items.every((x) => Array.isArray(x)) ? items.flat() : items;
return (
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className={cn('relative', nextClassName)}
>
{nextItems.slice(0, 3).map(children)}
<CollapsibleContent className={nextClassName}>
{nextItems.slice(3).map((x, idx) => children(x, idx + 3))}
</CollapsibleContent>
{nextItems.length > 3 && (
<CollapsibleTrigger
asChild
onClick={(e) => e.stopPropagation()}
className="absolute left-1/2 -translate-x-1/2 bottom-0 translate-y-1/2 cursor-pointer"
>
<div
className={cn(
'size-3 bg-text-secondary rounded-full flex items-center justify-center',
{ 'bg-text-primary': isOpen },
)}
>
{isOpen ? (
<ChevronUp className="stroke-bg-component" />
) : (
<ChevronDown className="stroke-bg-component" />
)}
</div>
</CollapsibleTrigger>
)}
</Collapsible>
);
}

View File

@@ -0,0 +1,71 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { DialogProps } from '@radix-ui/react-dialog';
import { useTranslation } from 'react-i18next';
interface IProps {
title?: string;
onOk?: (...args: any[]) => any;
onCancel?: (...args: any[]) => any;
hidden?: boolean;
}
export function ConfirmDeleteDialog({
children,
title,
onOk,
onCancel,
hidden = false,
onOpenChange,
open,
defaultOpen,
}: IProps & DialogProps) {
const { t } = useTranslation();
if (hidden) {
return children;
}
return (
<AlertDialog
onOpenChange={onOpenChange}
open={open}
defaultOpen={defaultOpen}
>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent
onSelect={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
>
<AlertDialogHeader>
<AlertDialogTitle>
{title ?? t('common.deleteModalTitle')}
</AlertDialogTitle>
{/* <AlertDialogDescription>
This action cannot be undone. This will permanently delete your
account and remove your data from our servers.
</AlertDialogDescription> */}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>
{t('common.no')}
</AlertDialogCancel>
<AlertDialogAction
className="bg-state-error text-text-primary"
onClick={onOk}
>
{t('common.yes')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,27 @@
import { useTranslate } from '@/hooks/common-hooks';
import { CheckOutlined, CopyOutlined } from '@ant-design/icons';
import { Tooltip } from 'antd';
import { useState } from 'react';
import { CopyToClipboard as Clipboard, Props } from 'react-copy-to-clipboard';
const CopyToClipboard = ({ text }: Props) => {
const [copied, setCopied] = useState(false);
const { t } = useTranslate('common');
const handleCopy = () => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
};
return (
<Tooltip title={copied ? t('copied') : t('copy')}>
<Clipboard text={text} onCopy={handleCopy}>
{copied ? <CheckOutlined /> : <CopyOutlined />}
</Clipboard>
</Tooltip>
);
};
export default CopyToClipboard;

View File

@@ -0,0 +1,75 @@
import {
FormControl,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/form';
import { MultiSelect } from '@/components/ui/multi-select';
import { cn } from '@/lib/utils';
import { t } from 'i18next';
import { toLower } from 'lodash';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
const Languages = [
'English',
'Chinese',
'Spanish',
'French',
'German',
'Japanese',
'Korean',
'Vietnamese',
];
export const crossLanguageOptions = Languages.map((x) => ({
label: t('language.' + toLower(x)),
value: x,
}));
type CrossLanguageItemProps = {
name?: string;
vertical?: boolean;
label?: string;
};
export const CrossLanguageFormField = ({
name = 'prompt_config.cross_languages',
vertical = true,
label,
}: CrossLanguageItemProps) => {
const { t } = useTranslation();
const form = useFormContext();
return (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem
className={cn('flex', {
'gap-2': vertical,
'flex-col': vertical,
'justify-between': !vertical,
'items-center': !vertical,
})}
>
<FormLabel tooltip={t('chat.crossLanguageTip')}>
{label || t('chat.crossLanguage')}
</FormLabel>
<FormControl>
<MultiSelect
options={crossLanguageOptions}
placeholder={t('fileManager.pleaseSelect')}
maxCount={100}
{...field}
onValueChange={field.onChange}
defaultValue={field.value}
modalPopover
/>
</FormControl>
</FormItem>
)}
/>
);
};

View File

@@ -0,0 +1,40 @@
import { Select as AntSelect, Form } from 'antd';
import { useTranslation } from 'react-i18next';
const Languages = [
'English',
'Chinese',
'Spanish',
'French',
'German',
'Japanese',
'Korean',
'Vietnamese',
];
const options = Languages.map((x) => ({ label: x, value: x }));
type CrossLanguageItemProps = {
name?: string | Array<string>;
};
export const CrossLanguageItem = ({
name = ['prompt_config', 'cross_languages'],
}: CrossLanguageItemProps) => {
const { t } = useTranslation();
return (
<Form.Item
label={t('chat.crossLanguage')}
name={name}
tooltip={t('chat.crossLanguageTip')}
>
<AntSelect
options={options}
allowClear
placeholder={t('common.languagePlaceholder')}
mode="multiple"
/>
</Form.Item>
);
};

View File

@@ -0,0 +1,177 @@
import { AgentCategory } from '@/constants/agent';
import { FormLayout } from '@/constants/form';
import { useTranslate } from '@/hooks/common-hooks';
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks';
import { useFetchAgentList } from '@/hooks/use-agent-request';
import { buildSelectOptions } from '@/utils/component-util';
import { ArrowUpRight } from 'lucide-react';
import { useEffect, useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { SelectWithSearch } from '../originui/select-with-search';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '../ui/form';
import { MultiSelect } from '../ui/multi-select';
export interface IDataPipelineSelectNode {
id?: string;
name?: string;
avatar?: string;
}
interface IProps {
showToDataPipeline?: boolean;
formFieldName: string;
isMult?: boolean;
setDataList?: (data: IDataPipelineSelectNode[]) => void;
layout?: FormLayout;
}
export function DataFlowSelect(props: IProps) {
const {
showToDataPipeline,
formFieldName,
isMult = false,
setDataList,
layout = FormLayout.Vertical,
} = props;
const { t } = useTranslate('knowledgeConfiguration');
const form = useFormContext();
const { navigateToAgents } = useNavigatePage();
const toDataPipLine = () => {
navigateToAgents();
};
const { data: dataPipelineOptions } = useFetchAgentList({
canvas_category: AgentCategory.DataflowCanvas,
});
const options = useMemo(() => {
const option = buildSelectOptions(
dataPipelineOptions?.canvas,
'id',
'title',
);
return option || [];
}, [dataPipelineOptions]);
const nodes = useMemo(() => {
return (
dataPipelineOptions?.canvas?.map((item) => {
return {
id: item?.id,
name: item?.title,
avatar: item?.avatar,
};
}) || []
);
}, [dataPipelineOptions]);
useEffect(() => {
setDataList?.(nodes);
}, [nodes, setDataList]);
return (
<FormField
control={form.control}
name={formFieldName}
render={({ field }) => (
<FormItem className=" items-center space-y-0 ">
{layout === FormLayout.Vertical && (
<div className="flex flex-col gap-1">
<div className="flex gap-2 justify-between ">
<FormLabel
// tooltip={t('dataFlowTip')}
className="text-sm text-text-primary whitespace-wrap "
>
{t('manualSetup')}
</FormLabel>
{showToDataPipeline && (
<div
className="text-sm flex text-text-primary cursor-pointer"
onClick={toDataPipLine}
>
{t('buildItFromScratch')}
<ArrowUpRight size={14} />
</div>
)}
</div>
<div className="text-muted-foreground">
<FormControl>
<>
{!isMult && (
<SelectWithSearch
{...field}
placeholder={t('dataFlowPlaceholder')}
options={options}
triggerClassName="!bg-bg-base"
/>
)}
{isMult && (
<MultiSelect
{...field}
onValueChange={field.onChange}
placeholder={t('dataFlowPlaceholder')}
options={options}
/>
)}
</>
</FormControl>
</div>
</div>
)}
{layout === FormLayout.Horizontal && (
<div className="flex gap-1 items-center">
<div className="flex gap-2 justify-between w-1/4">
<FormLabel
// tooltip={t('dataFlowTip')}
className="text-sm text-text-secondary whitespace-wrap "
>
{t('manualSetup')}
</FormLabel>
</div>
<div className="text-muted-foreground w-3/4 flex flex-col items-end">
{showToDataPipeline && (
<div
className="text-sm flex text-text-primary cursor-pointer"
onClick={toDataPipLine}
>
{t('buildItFromScratch')}
<ArrowUpRight size={14} />
</div>
)}
<FormControl>
<>
{!isMult && (
<SelectWithSearch
{...field}
placeholder={t('dataFlowPlaceholder')}
options={options}
/>
)}
{isMult && (
<MultiSelect
{...field}
onValueChange={field.onChange}
placeholder={t('dataFlowPlaceholder')}
options={options}
/>
)}
</>
</FormControl>
</div>
</div>
)}
<div className="flex pt-1">
<FormMessage />
</div>
</FormItem>
)}
/>
);
}

View File

@@ -0,0 +1,26 @@
import { cn } from '@/lib/utils';
import { PropsWithChildren } from 'react';
type DatasetConfigurationContainerProps = {
className?: string;
show?: boolean;
} & PropsWithChildren;
export function DatasetConfigurationContainer({
children,
className,
show = true,
}: DatasetConfigurationContainerProps) {
return show ? (
<div
className={cn(
'border p-2 rounded-lg bg-slate-50 dark:bg-gray-600',
className,
)}
>
{children}
</div>
) : (
children
);
}

View File

@@ -0,0 +1,85 @@
import { cn } from '@/lib/utils';
import { forwardRef } from 'react';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from './ui/form';
import { Input, InputProps } from './ui/input';
interface IProps {
value?: string | undefined;
onChange?: (val: string | undefined) => void;
}
export const DelimiterInput = forwardRef<HTMLInputElement, InputProps & IProps>(
({ value, onChange, maxLength, defaultValue, ...props }, ref) => {
const nextValue = value
?.replaceAll('\n', '\\n')
.replaceAll('\t', '\\t')
.replaceAll('\r', '\\r');
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
const nextValue = val
.replaceAll('\\n', '\n')
.replaceAll('\\t', '\t')
.replaceAll('\\r', '\r');
onChange?.(nextValue);
};
return (
<Input
value={nextValue}
onChange={handleInputChange}
maxLength={maxLength}
defaultValue={defaultValue}
ref={ref}
className={cn('bg-bg-base', props.className)}
{...props}
></Input>
);
},
);
export function DelimiterFormField() {
const { t } = useTranslation();
const form = useFormContext();
return (
<FormField
control={form.control}
name={'parser_config.delimiter'}
render={({ field }) => {
if (typeof field.value === 'undefined') {
// default value set
form.setValue('parser_config.delimiter', '\n');
}
return (
<FormItem className=" items-center space-y-0 ">
<div className="flex items-center gap-1">
<FormLabel
required
tooltip={t('knowledgeDetails.delimiterTip')}
className="text-sm text-text-secondary whitespace-break-spaces w-1/4"
>
{t('knowledgeDetails.delimiter')}
</FormLabel>
<div className="w-3/4">
<FormControl>
<DelimiterInput {...field}></DelimiterInput>
</FormControl>
</div>
</div>
<div className="flex pt-1">
<div className="w-1/4"></div>
<FormMessage />
</div>
</FormItem>
);
}}
/>
);
}

View File

@@ -0,0 +1,42 @@
import { Form, Input } from 'antd';
import { useTranslation } from 'react-i18next';
interface IProps {
value?: string | undefined;
onChange?: (val: string | undefined) => void;
maxLength?: number;
}
export const DelimiterInput = ({ value, onChange, maxLength }: IProps) => {
const nextValue = value?.replaceAll('\n', '\\n');
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
const nextValue = val.replaceAll('\\n', '\n');
onChange?.(nextValue);
};
return (
<Input
value={nextValue}
onChange={handleInputChange}
maxLength={maxLength}
></Input>
);
};
const Delimiter = () => {
const { t } = useTranslation();
return (
<Form.Item
name={['parser_config', 'delimiter']}
label={t('knowledgeDetails.delimiter')}
initialValue={`\n`}
rules={[{ required: true }]}
tooltip={t('knowledgeDetails.delimiterTip')}
>
<DelimiterInput />
</Form.Item>
);
};
export default Delimiter;

View File

@@ -0,0 +1,119 @@
import { PlusOutlined } from '@ant-design/icons';
import React, { useEffect, useRef, useState } from 'react';
import { X } from 'lucide-react';
import { Button } from '../ui/button';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '../ui/hover-card';
import { Input } from '../ui/input';
interface EditTagsProps {
value?: string[];
onChange?: (tags: string[]) => void;
}
const EditTag = React.forwardRef<HTMLDivElement, EditTagsProps>(
({ value = [], onChange }: EditTagsProps) => {
const [inputVisible, setInputVisible] = useState(false);
const [inputValue, setInputValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (inputVisible) {
inputRef.current?.focus();
}
}, [inputVisible]);
const handleClose = (removedTag: string) => {
const newTags = value?.filter((tag) => tag !== removedTag);
onChange?.(newTags ?? []);
};
const showInput = () => {
setInputVisible(true);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const handleInputConfirm = () => {
if (inputValue && value) {
const newTags = inputValue
.split(';')
.map((tag) => tag.trim())
.filter((tag) => tag && !value.includes(tag));
onChange?.([...value, ...newTags]);
}
setInputVisible(false);
setInputValue('');
};
const forMap = (tag: string) => {
return (
<HoverCard key={tag}>
<HoverCardContent side="top">{tag}</HoverCardContent>
<HoverCardTrigger asChild>
<div className="w-fit flex items-center justify-center gap-2 border-dashed border px-2 py-1 rounded-sm bg-bg-card">
<div className="flex gap-2 items-center">
<div className="max-w-80 overflow-hidden text-ellipsis">
{tag}
</div>
<X
className="w-4 h-4 text-muted-foreground hover:text-primary"
onClick={(e) => {
e.preventDefault();
handleClose(tag);
}}
/>
</div>
</div>
</HoverCardTrigger>
</HoverCard>
);
};
const tagChild = value?.map(forMap);
const tagPlusStyle: React.CSSProperties = {
borderStyle: 'dashed',
};
return (
<div>
{inputVisible && (
<Input
ref={inputRef}
type="text"
className="h-8 bg-bg-card mb-1"
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputConfirm}
onKeyDown={(e) => {
if (e?.key === 'Enter') {
handleInputConfirm();
}
}}
/>
)}
<div className="flex gap-2 py-1">
{Array.isArray(tagChild) && tagChild.length > 0 && <>{tagChild}</>}
{!inputVisible && (
<Button
variant="dashed"
className="w-fit flex items-center justify-center gap-2 bg-bg-card"
onClick={showInput}
style={tagPlusStyle}
>
<PlusOutlined />
</Button>
)}
</div>
</div>
);
},
);
export default EditTag;

View File

@@ -0,0 +1,98 @@
import { Form, FormInstance, Input, InputRef, Typography } from 'antd';
import { omit } from 'lodash';
import React, { useContext, useEffect, useRef, useState } from 'react';
const EditableContext = React.createContext<FormInstance<any> | null>(null);
const { Text } = Typography;
interface EditableRowProps {
index: number;
}
interface Item {
key: string;
name: string;
age: string;
address: string;
}
export const EditableRow: React.FC<EditableRowProps> = ({ ...props }) => {
const [form] = Form.useForm();
return (
<Form form={form} component={false}>
<EditableContext.Provider value={form}>
<tr {...omit(props, 'index')} />
</EditableContext.Provider>
</Form>
);
};
interface EditableCellProps {
title: React.ReactNode;
editable: boolean;
children: React.ReactNode;
dataIndex: keyof Item;
record: Item;
handleSave: (record: Item) => void;
}
export const EditableCell: React.FC<EditableCellProps> = ({
title,
editable,
children,
dataIndex,
record,
handleSave,
...restProps
}) => {
const [editing, setEditing] = useState(false);
const inputRef = useRef<InputRef>(null);
const form = useContext(EditableContext)!;
useEffect(() => {
if (editing) {
inputRef.current!.focus();
}
}, [editing]);
const toggleEdit = () => {
setEditing(!editing);
form.setFieldsValue({ [dataIndex]: record[dataIndex] });
};
const save = async () => {
try {
const values = await form.validateFields();
toggleEdit();
handleSave({ ...record, ...values });
} catch (errInfo) {
console.log('Save failed:', errInfo);
}
};
let childNode = children;
if (editable) {
childNode = editing ? (
<Form.Item
style={{ margin: 0, minWidth: 70 }}
name={dataIndex}
rules={[
{
required: true,
message: `${title} is required.`,
},
]}
>
<Input ref={inputRef} onPressEnter={save} onBlur={save} />
</Form.Item>
) : (
<div onClick={toggleEdit} className="editable-cell-value-wrap">
<Text>{children}</Text>
</div>
);
}
return <td {...restProps}>{childNode}</td>;
};

View File

@@ -0,0 +1,48 @@
import { useFetchAppConf } from '@/hooks/logic-hooks';
import { RefreshCcw } from 'lucide-react';
import { PropsWithChildren } from 'react';
import { RAGFlowAvatar } from './ragflow-avatar';
import { Button } from './ui/button';
type EmbedContainerProps = {
title: string;
avatar?: string;
handleReset?(): void;
} & PropsWithChildren;
export function EmbedContainer({
title,
avatar,
children,
handleReset,
}: EmbedContainerProps) {
const appConf = useFetchAppConf();
return (
<section className="h-[100vh] flex justify-center items-center">
<div className="w-40 flex gap-2 absolute left-3 top-12 items-center">
<img src="/logo.svg" alt="" />
<span className="text-2xl font-bold">{appConf.appName}</span>
</div>
<div className=" w-[80vw] border rounded-lg">
<div className="flex justify-between items-center border-b p-3">
<div className="flex gap-2 items-center">
<RAGFlowAvatar avatar={avatar} name={title} isPerson />
<div className="text-xl text-foreground">{title}</div>
</div>
<Button
variant={'secondary'}
className="text-sm text-foreground cursor-pointer"
onClick={handleReset}
>
<div className="flex gap-1 items-center">
<RefreshCcw size={14} />
<span className="text-lg ">Reset</span>
</div>
</Button>
</div>
{children}
</div>
</section>
);
}

View File

@@ -0,0 +1,268 @@
import CopyToClipboard from '@/components/copy-to-clipboard';
import HightLightMarkdown from '@/components/highlight-markdown';
import { SelectWithSearch } from '@/components/originui/select-with-search';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Switch } from '@/components/ui/switch';
import { SharedFrom } from '@/constants/chat';
import {
LanguageAbbreviation,
LanguageAbbreviationMap,
} from '@/constants/common';
import { useTranslate } from '@/hooks/common-hooks';
import { IModalProps } from '@/interfaces/common';
import { Routes } from '@/routes';
import { zodResolver } from '@hookform/resolvers/zod';
import { memo, useCallback, useMemo } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
const FormSchema = z.object({
visibleAvatar: z.boolean(),
locale: z.string(),
embedType: z.enum(['fullscreen', 'widget']),
enableStreaming: z.boolean(),
});
type IProps = IModalProps<any> & {
token: string;
from: SharedFrom;
beta: string;
isAgent: boolean;
};
function EmbedDialog({
hideModal,
token = '',
from,
beta = '',
isAgent,
}: IProps) {
const { t } = useTranslate('chat');
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
visibleAvatar: false,
locale: '',
embedType: 'fullscreen' as const,
enableStreaming: false,
},
});
const values = useWatch({ control: form.control });
const languageOptions = useMemo(() => {
return Object.values(LanguageAbbreviation).map((x) => ({
label: LanguageAbbreviationMap[x],
value: x,
}));
}, []);
const generateIframeSrc = useCallback(() => {
const { visibleAvatar, locale, embedType, enableStreaming } = values;
const baseRoute =
embedType === 'widget'
? Routes.ChatWidget
: from === SharedFrom.Agent
? Routes.AgentShare
: Routes.ChatShare;
let src = `${location.origin}${baseRoute}?shared_id=${token}&from=${from}&auth=${beta}`;
if (visibleAvatar) {
src += '&visible_avatar=1';
}
if (locale) {
src += `&locale=${locale}`;
}
if (enableStreaming) {
src += '&streaming=true';
}
return src;
}, [beta, from, token, values]);
const text = useMemo(() => {
const iframeSrc = generateIframeSrc();
const { embedType } = values;
if (embedType === 'widget') {
const { enableStreaming } = values;
const streamingParam = enableStreaming
? '&streaming=true'
: '&streaming=false';
return `
~~~ html
<iframe src="${iframeSrc}&mode=master${streamingParam}"
style="position:fixed;bottom:0;right:0;width:100px;height:100px;border:none;background:transparent;z-index:9999"
frameborder="0" allow="microphone;camera"></iframe>
<script>
window.addEventListener('message',e=>{
if(e.origin!=='${location.origin.replace(/:\d+/, ':9222')}')return;
if(e.data.type==='CREATE_CHAT_WINDOW'){
if(document.getElementById('chat-win'))return;
const i=document.createElement('iframe');
i.id='chat-win';i.src=e.data.src;
i.style.cssText='position:fixed;bottom:104px;right:24px;width:380px;height:500px;border:none;background:transparent;z-index:9998;display:none';
i.frameBorder='0';i.allow='microphone;camera';
document.body.appendChild(i);
}else if(e.data.type==='TOGGLE_CHAT'){
const w=document.getElementById('chat-win');
if(w)w.style.display=e.data.isOpen?'block':'none';
}else if(e.data.type==='SCROLL_PASSTHROUGH')window.scrollBy(0,e.data.deltaY);
});
</script>
~~~
`;
} else {
return `
~~~ html
<iframe
src="${iframeSrc}"
style="width: 100%; height: 100%; min-height: 600px"
frameborder="0"
>
</iframe>
~~~
`;
}
}, [generateIframeSrc, values]);
return (
<Dialog open onOpenChange={hideModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('embedIntoSite', { keyPrefix: 'common' })}
</DialogTitle>
</DialogHeader>
<section className="w-full overflow-auto space-y-5 text-sm text-text-secondary">
<Form {...form}>
<form className="space-y-5">
<FormField
control={form.control}
name="embedType"
render={({ field }) => (
<FormItem>
<FormLabel>Embed Type</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
value={field.value}
className="flex flex-col space-y-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="fullscreen" id="fullscreen" />
<Label htmlFor="fullscreen" className="text-sm">
Fullscreen Chat (Traditional iframe)
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="widget" id="widget" />
<Label htmlFor="widget" className="text-sm">
Floating Widget (Intercom-style)
</Label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="visibleAvatar"
render={({ field }) => (
<FormItem>
<FormLabel>{t('avatarHidden')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
></Switch>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{values.embedType === 'widget' && (
<FormField
control={form.control}
name="enableStreaming"
render={({ field }) => (
<FormItem>
<FormLabel>Enable Streaming Responses</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
></Switch>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="locale"
render={({ field }) => (
<FormItem>
<FormLabel>{t('locale')}</FormLabel>
<FormControl>
<SelectWithSearch
{...field}
options={languageOptions}
></SelectWithSearch>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<div className="max-h-[350px] overflow-auto">
<span>{t('embedCode', { keyPrefix: 'search' })}</span>
<div className="max-h-full overflow-y-auto">
<HightLightMarkdown>{text}</HightLightMarkdown>
</div>
</div>
<div className=" font-medium mt-4 mb-1">
{t(isAgent ? 'flow' : 'chat', { keyPrefix: 'header' })}
<span className="ml-1 inline-block">ID</span>
</div>
<div className="bg-bg-card rounded-lg flex justify-between p-2">
<span>{token} </span>
<CopyToClipboard text={token}></CopyToClipboard>
</div>
<a
className="cursor-pointer text-accent-primary inline-block"
href={
isAgent
? 'https://ragflow.io/docs/dev/http_api_reference#create-session-with-agent'
: 'https://ragflow.io/docs/dev/http_api_reference#create-session-with-chat-assistant'
}
target="_blank"
rel="noreferrer"
>
{t('howUseId', { keyPrefix: isAgent ? 'flow' : 'chat' })}
</a>
</section>
</DialogContent>
</Dialog>
);
}
export default memo(EmbedDialog);

View File

@@ -0,0 +1,87 @@
import { useSetModalState, useTranslate } from '@/hooks/common-hooks';
import { useFetchManualSystemTokenList } from '@/hooks/user-setting-hooks';
import { useCallback } from 'react';
import message from '../ui/message';
export const useShowTokenEmptyError = () => {
const { t } = useTranslate('chat');
const showTokenEmptyError = useCallback(() => {
message.error(t('tokenError'));
}, [t]);
return { showTokenEmptyError };
};
export const useShowBetaEmptyError = () => {
const { t } = useTranslate('chat');
const showBetaEmptyError = useCallback(() => {
message.error(t('betaError'));
}, [t]);
return { showBetaEmptyError };
};
export const useFetchTokenListBeforeOtherStep = () => {
const { showTokenEmptyError } = useShowTokenEmptyError();
const { showBetaEmptyError } = useShowBetaEmptyError();
const { data: tokenList, fetchSystemTokenList } =
useFetchManualSystemTokenList();
let token = '',
beta = '';
if (Array.isArray(tokenList) && tokenList.length > 0) {
token = tokenList[0].token;
beta = tokenList[0].beta;
}
token =
Array.isArray(tokenList) && tokenList.length > 0 ? tokenList[0].token : '';
const handleOperate = useCallback(async () => {
const ret = await fetchSystemTokenList();
const list = ret;
if (Array.isArray(list) && list.length > 0) {
if (!list[0].beta) {
showBetaEmptyError();
return false;
}
return list[0]?.token;
} else {
showTokenEmptyError();
return false;
}
}, [fetchSystemTokenList, showBetaEmptyError, showTokenEmptyError]);
return {
token,
beta,
handleOperate,
};
};
export const useShowEmbedModal = () => {
const {
visible: embedVisible,
hideModal: hideEmbedModal,
showModal: showEmbedModal,
} = useSetModalState();
const { handleOperate, token, beta } = useFetchTokenListBeforeOtherStep();
const handleShowEmbedModal = useCallback(async () => {
const succeed = await handleOperate();
if (succeed) {
showEmbedModal();
}
}, [handleOperate, showEmbedModal]);
return {
showEmbedModal: handleShowEmbedModal,
hideEmbedModal,
embedVisible,
embedToken: token,
beta,
};
};

View File

@@ -0,0 +1,77 @@
import { cn } from '@/lib/utils';
import { t } from 'i18next';
type EmptyProps = {
className?: string;
children?: React.ReactNode;
};
const EmptyIcon = () => (
<svg
width="184"
height="152"
viewBox="0 0 184 152"
xmlns="http://www.w3.org/2000/svg"
>
<title>{t('common.noData')}</title>
<g fill="none" fillRule="evenodd">
<g transform="translate(24 31.67)">
<ellipse
fillOpacity=".8"
fill="#F5F5F7"
cx="67.797"
cy="106.89"
rx="67.797"
ry="12.668"
></ellipse>
<path
d="M122.034 69.674L98.109 40.229c-1.148-1.386-2.826-2.225-4.593-2.225h-51.44c-1.766 0-3.444.839-4.592 2.225L13.56 69.674v15.383h108.475V69.674z"
fill="#AEB8C2"
></path>
<path
d="M101.537 86.214L80.63 61.102c-1.001-1.207-2.507-1.867-4.048-1.867H31.724c-1.54 0-3.047.66-4.048 1.867L6.769 86.214v13.792h94.768V86.214z"
fill="url(#linearGradient-1)"
transform="translate(13.56)"
></path>
<path
d="M33.83 0h67.933a4 4 0 0 1 4 4v93.344a4 4 0 0 1-4 4H33.83a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4z"
fill="#F5F5F7"
></path>
<path
d="M42.678 9.953h50.237a2 2 0 0 1 2 2V36.91a2 2 0 0 1-2 2H42.678a2 2 0 0 1-2-2V11.953a2 2 0 0 1 2-2zM42.94 49.767h49.713a2.262 2.262 0 1 1 0 4.524H42.94a2.262 2.262 0 0 1 0-4.524zM42.94 61.53h49.713a2.262 2.262 0 1 1 0 4.525H42.94a2.262 2.262 0 0 1 0-4.525zM121.813 105.032c-.775 3.071-3.497 5.36-6.735 5.36H20.515c-3.238 0-5.96-2.29-6.734-5.36a7.309 7.309 0 0 1-.222-1.79V69.675h26.318c2.907 0 5.25 2.448 5.25 5.42v.04c0 2.971 2.37 5.37 5.277 5.37h34.785c2.907 0 5.277-2.421 5.277-5.393V75.1c0-2.972 2.343-5.426 5.25-5.426h26.318v33.569c0 .617-.077 1.216-.221 1.789z"
fill="#DCE0E6"
></path>
</g>
<path
d="M149.121 33.292l-6.83 2.65a1 1 0 0 1-1.317-1.23l1.937-6.207c-2.589-2.944-4.109-6.534-4.109-10.408C138.802 8.102 148.92 0 161.402 0 173.881 0 184 8.102 184 18.097c0 9.995-10.118 18.097-22.599 18.097-4.528 0-8.744-1.066-12.28-2.902z"
fill="#DCE0E6"
></path>
<g transform="translate(149.65 15.383)" fill="#FFF">
<ellipse cx="20.654" cy="3.167" rx="2.849" ry="2.815"></ellipse>
<path d="M5.698 5.63H0L2.898.704zM9.259.704h4.985V5.63H9.259z"></path>
</g>
</g>
</svg>
);
const Empty = (props: EmptyProps) => {
const { className, children } = props;
return (
<div
className={cn(
'flex flex-col justify-center items-center text-center gap-3',
className,
)}
>
<EmptyIcon />
{!children && (
<div className="empty-text mt-4 text-text-secondary">
{t('common.noData')}
</div>
)}
{children}
</div>
);
};
export default Empty;

View File

@@ -0,0 +1,47 @@
import { useTranslate } from '@/hooks/common-hooks';
import { useFormContext } from 'react-hook-form';
import EditTag from './edit-tag';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from './ui/form';
type EntityTypesFormFieldProps = {
name?: string;
};
export function EntityTypesFormField({
name = 'parser_config.entity_types',
}: EntityTypesFormFieldProps) {
const { t } = useTranslate('knowledgeConfiguration');
const form = useFormContext();
return (
<FormField
control={form.control}
name={name}
render={({ field }) => {
return (
<FormItem className=" items-center space-y-0 ">
<div className="flex items-center">
<FormLabel className="text-sm whitespace-nowrap w-1/4">
<span className="text-red-600">*</span> {t('entityTypes')}
</FormLabel>
<div className="w-3/4">
<FormControl>
<EditTag {...field}></EditTag>
</FormControl>
</div>
</div>
<div className="flex pt-1">
<div className="w-1/4"></div>
<FormMessage />
</div>
</FormItem>
);
}}
/>
);
}

View File

@@ -0,0 +1,33 @@
import { useTranslate } from '@/hooks/common-hooks';
import { Form } from 'antd';
import EditTag from './edit-tag';
const initialEntityTypes = [
'organization',
'person',
'geo',
'event',
'category',
];
type IProps = {
field?: string[];
};
const EntityTypesItem = ({
field = ['parser_config', 'entity_types'],
}: IProps) => {
const { t } = useTranslate('knowledgeConfiguration');
return (
<Form.Item
name={field}
label={t('entityTypes')}
rules={[{ required: true }]}
initialValue={initialEntityTypes}
>
<EditTag value={field}></EditTag>
</Form.Item>
);
};
export default EntityTypesItem;

View File

@@ -0,0 +1,53 @@
import { useTranslate } from '@/hooks/common-hooks';
import { useFormContext } from 'react-hook-form';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from './ui/form';
import { Switch } from './ui/switch';
export function ExcelToHtmlFormField() {
const form = useFormContext();
const { t } = useTranslate('knowledgeDetails');
return (
<FormField
control={form.control}
name="parser_config.html4excel"
render={({ field }) => {
if (typeof field.value === 'undefined') {
// default value set
form.setValue('parser_config.html4excel', false);
}
return (
<FormItem defaultChecked={false} className=" items-center space-y-0 ">
<div className="flex items-center gap-1">
<FormLabel
tooltip={t('html4excelTip')}
className="text-sm text-text-secondary whitespace-break-spaces w-1/4"
>
{t('html4excel')}
</FormLabel>
<div className="w-3/4">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
></Switch>
</FormControl>
</div>
</div>
<div className="flex pt-1">
<div className="w-1/4"></div>
<FormMessage />
</div>
</FormItem>
);
}}
/>
);
}

View File

@@ -0,0 +1,19 @@
import { useTranslate } from '@/hooks/common-hooks';
import { Form, Switch } from 'antd';
const ExcelToHtml = () => {
const { t } = useTranslate('knowledgeDetails');
return (
<Form.Item
name={['parser_config', 'html4excel']}
label={t('html4excel')}
initialValue={false}
valuePropName="checked"
tooltip={t('html4excelTip')}
>
<Switch />
</Form.Item>
);
};
export default ExcelToHtml;

View File

@@ -0,0 +1,4 @@
.thumbnailImg {
display: inline-block;
max-width: 20px;
}

View File

@@ -0,0 +1,33 @@
import { getExtension } from '@/utils/document-util';
import SvgIcon from '../svg-icon';
import { useFetchDocumentThumbnailsByIds } from '@/hooks/document-hooks';
import { useEffect } from 'react';
import styles from './index.less';
interface IProps {
name: string;
id: string;
}
const FileIcon = ({ name, id }: IProps) => {
const fileExtension = getExtension(name);
const { data: fileThumbnails, setDocumentIds } =
useFetchDocumentThumbnailsByIds();
const fileThumbnail = fileThumbnails[id];
useEffect(() => {
if (id) {
setDocumentIds([id]);
}
}, [id, setDocumentIds]);
return fileThumbnail ? (
<img src={fileThumbnail} className={styles.thumbnailImg}></img>
) : (
<SvgIcon name={`file-icon/${fileExtension}`} width={24}></SvgIcon>
);
};
export default FileIcon;

View File

@@ -0,0 +1,62 @@
// src/pages/dataset/file-logs/file-status-badge.tsx
import { RunningStatus } from '@/pages/dataset/dataset/constant';
import { FC } from 'react';
/**
* params: status: 0 not run yet 1 running, 2 cancel, 3 success, 4 fail
*/
interface StatusBadgeProps {
// status: 'Success' | 'Failed' | 'Running' | 'Pending';
status: RunningStatus;
name?: string;
}
const FileStatusBadge: FC<StatusBadgeProps> = ({ status, name }) => {
const getStatusColor = () => {
// #3ba05c → rgb(59, 160, 92) // state-success
// #d8494b → rgb(216, 73, 75) // state-error
// #00beb4 → rgb(0, 190, 180) // accent-primary
// #faad14 → rgb(250, 173, 20) // state-warning
switch (status) {
case RunningStatus.DONE:
return `bg-[rgba(59,160,92,0.1)] text-state-success`;
case RunningStatus.FAIL:
return `bg-[rgba(216,73,75,0.1)] text-state-error`;
case RunningStatus.RUNNING:
return `bg-[rgba(0,190,180,0.1)] text-accent-primary`;
case RunningStatus.UNSTART:
return `bg-[rgba(250,173,20,0.1)] text-state-warning`;
default:
return 'bg-gray-500/10 text-text-secondary';
}
};
const getBgStatusColor = () => {
// #3ba05c → rgb(59, 160, 92) // state-success
// #d8494b → rgb(216, 73, 75) // state-error
// #00beb4 → rgb(0, 190, 180) // accent-primary
// #faad14 → rgb(250, 173, 20) // state-warning
switch (status) {
case RunningStatus.DONE:
return `bg-[rgba(59,160,92,1)] text-state-success`;
case RunningStatus.FAIL:
return `bg-[rgba(216,73,75,1)] text-state-error`;
case RunningStatus.RUNNING:
return `bg-[rgba(0,190,180,1)] text-accent-primary`;
case RunningStatus.UNSTART:
return `bg-[rgba(250,173,20,1)] text-state-warning`;
default:
return `bg-[rgba(117,120,122,1)] text-text-secondary`;
}
};
return (
<span
className={`inline-flex items-center w-[75px] px-2 py-1 rounded-full text-xs font-medium ${getStatusColor()}`}
>
<div className={`w-1 h-1 mr-1 rounded-full ${getBgStatusColor()}`}></div>
{name || ''}
</span>
);
};
export default FileStatusBadge;

View File

@@ -0,0 +1,126 @@
import { ButtonLoading } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { IModalProps } from '@/interfaces/common';
import { zodResolver } from '@hookform/resolvers/zod';
import { TFunction } from 'i18next';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { FileUploader } from '../file-uploader';
import { RAGFlowFormItem } from '../ragflow-form';
import { Form } from '../ui/form';
import { Switch } from '../ui/switch';
function buildUploadFormSchema(t: TFunction) {
const FormSchema = z.object({
parseOnCreation: z.boolean().optional(),
fileList: z
.array(z.instanceof(File))
.min(1, { message: t('fileManager.pleaseUploadAtLeastOneFile') }),
});
return FormSchema;
}
export type UploadFormSchemaType = z.infer<
ReturnType<typeof buildUploadFormSchema>
>;
const UploadFormId = 'UploadFormId';
type UploadFormProps = {
submit: (values?: UploadFormSchemaType) => void;
showParseOnCreation?: boolean;
};
function UploadForm({ submit, showParseOnCreation }: UploadFormProps) {
const { t } = useTranslation();
const FormSchema = buildUploadFormSchema(t);
type UploadFormSchemaType = z.infer<typeof FormSchema>;
const form = useForm<UploadFormSchemaType>({
resolver: zodResolver(FormSchema),
defaultValues: {
parseOnCreation: false,
fileList: [],
},
});
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(submit)}
id={UploadFormId}
className="space-y-4"
>
{showParseOnCreation && (
<RAGFlowFormItem
name="parseOnCreation"
label={t('fileManager.parseOnCreation')}
>
{(field) => (
<Switch
onCheckedChange={field.onChange}
checked={field.value}
></Switch>
)}
</RAGFlowFormItem>
)}
<RAGFlowFormItem name="fileList" label={t('fileManager.file')}>
{(field) => (
<FileUploader
value={field.value}
onValueChange={field.onChange}
accept={{ '*': [] }}
/>
)}
</RAGFlowFormItem>
</form>
</Form>
);
}
type FileUploadDialogProps = IModalProps<UploadFormSchemaType> &
Pick<UploadFormProps, 'showParseOnCreation'>;
export function FileUploadDialog({
hideModal,
onOk,
loading,
showParseOnCreation = false,
}: FileUploadDialogProps) {
const { t } = useTranslation();
return (
<Dialog open onOpenChange={hideModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('fileManager.uploadFile')}</DialogTitle>
</DialogHeader>
<Tabs defaultValue="account">
<TabsList className="grid w-full grid-cols-2 mb-4">
<TabsTrigger value="account">{t('fileManager.local')}</TabsTrigger>
<TabsTrigger value="password">{t('fileManager.s3')}</TabsTrigger>
</TabsList>
<TabsContent value="account">
<UploadForm
submit={onOk!}
showParseOnCreation={showParseOnCreation}
></UploadForm>
</TabsContent>
<TabsContent value="password">{t('common.comingSoon')}</TabsContent>
</Tabs>
<DialogFooter>
<ButtonLoading type="submit" loading={loading} form={UploadFormId}>
{t('common.save')}
</ButtonLoading>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,13 @@
.uploader {
:global {
.ant-upload-list {
max-height: 40vh;
overflow-y: auto;
}
}
}
.uploadLimit {
color: red;
font-size: 12px;
}

View File

@@ -0,0 +1,191 @@
import { useTranslate } from '@/hooks/common-hooks';
import { IModalProps } from '@/interfaces/common';
import { InboxOutlined } from '@ant-design/icons';
import {
Checkbox,
Flex,
Modal,
Progress,
Segmented,
Tabs,
TabsProps,
Upload,
UploadFile,
UploadProps,
} from 'antd';
import { Dispatch, SetStateAction, useState } from 'react';
import styles from './index.less';
const { Dragger } = Upload;
const FileUpload = ({
directory,
fileList,
setFileList,
uploadProgress,
}: {
directory: boolean;
fileList: UploadFile[];
setFileList: Dispatch<SetStateAction<UploadFile[]>>;
uploadProgress?: number;
}) => {
const { t } = useTranslate('fileManager');
const props: UploadProps = {
multiple: true,
onRemove: (file) => {
const index = fileList.indexOf(file);
const newFileList = fileList.slice();
newFileList.splice(index, 1);
setFileList(newFileList);
},
beforeUpload: (file: UploadFile) => {
setFileList((pre) => {
return [...pre, file];
});
return false;
},
directory,
fileList,
progress: {
strokeWidth: 2,
},
};
return (
<>
<Progress percent={uploadProgress} showInfo={false} />
<Dragger {...props} className={styles.uploader}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">{t('uploadTitle')}</p>
<p className="ant-upload-hint">{t('uploadDescription')}</p>
{false && <p className={styles.uploadLimit}>{t('uploadLimit')}</p>}
</Dragger>
</>
);
};
interface IFileUploadModalProps
extends IModalProps<
{ parseOnCreation: boolean; directoryFileList: UploadFile[] } | UploadFile[]
> {
uploadFileList?: UploadFile[];
setUploadFileList?: Dispatch<SetStateAction<UploadFile[]>>;
uploadProgress?: number;
setUploadProgress?: Dispatch<SetStateAction<number>>;
}
const FileUploadModal = ({
visible,
hideModal,
loading,
onOk: onFileUploadOk,
uploadFileList: fileList,
setUploadFileList: setFileList,
uploadProgress,
setUploadProgress,
}: IFileUploadModalProps) => {
const { t } = useTranslate('fileManager');
const [value, setValue] = useState<string | number>('local');
const [parseOnCreation, setParseOnCreation] = useState(false);
const [currentFileList, setCurrentFileList] = useState<UploadFile[]>([]);
const [directoryFileList, setDirectoryFileList] = useState<UploadFile[]>([]);
const clearFileList = () => {
if (setFileList) {
setFileList([]);
setUploadProgress?.(0);
} else {
setCurrentFileList([]);
}
setDirectoryFileList([]);
};
const onOk = async () => {
if (uploadProgress === 100) {
hideModal?.();
return;
}
const ret = await onFileUploadOk?.(
fileList
? { parseOnCreation, directoryFileList }
: [...currentFileList, ...directoryFileList],
);
return ret;
};
const afterClose = () => {
clearFileList();
};
const items: TabsProps['items'] = [
{
key: '1',
label: t('file'),
children: (
<FileUpload
directory={false}
fileList={fileList ? fileList : currentFileList}
setFileList={setFileList ? setFileList : setCurrentFileList}
uploadProgress={uploadProgress}
></FileUpload>
),
},
{
key: '2',
label: t('directory'),
children: (
<FileUpload
directory
fileList={directoryFileList}
setFileList={setDirectoryFileList}
uploadProgress={uploadProgress}
></FileUpload>
),
},
];
return (
<>
<Modal
title={t('uploadFile')}
open={visible}
onOk={onOk}
onCancel={hideModal}
confirmLoading={loading}
afterClose={afterClose}
>
<Flex gap={'large'} vertical>
<Segmented
options={[
{ label: t('local'), value: 'local' },
{ label: t('s3'), value: 's3' },
]}
block
value={value}
onChange={setValue}
/>
{value === 'local' ? (
<>
<Checkbox
checked={parseOnCreation}
onChange={(e) => setParseOnCreation(e.target.checked)}
>
{t('parseOnCreation')}
</Checkbox>
<Tabs defaultActiveKey="1" items={items} />
</>
) : (
t('comingSoon', { keyPrefix: 'common' })
)}
</Flex>
</Modal>
</>
);
};
export default FileUploadModal;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,337 @@
// https://github.com/sadmann7/file-uploader
'use client';
import { FileText, Upload, X } from 'lucide-react';
import * as React from 'react';
import Dropzone, {
type DropzoneProps,
type FileRejection,
} from 'react-dropzone';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useControllableState } from '@/hooks/use-controllable-state';
import { cn, formatBytes } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
function isFileWithPreview(file: File): file is File & { preview: string } {
return 'preview' in file && typeof file.preview === 'string';
}
interface FileCardProps {
file: File;
onRemove: () => void;
progress?: number;
}
interface FilePreviewProps {
file: File & { preview: string };
}
function FilePreview({ file }: FilePreviewProps) {
if (file.type.startsWith('image/')) {
return (
<img
src={file.preview}
alt={file.name}
width={48}
height={48}
loading="lazy"
className="aspect-square shrink-0 rounded-md object-cover"
/>
);
}
return (
<FileText className="size-10 text-muted-foreground" aria-hidden="true" />
);
}
function FileCard({ file, progress, onRemove }: FileCardProps) {
return (
<div className="relative flex items-center gap-2.5">
<div className="flex flex-1 gap-2.5">
{isFileWithPreview(file) ? <FilePreview file={file} /> : null}
<div className="flex w-full flex-col gap-2">
<div className="flex flex-col gap-px">
<p className="line-clamp-1 text-sm font-medium text-foreground/80">
{file.name}
</p>
<p className="text-xs text-muted-foreground">
{formatBytes(file.size)}
</p>
</div>
{progress ? <Progress value={progress} /> : null}
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
className="size-7"
onClick={onRemove}
>
<X className="size-4" aria-hidden="true" />
<span className="sr-only">Remove file</span>
</Button>
</div>
</div>
);
}
interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Value of the uploader.
* @type File[]
* @default undefined
* @example value={files}
*/
value?: File[];
/**
* Function to be called when the value changes.
* @type (files: File[]) => void
* @default undefined
* @example onValueChange={(files) => setFiles(files)}
*/
onValueChange?: (files: File[]) => void;
/**
* Function to be called when files are uploaded.
* @type (files: File[]) => Promise<void>
* @default undefined
* @example onUpload={(files) => uploadFiles(files)}
*/
onUpload?: (files: File[]) => Promise<void>;
/**
* Progress of the uploaded files.
* @type Record<string, number> | undefined
* @default undefined
* @example progresses={{ "file1.png": 50 }}
*/
progresses?: Record<string, number>;
/**
* Accepted file types for the uploader.
* @type { [key: string]: string[]}
* @default
* ```ts
* { "image/*": [] }
* ```
* @example accept={["image/png", "image/jpeg"]}
*/
accept?: DropzoneProps['accept'];
/**
* Maximum file size for the uploader.
* @type number | undefined
* @default 1024 * 1024 * 2 // 2MB
* @example maxSize={1024 * 1024 * 2} // 2MB
*/
maxSize?: DropzoneProps['maxSize'];
/**
* Maximum number of files for the uploader.
* @type number | undefined
* @default 1
* @example maxFileCount={4}
*/
maxFileCount?: DropzoneProps['maxFiles'];
/**
* Whether the uploader should accept multiple files.
* @type boolean
* @default false
* @example multiple
*/
multiple?: boolean;
/**
* Whether the uploader is disabled.
* @type boolean
* @default false
* @example disabled
*/
disabled?: boolean;
description?: string;
}
export function FileUploader(props: FileUploaderProps) {
const {
value: valueProp,
onValueChange,
onUpload,
progresses,
accept = {
'image/*': [],
},
maxSize = 1024 * 1024 * 10000000,
maxFileCount = 100000000000,
multiple = false,
disabled = false,
className,
description,
...dropzoneProps
} = props;
const { t } = useTranslation();
const [files, setFiles] = useControllableState({
prop: valueProp,
onChange: onValueChange,
});
const onDrop = React.useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
if (!multiple && maxFileCount === 1 && acceptedFiles.length > 1) {
toast.error('Cannot upload more than 1 file at a time');
return;
}
if ((files?.length ?? 0) + acceptedFiles.length > maxFileCount) {
toast.error(`Cannot upload more than ${maxFileCount} files`);
return;
}
const newFiles = acceptedFiles.map((file) =>
Object.assign(file, {
preview: URL.createObjectURL(file),
}),
);
const updatedFiles = files ? [...files, ...newFiles] : newFiles;
setFiles(updatedFiles);
if (rejectedFiles.length > 0) {
rejectedFiles.forEach(({ file }) => {
toast.error(`File ${file.name} was rejected`);
});
}
if (
onUpload &&
updatedFiles.length > 0 &&
updatedFiles.length <= maxFileCount
) {
const target =
updatedFiles.length > 0 ? `${updatedFiles.length} files` : `file`;
toast.promise(onUpload(updatedFiles), {
loading: `Uploading ${target}...`,
success: () => {
setFiles([]);
return `${target} uploaded`;
},
error: `Failed to upload ${target}`,
});
}
},
[files, maxFileCount, multiple, onUpload, setFiles],
);
function onRemove(index: number) {
if (!files) return;
const newFiles = files.filter((_, i) => i !== index);
setFiles(newFiles);
onValueChange?.(newFiles);
}
// Revoke preview url when component unmounts
React.useEffect(() => {
return () => {
if (!files) return;
files.forEach((file) => {
if (isFileWithPreview(file)) {
URL.revokeObjectURL(file.preview);
}
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount;
return (
<div className="relative flex flex-col gap-6 overflow-hidden">
<Dropzone
onDrop={onDrop}
accept={accept}
maxSize={maxSize}
maxFiles={maxFileCount}
multiple={maxFileCount > 1 || multiple}
disabled={isDisabled}
>
{({ getRootProps, getInputProps, isDragActive }) => (
<div
{...getRootProps()}
className={cn(
'group relative grid h-72 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed border-muted-foreground/25 px-5 py-2.5 text-center transition hover:bg-muted/25',
'ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isDragActive && 'border-muted-foreground/50',
isDisabled && 'pointer-events-none opacity-60',
className,
)}
{...dropzoneProps}
>
<input {...getInputProps()} />
{isDragActive ? (
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
<div className="rounded-full border border-dashed p-3">
<Upload
className="size-7 text-muted-foreground"
aria-hidden="true"
/>
</div>
<p className="font-medium text-muted-foreground">
Drop the files here
</p>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
<div className="rounded-full border border-dashed p-3">
<Upload
className="size-7 text-muted-foreground"
aria-hidden="true"
/>
</div>
<div className="flex flex-col gap-px">
<p className="font-medium text-muted-foreground">
{t('knowledgeDetails.uploadTitle')}
</p>
<p className="text-sm text-muted-foreground/70">
{description || t('knowledgeDetails.uploadDescription')}
{/* You can upload
{maxFileCount > 1
? ` ${maxFileCount === Infinity ? 'multiple' : maxFileCount}
files (up to ${formatBytes(maxSize)} each)`
: ` a file with ${formatBytes(maxSize)}`} */}
</p>
</div>
</div>
)}
</div>
)}
</Dropzone>
{files?.length ? (
<ScrollArea className="h-fit w-full px-3">
<div className="flex max-h-48 flex-col gap-4">
{files?.map((file, index) => (
<FileCard
key={index}
file={file}
onRemove={() => onRemove(index)}
progress={progresses?.[file.name]}
/>
))}
</div>
</ScrollArea>
) : null}
</div>
);
}

View File

@@ -0,0 +1,58 @@
/* floating-chat-widget-markdown.less */
.widget-citation-popover {
max-width: 90vw;
/* Use viewport width for better responsiveness */
width: max-content;
.ant-popover-inner {
max-height: 400px;
overflow-y: auto;
}
.ant-popover-inner-content {
padding: 12px;
}
}
/* Responsive breakpoints for popover width */
@media (min-width: 480px) {
.widget-citation-popover {
max-width: 360px;
}
}
.widget-citation-content {
p,
div,
span,
button {
word-break: break-word;
overflow-wrap: break-word;
white-space: normal;
}
}
.floating-chat-widget {
/* General styles for markdown content within the widget */
p,
div,
ul,
ol,
blockquote {
line-height: 1.6;
}
/* Enhanced image styles */
img,
.ant-image,
.ant-image-img {
max-width: 100% !important;
height: auto !important;
border-radius: 8px;
margin: 8px 0 !important;
display: inline-block !important;
}
}

View File

@@ -0,0 +1,317 @@
import Image from '@/components/image';
import SvgIcon from '@/components/svg-icon';
import {
useFetchDocumentThumbnailsByIds,
useGetDocumentUrl,
} from '@/hooks/document-hooks';
import { IReference, IReferenceChunk } from '@/interfaces/database/chat';
import {
preprocessLaTeX,
replaceThinkToSection,
showImage,
} from '@/utils/chat';
import { getExtension } from '@/utils/document-util';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Button, Flex, Popover, Tooltip } from 'antd';
import classNames from 'classnames';
import DOMPurify from 'dompurify';
import 'katex/dist/katex.min.css';
import { omit } from 'lodash';
import { pipe } from 'lodash/fp';
import { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import Markdown from 'react-markdown';
import reactStringReplace from 'react-string-replace';
import SyntaxHighlighter from 'react-syntax-highlighter';
import {
oneDark,
oneLight,
} from 'react-syntax-highlighter/dist/esm/styles/prism';
import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { visitParents } from 'unist-util-visit-parents';
import { currentReg, replaceTextByOldReg } from '../pages/next-chats/utils';
import styles from './floating-chat-widget-markdown.less';
import { useIsDarkTheme } from './theme-provider';
const getChunkIndex = (match: string) => Number(match.replace(/\[|\]/g, ''));
const FloatingChatWidgetMarkdown = ({
reference,
clickDocumentButton,
content,
}: {
content: string;
loading: boolean;
reference: IReference;
clickDocumentButton?: (documentId: string, chunk: IReferenceChunk) => void;
}) => {
const { t } = useTranslation();
const { setDocumentIds, data: fileThumbnails } =
useFetchDocumentThumbnailsByIds();
const getDocumentUrl = useGetDocumentUrl();
const isDarkTheme = useIsDarkTheme();
const contentWithCursor = useMemo(() => {
let text = content === '' ? t('chat.searching') : content;
const nextText = replaceTextByOldReg(text);
return pipe(replaceThinkToSection, preprocessLaTeX)(nextText);
}, [content, t]);
useEffect(() => {
const docAggs = reference?.doc_aggs;
const docList = Array.isArray(docAggs)
? docAggs
: Object.values(docAggs ?? {});
setDocumentIds(docList.map((x: any) => x.doc_id).filter(Boolean));
}, [reference, setDocumentIds]);
const handleDocumentButtonClick = useCallback(
(
documentId: string,
chunk: IReferenceChunk,
isPdf: boolean,
documentUrl?: string,
) =>
() => {
if (!documentId) return;
if (!isPdf && documentUrl) {
window.open(documentUrl, '_blank');
} else if (clickDocumentButton) {
clickDocumentButton(documentId, chunk);
}
},
[clickDocumentButton],
);
const rehypeWrapReference = () => (tree: any) => {
visitParents(tree, 'text', (node, ancestors) => {
const latestAncestor = ancestors[ancestors.length - 1];
if (
latestAncestor.tagName !== 'custom-typography' &&
latestAncestor.tagName !== 'code'
) {
node.type = 'element';
node.tagName = 'custom-typography';
node.properties = {};
node.children = [{ type: 'text', value: node.value }];
}
});
};
const getReferenceInfo = useCallback(
(chunkIndex: number) => {
const chunkItem = reference?.chunks?.[chunkIndex];
if (!chunkItem) return null;
const docAggsArray = Array.isArray(reference?.doc_aggs)
? reference.doc_aggs
: Object.values(reference?.doc_aggs ?? {});
const document = docAggsArray.find(
(x: any) => x?.doc_id === chunkItem?.document_id,
) as any;
const documentId = document?.doc_id;
const documentUrl =
document?.url ?? (documentId ? getDocumentUrl(documentId) : undefined);
const fileThumbnail = documentId ? fileThumbnails[documentId] : '';
const fileExtension = documentId
? getExtension(document?.doc_name ?? '')
: '';
return {
documentUrl,
fileThumbnail,
fileExtension,
imageId: chunkItem.image_id,
chunkItem,
documentId,
document,
};
},
[fileThumbnails, reference, getDocumentUrl],
);
const getPopoverContent = useCallback(
(chunkIndex: number) => {
const info = getReferenceInfo(chunkIndex);
if (!info) {
return (
<div className="p-2 text-xs text-red-500">
Error: Missing document information.
</div>
);
}
const {
documentUrl,
fileThumbnail,
fileExtension,
imageId,
chunkItem,
documentId,
document,
} = info;
return (
<div
key={`popover-content-${chunkItem.id}`}
className="flex gap-2 widget-citation-content"
>
{imageId && (
<Popover
placement="left"
content={
<Image
id={imageId}
className="max-w-[80vw] max-h-[60vh] rounded"
/>
}
>
<Image
id={imageId}
className="w-24 h-24 object-contain rounded m-1 cursor-pointer"
/>
</Popover>
)}
<div className="space-y-2 flex-1 min-w-0">
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(chunkItem?.content ?? ''),
}}
className="max-h-[250px] overflow-y-auto text-xs leading-relaxed p-2 bg-gray-50 dark:bg-gray-800 rounded prose-sm"
></div>
{documentId && (
<Flex gap={'small'} align="center">
{fileThumbnail ? (
<img
src={fileThumbnail}
alt={document?.doc_name}
className="w-6 h-6 rounded"
/>
) : (
<SvgIcon name={`file-icon/${fileExtension}`} width={20} />
)}
<Tooltip
title={
!documentUrl && fileExtension !== 'pdf'
? 'Document link unavailable'
: document.doc_name
}
>
<Button
type="link"
size="small"
className="p-0 text-xs break-words h-auto text-left flex-1"
onClick={handleDocumentButtonClick(
documentId,
chunkItem,
fileExtension === 'pdf',
documentUrl,
)}
disabled={!documentUrl && fileExtension !== 'pdf'}
style={{ whiteSpace: 'normal' }}
>
<span className="truncate">
{document?.doc_name ?? 'Unnamed Document'}
</span>
</Button>
</Tooltip>
</Flex>
)}
</div>
</div>
);
},
[getReferenceInfo, handleDocumentButtonClick],
);
const renderReference = useCallback(
(text: string) => {
return reactStringReplace(text, currentReg, (match, i) => {
const chunkIndex = getChunkIndex(match);
const info = getReferenceInfo(chunkIndex);
if (!info) {
return (
<Tooltip key={`err-tooltip-${i}`} title="Reference unavailable">
<InfoCircleOutlined className={styles.referenceIcon} />
</Tooltip>
);
}
const { imageId, chunkItem, documentId, fileExtension, documentUrl } =
info;
if (showImage(chunkItem?.doc_type)) {
return (
<Image
key={`img-${i}`}
id={imageId}
className="block object-contain max-w-full max-h-48 rounded my-2 cursor-pointer"
onClick={handleDocumentButtonClick(
documentId,
chunkItem,
fileExtension === 'pdf',
documentUrl,
)}
/>
);
}
return (
<Popover content={getPopoverContent(chunkIndex)} key={`popover-${i}`}>
<InfoCircleOutlined className={styles.referenceIcon} />
</Popover>
);
});
},
[getPopoverContent, getReferenceInfo, handleDocumentButtonClick],
);
return (
<div className="floating-chat-widget">
<Markdown
rehypePlugins={[rehypeWrapReference, rehypeKatex, rehypeRaw]}
remarkPlugins={[remarkGfm, remarkMath]}
className="text-sm leading-relaxed space-y-2 prose-sm max-w-full"
components={
{
'custom-typography': ({ children }: { children: string }) =>
renderReference(children),
code(props: any) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { children, className, node, ...rest } = props;
const match = /language-(\w+)/.exec(className || '');
return match ? (
<SyntaxHighlighter
{...omit(rest, 'inline')}
PreTag="div"
language={match[1]}
style={isDarkTheme ? oneDark : oneLight}
wrapLongLines
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code
{...rest}
className={classNames(
className,
'text-wrap text-xs bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded',
)}
>
{children}
</code>
);
},
} as any
}
>
{contentWithCursor}
</Markdown>
</div>
);
};
export default FloatingChatWidgetMarkdown;

View File

@@ -0,0 +1,708 @@
import PdfDrawer from '@/components/pdf-drawer';
import { useClickDrawer } from '@/components/pdf-drawer/hooks';
import { MessageType } from '@/constants/chat';
import { useFetchExternalChatInfo } from '@/hooks/use-chat-request';
import i18n from '@/locales/config';
import { MessageCircle, Minimize2, Send, X } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
useGetSharedChatSearchParams,
useSendSharedMessage,
} from '../pages/next-chats/hooks/use-send-shared-message';
import FloatingChatWidgetMarkdown from './floating-chat-widget-markdown';
const FloatingChatWidget = () => {
const [isOpen, setIsOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [inputValue, setInputValue] = useState('');
const [lastResponseId, setLastResponseId] = useState<string | null>(null);
const [displayMessages, setDisplayMessages] = useState<any[]>([]);
const [isLoaded, setIsLoaded] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { sharedId: conversationId, locale } = useGetSharedChatSearchParams();
// Check if we're in button-only mode or window-only mode
const urlParams = new URLSearchParams(window.location.search);
const mode = urlParams.get('mode') || 'full'; // 'button', 'window', or 'full'
const enableStreaming = urlParams.get('streaming') === 'true'; // Only enable if explicitly set to true
const {
handlePressEnter,
handleInputChange,
value: hookValue,
sendLoading,
derivedMessages,
hasError,
} = useSendSharedMessage();
// Sync our local input with the hook's value when needed
useEffect(() => {
if (hookValue && hookValue !== inputValue) {
setInputValue(hookValue);
}
}, [hookValue, inputValue]);
const { data: chatInfo } = useFetchExternalChatInfo();
const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } =
useClickDrawer();
// PDF drawer state tracking
useEffect(() => {
// Drawer state management
}, [visible, documentId, selectedChunk]);
// Play sound when opening
const playNotificationSound = useCallback(() => {
try {
const audioContext = new (window.AudioContext ||
(window as any).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800;
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(
0.01,
audioContext.currentTime + 0.3,
);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.3);
} catch (error) {
// Silent fail if audio not supported
}
}, []);
// Play sound for AI responses (Intercom-style)
const playResponseSound = useCallback(() => {
try {
const audioContext = new (window.AudioContext ||
(window as any).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 600;
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.2, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(
0.01,
audioContext.currentTime + 0.2,
);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.2);
} catch (error) {
// Silent fail if audio not supported
}
}, []);
// Set loaded state and locale
useEffect(() => {
// Set component as loaded after a brief moment to prevent flash
const timer = setTimeout(() => {
setIsLoaded(true);
// Tell parent window that we're ready to be shown
window.parent.postMessage(
{
type: 'WIDGET_READY',
},
'*',
);
}, 50);
if (locale && i18n.language !== locale) {
i18n.changeLanguage(locale);
}
return () => clearTimeout(timer);
}, [locale]);
// Handle message display based on streaming preference
useEffect(() => {
if (!derivedMessages) {
setDisplayMessages([]);
return;
}
if (enableStreaming) {
// Show messages as they stream
setDisplayMessages(derivedMessages);
} else {
// Only show complete messages (non-streaming mode)
const completeMessages = derivedMessages.filter((msg, index) => {
// Always show user messages immediately
if (msg.role === MessageType.User) return true;
// For AI messages, only show when response is complete (not loading)
if (msg.role === MessageType.Assistant) {
return !sendLoading || index < derivedMessages.length - 1;
}
return true;
});
setDisplayMessages(completeMessages);
}
}, [derivedMessages, enableStreaming, sendLoading]);
// Auto-scroll to bottom when display messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [displayMessages]);
// Render different content based on mode
// Master mode - handles everything and creates second iframe dynamically
useEffect(() => {
if (mode !== 'master') return;
// Create the chat window iframe dynamically when needed
const createChatWindow = () => {
// Check if iframe already exists in parent document
window.parent.postMessage(
{
type: 'CREATE_CHAT_WINDOW',
src: window.location.href.replace('mode=master', 'mode=window'),
},
'*',
);
};
createChatWindow();
// Listen for our own toggle events to show/hide the dynamic iframe
const handleToggle = (e: MessageEvent) => {
if (e.source === window) return; // Ignore our own messages
const chatWindow = document.getElementById(
'dynamic-chat-window',
) as HTMLIFrameElement;
if (chatWindow && e.data.type === 'TOGGLE_CHAT') {
chatWindow.style.display = e.data.isOpen ? 'block' : 'none';
}
};
window.addEventListener('message', handleToggle);
return () => window.removeEventListener('message', handleToggle);
}, [mode]);
// Play sound only when AI response is complete (not streaming chunks)
useEffect(() => {
if (derivedMessages && derivedMessages.length > 0 && !sendLoading) {
const lastMessage = derivedMessages[derivedMessages.length - 1];
if (
lastMessage.role === MessageType.Assistant &&
lastMessage.id !== lastResponseId &&
derivedMessages.length > 1
) {
setLastResponseId(lastMessage.id || '');
playResponseSound();
}
}
}, [derivedMessages, sendLoading, lastResponseId, playResponseSound]);
const toggleChat = useCallback(() => {
if (mode === 'button') {
// In button mode, communicate with parent window to show/hide chat window
window.parent.postMessage(
{
type: 'TOGGLE_CHAT',
isOpen: !isOpen,
},
'*',
);
setIsOpen(!isOpen);
if (!isOpen) {
playNotificationSound();
}
} else {
// In full mode, handle locally
if (!isOpen) {
setIsOpen(true);
setIsMinimized(false);
playNotificationSound();
} else {
setIsOpen(false);
setIsMinimized(false);
}
}
}, [isOpen, mode, playNotificationSound]);
const minimizeChat = useCallback(() => {
setIsMinimized(true);
}, []);
const handleSendMessage = useCallback(() => {
if (!inputValue.trim() || sendLoading) return;
// Update the hook's internal state first
const syntheticEvent = {
target: { value: inputValue },
currentTarget: { value: inputValue },
preventDefault: () => {},
} as any;
handleInputChange(syntheticEvent);
// Wait for state to update, then send
setTimeout(() => {
handlePressEnter([]);
// Clear our local input after sending
setInputValue('');
}, 50);
}, [inputValue, sendLoading, handleInputChange, handlePressEnter]);
const handleKeyPress = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
},
[handleSendMessage],
);
if (!conversationId) {
return (
<div className="fixed bottom-5 right-5 z-50">
<div className="bg-red-500 text-white p-4 rounded-lg shadow-lg">
Error: No conversation ID provided
</div>
</div>
);
}
// Remove the blocking return - we'll handle visibility with CSS instead
const messageCount = displayMessages?.length || 0;
// Show just the button in master mode
if (mode === 'master') {
return (
<div
className={`fixed bottom-6 right-6 z-50 transition-opacity duration-300 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
>
<button
type="button"
onClick={() => {
const newIsOpen = !isOpen;
setIsOpen(newIsOpen);
if (newIsOpen) playNotificationSound();
// Tell the parent to show/hide the dynamic iframe
window.parent.postMessage(
{
type: 'TOGGLE_CHAT',
isOpen: newIsOpen,
},
'*',
);
}}
className={`w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full transition-all duration-300 flex items-center justify-center group ${
isOpen ? 'scale-95' : 'scale-100 hover:scale-105'
}`}
>
<div
className={`transition-transform duration-300 ${isOpen ? 'rotate-45' : 'rotate-0'}`}
>
{isOpen ? <X size={24} /> : <MessageCircle size={24} />}
</div>
</button>
{/* Unread Badge */}
{!isOpen && messageCount > 0 && (
<div className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center animate-pulse">
{messageCount > 9 ? '9+' : messageCount}
</div>
)}
</div>
);
}
if (mode === 'button') {
// Only render the floating button
return (
<div
className={`fixed bottom-6 right-6 z-50 transition-opacity duration-300 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
>
<button
type="button"
onClick={toggleChat}
className={`w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full transition-all duration-300 flex items-center justify-center group ${
isOpen ? 'scale-95' : 'scale-100 hover:scale-105'
}`}
>
<div
className={`transition-transform duration-300 ${isOpen ? 'rotate-45' : 'rotate-0'}`}
>
{isOpen ? <X size={24} /> : <MessageCircle size={24} />}
</div>
</button>
{/* Unread Badge */}
{!isOpen && messageCount > 0 && (
<div className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center animate-pulse">
{messageCount > 9 ? '9+' : messageCount}
</div>
)}
</div>
);
}
if (mode === 'window') {
// Only render the chat window (always open)
return (
<>
<div
className={`fixed top-0 left-0 z-50 bg-blue-600 rounded-2xl transition-all duration-300 ease-out h-[500px] w-[380px] overflow-hidden ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
>
{/* Header */}
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-t-2xl">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center">
<MessageCircle size={18} />
</div>
<div>
<h3 className="font-semibold text-sm">
{chatInfo?.title || 'Chat Support'}
</h3>
<p className="text-xs text-blue-100">
We typically reply instantly
</p>
</div>
</div>
</div>
{/* Messages and Input */}
<div
className="flex flex-col h-[436px] bg-white"
style={{ borderRadius: '0 0 16px 16px' }}
>
<div
className="flex-1 overflow-y-auto p-4 space-y-4"
onWheel={(e) => {
const element = e.currentTarget;
const isAtTop = element.scrollTop === 0;
const isAtBottom =
element.scrollTop + element.clientHeight >=
element.scrollHeight - 1;
// Allow scroll to pass through to parent when at boundaries
if ((isAtTop && e.deltaY < 0) || (isAtBottom && e.deltaY > 0)) {
e.preventDefault();
// Let the parent handle the scroll
window.parent.postMessage(
{
type: 'SCROLL_PASSTHROUGH',
deltaY: e.deltaY,
},
'*',
);
}
}}
>
{displayMessages?.map((message, index) => (
<div
key={index}
className={`flex ${message.role === MessageType.User ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[280px] px-4 py-2 rounded-2xl ${
message.role === MessageType.User
? 'bg-blue-600 text-white rounded-br-md'
: 'bg-gray-100 text-gray-800 rounded-bl-md'
}`}
>
{message.role === MessageType.User ? (
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{message.content}
</p>
) : (
<FloatingChatWidgetMarkdown
loading={false}
content={message.content}
reference={
message.reference || {
doc_aggs: [],
chunks: [],
total: 0,
}
}
clickDocumentButton={clickDocumentButton}
/>
)}
</div>
</div>
))}
{/* Clean Typing Indicator */}
{sendLoading && !enableStreaming && (
<div className="flex justify-start pl-4">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"></div>
<div
className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"
style={{ animationDelay: '0.1s' }}
></div>
<div
className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"
style={{ animationDelay: '0.2s' }}
></div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="border-t border-gray-200 p-4">
<div className="flex items-end space-x-3">
<div className="flex-1">
<textarea
value={inputValue}
onChange={(e) => {
const newValue = e.target.value;
setInputValue(newValue);
handleInputChange(e);
}}
onKeyPress={handleKeyPress}
placeholder="Type your message..."
rows={1}
className="w-full resize-none border border-gray-300 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-black"
style={{ minHeight: '44px', maxHeight: '120px' }}
disabled={hasError || sendLoading}
/>
</div>
<button
type="button"
onClick={handleSendMessage}
disabled={!inputValue.trim() || sendLoading}
className="p-3 bg-blue-600 text-white rounded-full hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Send size={18} />
</button>
</div>
</div>
</div>
</div>
<PdfDrawer
visible={visible}
hideModal={hideModal}
documentId={documentId}
chunk={selectedChunk}
width={'100vw'}
height={'100vh'}
/>
</>
);
} // Full mode - render everything together (original behavior)
return (
<div
className={`transition-opacity duration-300 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
>
{/* Chat Widget Container */}
{isOpen && (
<div
className={`fixed bottom-24 right-6 z-50 bg-blue-600 rounded-2xl transition-all duration-300 ease-out ${
isMinimized ? 'h-16' : 'h-[500px]'
} w-[380px] overflow-hidden`}
>
{/* Header */}
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-t-2xl">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center">
<MessageCircle size={18} />
</div>
<div>
<h3 className="font-semibold text-sm">
{chatInfo?.title || 'Chat Support'}
</h3>
<p className="text-xs text-blue-100">
We typically reply instantly
</p>
</div>
</div>
<div className="flex items-center space-x-1">
<button
type="button"
onClick={minimizeChat}
className="p-1.5 hover:bg-white hover:bg-opacity-20 rounded-full transition-colors"
>
<Minimize2 size={16} />
</button>
<button
type="button"
onClick={toggleChat}
className="p-1.5 hover:bg-white hover:bg-opacity-20 rounded-full transition-colors"
>
<X size={16} />
</button>
</div>
</div>
{/* Messages Container */}
{!isMinimized && (
<div
className="flex flex-col h-[436px] bg-white"
style={{ borderRadius: '0 0 16px 16px' }}
>
<div
className="flex-1 overflow-y-auto p-4 space-y-4"
onWheel={(e) => {
const element = e.currentTarget;
const isAtTop = element.scrollTop === 0;
const isAtBottom =
element.scrollTop + element.clientHeight >=
element.scrollHeight - 1;
// Allow scroll to pass through to parent when at boundaries
if (
(isAtTop && e.deltaY < 0) ||
(isAtBottom && e.deltaY > 0)
) {
e.preventDefault();
// Let the parent handle the scroll
window.parent.postMessage(
{
type: 'SCROLL_PASSTHROUGH',
deltaY: e.deltaY,
},
'*',
);
}
}}
>
{displayMessages?.map((message, index) => (
<div
key={index}
className={`flex ${message.role === MessageType.User ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[280px] px-4 py-2 rounded-2xl ${
message.role === MessageType.User
? 'bg-blue-600 text-white rounded-br-md'
: 'bg-gray-100 text-gray-800 rounded-bl-md'
}`}
>
{message.role === MessageType.User ? (
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{message.content}
</p>
) : (
<FloatingChatWidgetMarkdown
loading={false}
content={message.content}
reference={
message.reference || {
doc_aggs: [],
chunks: [],
total: 0,
}
}
clickDocumentButton={clickDocumentButton}
/>
)}
</div>
</div>
))}
{/* Typing Indicator */}
{sendLoading && (
<div className="flex justify-start">
<div className="bg-gray-100 rounded-2xl rounded-bl-md px-4 py-3">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
style={{ animationDelay: '0.1s' }}
></div>
<div
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
style={{ animationDelay: '0.2s' }}
></div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="border-t border-gray-200 p-4">
<div className="flex items-end space-x-3">
<div className="flex-1">
<textarea
value={inputValue}
onChange={(e) => {
const newValue = e.target.value;
setInputValue(newValue);
// Also update the hook's state
handleInputChange(e);
}}
onKeyPress={handleKeyPress}
placeholder="Type your message..."
rows={1}
className="w-full resize-none border border-gray-300 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-black"
style={{ minHeight: '44px', maxHeight: '120px' }}
disabled={hasError || sendLoading}
/>
</div>
<button
type="button"
onClick={handleSendMessage}
disabled={!inputValue.trim() || sendLoading}
className="p-3 bg-blue-600 text-white rounded-full hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Send size={18} />
</button>
</div>
</div>
</div>
)}
</div>
)}
{/* Floating Button */}
<div className="fixed bottom-6 right-6 z-50">
<button
type="button"
onClick={toggleChat}
className={`w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full transition-all duration-300 flex items-center justify-center group ${
isOpen ? 'scale-95' : 'scale-100 hover:scale-105'
}`}
>
<div
className={`transition-transform duration-300 ${isOpen ? 'rotate-45' : 'rotate-0'}`}
>
{isOpen ? <X size={24} /> : <MessageCircle size={24} />}
</div>
</button>
{/* Unread Badge */}
{!isOpen && messageCount > 0 && (
<div className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center animate-pulse">
{messageCount > 9 ? '9+' : messageCount}
</div>
)}
</div>
<PdfDrawer
visible={visible}
hideModal={hideModal}
documentId={documentId}
chunk={selectedChunk}
/>
</div>
);
};
export default FloatingChatWidget;

View File

@@ -0,0 +1,21 @@
import { cn } from '@/lib/utils';
import { PropsWithChildren } from 'react';
export type FormContainerProps = {
className?: string;
show?: boolean;
} & PropsWithChildren;
export function FormContainer({
children,
show = true,
className,
}: FormContainerProps) {
return show ? (
<section className={cn('border rounded-lg p-5 space-y-5', className)}>
{children}
</section>
) : (
children
);
}

View File

@@ -0,0 +1,19 @@
.text {
.chunkText;
font-size: 16px;
li {
padding: 4px 0px;
}
// p {
// white-space: pre-wrap; // https://stackoverflow.com/questions/60332183/new-line-with-react-markdown
// }
}
.code {
padding: 3px 6px 6px;
margin: 0;
white-space: break-spaces;
background-color: rgba(129, 139, 152, 0.12);
border-radius: 4px;
color: var(--ant-color-text-base);
}

View File

@@ -0,0 +1,59 @@
import classNames from 'classnames';
import Markdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import {
oneDark,
oneLight,
} from 'react-syntax-highlighter/dist/esm/styles/prism';
import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you
import { preprocessLaTeX } from '@/utils/chat';
import { useIsDarkTheme } from '../theme-provider';
import styles from './index.less';
const HightLightMarkdown = ({
children,
}: {
children: string | null | undefined;
}) => {
const isDarkTheme = useIsDarkTheme();
return (
<Markdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeRaw, rehypeKatex]}
className={classNames(styles.text)}
components={
{
code(props: any) {
const { children, className, ...rest } = props;
const match = /language-(\w+)/.exec(className || '');
return match ? (
<SyntaxHighlighter
{...rest}
PreTag="div"
language={match[1]}
style={isDarkTheme ? oneDark : oneLight}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code {...rest} className={`${className} ${styles.code}`}>
{children}
</code>
);
},
} as any
}
>
{children ? preprocessLaTeX(children) : children}
</Markdown>
);
};
export default HightLightMarkdown;

View File

@@ -0,0 +1,66 @@
import { RAGFlowAvatar } from '@/components/ragflow-avatar';
import { Card, CardContent } from '@/components/ui/card';
import { formatDate } from '@/utils/date';
import { ReactNode } from 'react';
interface IProps {
data: {
name: string;
description?: string;
avatar?: string;
update_time?: string | number;
};
onClick?: () => void;
moreDropdown: React.ReactNode;
sharedBadge?: ReactNode;
icon?: React.ReactNode;
}
export function HomeCard({
data,
onClick,
moreDropdown,
sharedBadge,
icon,
}: IProps) {
return (
<Card
onClick={() => {
// navigateToSearch(data?.id);
onClick?.();
}}
>
<CardContent className="p-4 flex gap-2 items-start group h-full">
<div className="flex justify-between mb-4">
<RAGFlowAvatar
className="w-[32px] h-[32px]"
avatar={data.avatar}
name={data.name}
/>
</div>
<div className="flex flex-col justify-between gap-1 flex-1 h-full w-[calc(100%-50px)]">
<section className="flex justify-between">
<section className="flex flex-1 min-w-0 gap-1 items-center">
<div className="text-base font-bold leading-snug truncate">
{data.name}
</div>
{icon}
</section>
{moreDropdown}
</section>
<section className="flex flex-col gap-1 mt-1">
<div className="whitespace-nowrap overflow-hidden text-ellipsis">
{data.description}
</div>
<div className="flex justify-between items-center">
<p className="text-sm opacity-80 whitespace-nowrap">
{formatDate(data.update_time)}
</p>
{sharedBadge}
</div>
</section>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener('change', onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange);
}, []);
return !!isMobile;
}

View File

@@ -0,0 +1,194 @@
'use client';
// Inspired by react-hot-toast library
import * as React from 'react';
import type {
ToastActionElement,
ToastProps,
} from '@/registry/default/ui/toast';
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType['ADD_TOAST'];
toast: ToasterToast;
}
| {
type: ActionType['UPDATE_TOAST'];
toast: Partial<ToasterToast>;
}
| {
type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast['id'];
}
| {
type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast['id'];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
case 'DISMISS_TOAST': {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
};
}
export { toast, useToast };

View File

@@ -0,0 +1,47 @@
import { FileIconMap } from '@/constants/file';
import { cn } from '@/lib/utils';
import { getExtension } from '@/utils/document-util';
type IconFontType = {
name: string;
className?: string;
};
export const IconFont = ({ name, className }: IconFontType) => (
<svg className={cn('size-4', className)}>
<use xlinkHref={`#icon-${name}`} />
</svg>
);
export function IconFontFill({
name,
className,
isFill = true,
}: IconFontType & { isFill?: boolean }) {
return (
<span className={cn('size-4', className)}>
<svg
className={cn('size-4', className)}
style={{ fill: isFill ? 'currentColor' : '' }}
>
<use xlinkHref={`#icon-${name}`} />
</svg>
</span>
);
}
export function FileIcon({
name,
className,
type,
}: IconFontType & { type?: string }) {
const isFolder = type === 'folder';
return (
<span className={cn('size-4', className)}>
<IconFont
name={isFolder ? 'file-sub' : FileIconMap[getExtension(name)]}
></IconFont>
</span>
);
}

View File

@@ -0,0 +1,35 @@
import { api_host } from '@/utils/api';
import classNames from 'classnames';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
interface IImage {
id: string;
className: string;
onClick?(): void;
}
const Image = ({ id, className, ...props }: IImage) => {
return (
<img
{...props}
src={`${api_host}/document/image/${id}`}
alt=""
className={classNames('max-w-[45vw] max-h-[40wh] block', className)}
/>
);
};
export default Image;
export const ImageWithPopover = ({ id }: { id: string }) => {
return (
<Popover>
<PopoverTrigger>
<Image id={id} className="max-h-[100px] inline-block"></Image>
</PopoverTrigger>
<PopoverContent>
<Image id={id} className="max-w-[100px] object-contain"></Image>
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,430 @@
import { Rect } from '@antv/g';
import {
Badge,
BaseBehavior,
BaseNode,
CommonEvent,
ExtensionCategory,
Graph,
NodeEvent,
Point,
Polyline,
PolylineStyleProps,
register,
subStyleProps,
treeToGraphData,
} from '@antv/g6';
import { TreeData } from '@antv/g6/lib/types';
import isEmpty from 'lodash/isEmpty';
import React, { useCallback, useEffect, useRef } from 'react';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import { useIsDarkTheme } from '../theme-provider';
const rootId = 'root';
const COLORS = [
'#5B8FF9',
'#F6BD16',
'#5AD8A6',
'#945FB9',
'#E86452',
'#6DC8EC',
'#FF99C3',
'#1E9493',
'#FF9845',
'#5D7092',
];
const TreeEvent = {
COLLAPSE_EXPAND: 'collapse-expand',
WHEEL: 'canvas:wheel',
};
class IndentedNode extends BaseNode {
static defaultStyleProps = {
ports: [
{
key: 'in',
placement: 'right-bottom',
},
{
key: 'out',
placement: 'left-bottom',
},
],
} as any;
constructor(options: any) {
Object.assign(options.style, IndentedNode.defaultStyleProps);
super(options);
}
get childrenData() {
return this.attributes.context?.model.getChildrenData(this.id);
}
getKeyStyle(attributes: any) {
const [width, height] = this.getSize(attributes);
const keyStyle = super.getKeyStyle(attributes);
return {
width,
height,
...keyStyle,
fill: 'transparent',
};
}
drawKeyShape(attributes: any, container: any) {
const keyStyle = this.getKeyStyle(attributes);
return this.upsert('key', Rect, keyStyle, container);
}
getLabelStyle(attributes: any) {
if (attributes.label === false || !attributes.labelText) return false;
return subStyleProps(this.getGraphicStyle(attributes), 'label') as any;
}
drawIconArea(attributes: any, container: any) {
const [, h] = this.getSize(attributes);
const iconAreaStyle = {
fill: 'transparent',
height: 30,
width: 12,
x: -6,
y: h,
zIndex: -1,
};
this.upsert('icon-area', Rect, iconAreaStyle, container);
}
forwardEvent(target: any, type: any, listener: any) {
if (target && !Reflect.has(target, '__bind__')) {
Reflect.set(target, '__bind__', true);
target.addEventListener(type, listener);
}
}
getCountStyle(attributes: any) {
const { collapsed, color } = attributes;
if (collapsed) {
const [, height] = this.getSize(attributes);
return {
backgroundFill: color,
cursor: 'pointer',
fill: '#fff',
fontSize: 8,
padding: [0, 10],
text: `${this.childrenData?.length}`,
textAlign: 'center',
y: height + 8,
};
}
return false;
}
drawCountShape(attributes: any, container: any) {
const countStyle = this.getCountStyle(attributes);
const btn = this.upsert('count', Badge, countStyle as any, container);
this.forwardEvent(btn, CommonEvent.CLICK, (event: any) => {
event.stopPropagation();
attributes.context.graph.emit(TreeEvent.COLLAPSE_EXPAND, {
id: this.id,
collapsed: false,
});
});
}
isShowCollapse(attributes: any) {
return (
!attributes.collapsed &&
Array.isArray(this.childrenData) &&
this.childrenData?.length > 0
);
}
getCollapseStyle(attributes: any) {
const { showIcon, color } = attributes;
if (!this.isShowCollapse(attributes)) return false;
const [, height] = this.getSize(attributes);
return {
visibility: showIcon ? 'visible' : 'hidden',
backgroundFill: color,
backgroundHeight: 12,
backgroundWidth: 12,
cursor: 'pointer',
fill: '#fff',
fontFamily: 'iconfont',
fontSize: 8,
text: '\ue6e4',
textAlign: 'center',
x: -1, // half of edge line width
y: height + 8,
};
}
drawCollapseShape(attributes: any, container: any) {
const iconStyle = this.getCollapseStyle(attributes);
const btn = this.upsert(
'collapse-expand',
Badge,
iconStyle as any,
container,
);
this.forwardEvent(btn, CommonEvent.CLICK, (event: any) => {
event.stopPropagation();
attributes.context.graph.emit(TreeEvent.COLLAPSE_EXPAND, {
id: this.id,
collapsed: !attributes.collapsed,
});
});
}
getAddStyle(attributes: any) {
const { collapsed, showIcon } = attributes;
if (collapsed) return false;
const [, height] = this.getSize(attributes);
const color = '#ddd';
const lineWidth = 1;
return {
visibility: showIcon ? 'visible' : 'hidden',
backgroundFill: '#fff',
backgroundHeight: 12,
backgroundLineWidth: lineWidth,
backgroundStroke: color,
backgroundWidth: 12,
cursor: 'pointer',
fill: color,
fontFamily: 'iconfont',
text: '\ue664',
textAlign: 'center',
x: -1,
y: height + (this.isShowCollapse(attributes) ? 22 : 8),
};
}
render(attributes = this.parsedAttributes, container = this) {
super.render(attributes, container);
this.drawCountShape(attributes, container);
this.drawIconArea(attributes, container);
this.drawCollapseShape(attributes, container);
}
}
class IndentedEdge extends Polyline {
getControlPoints(
attributes: Required<PolylineStyleProps>,
sourcePoint: Point,
targetPoint: Point,
) {
const [sx] = sourcePoint;
const [, ty] = targetPoint;
return [[sx, ty]] as any;
}
}
class CollapseExpandTree extends BaseBehavior {
constructor(context: any, options: any) {
super(context, options);
this.bindEvents();
}
update(options: any) {
this.unbindEvents();
super.update(options);
this.bindEvents();
}
bindEvents() {
const { graph } = this.context;
graph.on(NodeEvent.POINTER_ENTER, this.showIcon);
graph.on(NodeEvent.POINTER_LEAVE, this.hideIcon);
graph.on(TreeEvent.COLLAPSE_EXPAND, this.onCollapseExpand);
}
unbindEvents() {
const { graph } = this.context;
graph.off(NodeEvent.POINTER_ENTER, this.showIcon);
graph.off(NodeEvent.POINTER_LEAVE, this.hideIcon);
graph.off(TreeEvent.COLLAPSE_EXPAND, this.onCollapseExpand);
}
status = 'idle';
showIcon = (event: any) => {
this.setIcon(event, true);
};
hideIcon = (event: any) => {
this.setIcon(event, false);
};
setIcon = (event: any, show: boolean) => {
if (this.status !== 'idle') return;
const { target } = event;
const id = target.id;
const { graph, element } = this.context;
graph.updateNodeData([{ id, style: { showIcon: show } }]);
element?.draw({ animation: false, silence: true });
};
onCollapseExpand = async (event: any) => {
this.status = 'busy';
const { id, collapsed } = event;
const { graph } = this.context;
if (collapsed) await graph.collapseElement(id);
else await graph.expandElement(id);
this.status = 'idle';
};
}
register(ExtensionCategory.NODE, 'indented', IndentedNode);
register(ExtensionCategory.EDGE, 'indented', IndentedEdge);
register(
ExtensionCategory.BEHAVIOR,
'collapse-expand-tree',
CollapseExpandTree,
);
interface IProps {
data: TreeData;
show: boolean;
style?: React.CSSProperties;
}
function fallbackRender({ error }: FallbackProps) {
// Call resetErrorBoundary() to reset the error boundary and retry the render.
return (
<div role="alert">
<p>Something went wrong:</p>
<pre style={{ color: 'red' }}>{error.message}</pre>
</div>
);
}
const IndentedTree = ({ data, show, style = {} }: IProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const graphRef = useRef<Graph | null>(null);
const assignIds = React.useCallback(function assignIds(
node: TreeData,
parentId: string = '',
index = 0,
) {
if (!node.id) node.id = parentId ? `${parentId}-${index}` : 'root';
if (node.children) {
node.children.forEach((child, idx) => assignIds(child, node.id, idx));
}
}, []);
const isDark = useIsDarkTheme();
const render = useCallback(
async (data: TreeData) => {
const graph: Graph = new Graph({
container: containerRef.current!,
x: 60,
node: {
type: 'indented',
style: {
size: (d) => [d.id.length * 6 + 10, 20],
labelBackground: (datum) => datum.id === rootId,
labelBackgroundRadius: 0,
labelBackgroundFill: '#576286',
labelFill: isDark ? '#fff' : '#333',
// labelFill: (datum) => (datum.id === rootId ? '#fff' : '#666'),
labelText: (d) => d.style?.labelText || d.id,
labelTextAlign: (datum) =>
datum.id === rootId ? 'center' : 'left',
labelTextBaseline: 'top',
color: (datum: any) => {
const depth = graph.getAncestorsData(datum.id, 'tree').length - 1;
return COLORS[depth % COLORS.length] || '#576286';
},
},
state: {
selected: {
lineWidth: 0,
labelFill: '#40A8FF',
labelBackground: true,
labelFontWeight: 'normal',
labelBackgroundFill: '#e8f7ff',
labelBackgroundRadius: 10,
},
},
},
edge: {
type: 'indented',
style: {
radius: 16,
lineWidth: 2,
sourcePort: 'out',
targetPort: 'in',
stroke: (datum: any) => {
const depth = graph.getAncestorsData(datum.source, 'tree').length;
return COLORS[depth % COLORS.length] || 'black';
},
},
},
layout: {
type: 'indented',
direction: 'LR',
isHorizontal: true,
indent: 40,
getHeight: () => 20,
getVGap: () => 10,
},
behaviors: [
'scroll-canvas',
'collapse-expand-tree',
{
type: 'click-select',
enable: (event: any) =>
event.targetType === 'node' && event.target.id !== rootId,
},
],
});
if (graphRef.current) {
graphRef.current.destroy();
}
graphRef.current = graph;
assignIds(data);
graph?.setData(treeToGraphData(data));
graph?.render();
},
[assignIds],
);
useEffect(() => {
if (!isEmpty(data)) {
render(data);
}
}, [render, data]);
return (
<ErrorBoundary fallbackRender={fallbackRender}>
<div
id="tree"
ref={containerRef}
style={{
width: '90vw',
height: '80vh',
display: show ? 'block' : 'none',
...style,
}}
/>
</ErrorBoundary>
);
};
export default IndentedTree;

View File

@@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next';
import IndentedTree from './indented-tree';
import { useFetchKnowledgeGraph } from '@/hooks/knowledge-hooks';
import { IModalProps } from '@/interfaces/common';
import { Modal } from 'antd';
const IndentedTreeModal = ({
visible,
hideModal,
}: IModalProps<any> & { documentId: string }) => {
const { data } = useFetchKnowledgeGraph();
const { t } = useTranslation();
return (
<Modal
title={t('chunk.mind')}
open={visible}
onCancel={hideModal}
width={'90vw'}
footer={null}
>
<section>
<IndentedTree data={data?.mind_map} show></IndentedTree>
</section>
</Modal>
);
};
export default IndentedTreeModal;

View File

@@ -0,0 +1,155 @@
import { DocumentParserType } from '@/constants/knowledge';
import { useTranslate } from '@/hooks/common-hooks';
import { useFetchKnowledgeList } from '@/hooks/knowledge-hooks';
import { useBuildQueryVariableOptions } from '@/pages/agent/hooks/use-get-begin-query';
import { UserOutlined } from '@ant-design/icons';
import { Avatar as AntAvatar, Form, Select, Space } from 'antd';
import { toLower } from 'lodash';
import { useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { RAGFlowAvatar } from './ragflow-avatar';
import { FormControl, FormField, FormItem, FormLabel } from './ui/form';
import { MultiSelect } from './ui/multi-select';
interface KnowledgeBaseItemProps {
label?: string;
tooltipText?: string;
name?: string;
required?: boolean;
onChange?(): void;
}
const KnowledgeBaseItem = ({
label,
tooltipText,
name,
required = true,
onChange,
}: KnowledgeBaseItemProps) => {
const { t } = useTranslate('chat');
const { list: knowledgeList } = useFetchKnowledgeList(true);
const filteredKnowledgeList = knowledgeList.filter(
(x) => x.parser_id !== DocumentParserType.Tag,
);
const knowledgeOptions = filteredKnowledgeList.map((x) => ({
label: (
<Space>
<AntAvatar size={20} icon={<UserOutlined />} src={x.avatar} />
{x.name}
</Space>
),
value: x.id,
}));
return (
<Form.Item
label={label || t('knowledgeBases')}
name={name || 'kb_ids'}
tooltip={tooltipText || t('knowledgeBasesTip')}
rules={[
{
required,
message: t('knowledgeBasesMessage'),
type: 'array',
},
]}
>
<Select
mode="multiple"
options={knowledgeOptions}
placeholder={t('knowledgeBasesMessage')}
onChange={onChange}
></Select>
</Form.Item>
);
};
export default KnowledgeBaseItem;
function buildQueryVariableOptionsByShowVariable(showVariable?: boolean) {
return showVariable ? useBuildQueryVariableOptions : () => [];
}
export function KnowledgeBaseFormField({
showVariable = false,
}: {
showVariable?: boolean;
}) {
const form = useFormContext();
const { t } = useTranslation();
const { list: knowledgeList } = useFetchKnowledgeList(true);
const filteredKnowledgeList = knowledgeList.filter(
(x) => x.parser_id !== DocumentParserType.Tag,
);
const nextOptions = buildQueryVariableOptionsByShowVariable(showVariable)();
const knowledgeOptions = filteredKnowledgeList.map((x) => ({
label: x.name,
value: x.id,
icon: () => (
<RAGFlowAvatar className="size-4 mr-2" avatar={x.avatar} name={x.name} />
),
}));
const options = useMemo(() => {
if (showVariable) {
return [
{
label: t('knowledgeDetails.dataset'),
options: knowledgeOptions,
},
...nextOptions.map((x) => {
return {
...x,
options: x.options
.filter((y) => toLower(y.type).includes('string'))
.map((x) => ({
...x,
icon: () => (
<RAGFlowAvatar
className="size-4 mr-2"
avatar={x.label}
name={x.label}
/>
),
})),
};
}),
];
}
return knowledgeOptions;
}, [knowledgeOptions, nextOptions, showVariable, t]);
return (
<FormField
control={form.control}
name="kb_ids"
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t('chat.knowledgeBasesTip')}>
{t('chat.knowledgeBases')}
</FormLabel>
<FormControl>
<MultiSelect
options={options}
onValueChange={field.onChange}
placeholder={t('chat.knowledgeBasesMessage')}
variant="inverted"
maxCount={100}
defaultValue={field.value}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
);
}

View File

@@ -0,0 +1,128 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { LlmModelType } from '@/constants/knowledge';
import { t } from 'i18next';
import { Funnel } from 'lucide-react';
import { useFormContext, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { NextInnerLLMSelectProps, NextLLMSelect } from './llm-select/next';
import { Button } from './ui/button';
const ModelTypes = [
{
title: t('flow.allModels'),
value: 'all',
},
{
title: t('flow.textOnlyModels'),
value: LlmModelType.Chat,
},
{
title: t('flow.multimodalModels'),
value: LlmModelType.Image2text,
},
];
export const LargeModelFilterFormSchema = {
llm_filter: z.string().optional(),
};
type LargeModelFormFieldProps = Pick<
NextInnerLLMSelectProps,
'showSpeech2TextModel'
>;
export function LargeModelFormField({
showSpeech2TextModel: showTTSModel,
}: LargeModelFormFieldProps) {
const form = useFormContext();
const { t } = useTranslation();
const filter = useWatch({ control: form.control, name: 'llm_filter' });
return (
<>
<FormField
control={form.control}
name="llm_id"
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t('chat.modelTip')}>
{t('chat.model')}
</FormLabel>
<section className="flex gap-2.5">
<FormField
control={form.control}
name="llm_filter"
render={({ field }) => (
<FormItem>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant={'ghost'}>
<Funnel />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{ModelTypes.map((x) => (
<DropdownMenuItem
key={x.value}
onClick={() => {
field.onChange(x.value);
}}
>
{x.title}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
</FormItem>
)}
/>
<FormControl>
<NextLLMSelect
{...field}
filter={filter}
showSpeech2TextModel={showTTSModel}
/>
</FormControl>
</section>
<FormMessage />
</FormItem>
)}
/>
</>
);
}
export function LargeModelFormFieldWithoutFilter() {
const form = useFormContext();
return (
<FormField
control={form.control}
name="llm_id"
render={({ field }) => (
<FormItem>
<FormControl>
<NextLLMSelect {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@@ -0,0 +1,110 @@
import { LlmModelType } from '@/constants/knowledge';
import { useTranslate } from '@/hooks/common-hooks';
import { useSelectLlmOptionsByModelType } from '@/hooks/llm-hooks';
import { cn } from '@/lib/utils';
import { camelCase } from 'lodash';
import { ReactNode, useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { SelectWithSearch } from './originui/select-with-search';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from './ui/form';
export const enum ParseDocumentType {
DeepDOC = 'DeepDOC',
PlainText = 'Plain Text',
MinerU = 'MinerU',
}
export function LayoutRecognizeFormField({
name = 'parser_config.layout_recognize',
horizontal = true,
optionsWithoutLLM,
label,
}: {
name?: string;
horizontal?: boolean;
optionsWithoutLLM?: { value: string; label: string }[];
label?: ReactNode;
}) {
const form = useFormContext();
const { t } = useTranslate('knowledgeDetails');
const allOptions = useSelectLlmOptionsByModelType();
const options = useMemo(() => {
const list = optionsWithoutLLM
? optionsWithoutLLM
: [
ParseDocumentType.DeepDOC,
ParseDocumentType.PlainText,
ParseDocumentType.MinerU,
].map((x) => ({
label: x === ParseDocumentType.PlainText ? t(camelCase(x)) : x,
value: x,
}));
const image2TextList = allOptions[LlmModelType.Image2text].map((x) => {
return {
...x,
options: x.options.map((y) => {
return {
...y,
label: (
<div className="flex justify-between items-center gap-2">
{y.label}
<span className="text-red-500 text-sm">Experimental</span>
</div>
),
};
}),
};
});
return [...list, ...image2TextList];
}, [allOptions, optionsWithoutLLM, t]);
return (
<FormField
control={form.control}
name={name}
render={({ field }) => {
return (
<FormItem className={'items-center space-y-0 '}>
<div
className={cn('flex', {
'flex-col ': !horizontal,
'items-center': horizontal,
})}
>
<FormLabel
tooltip={t('layoutRecognizeTip')}
className={cn('text-sm text-text-secondary whitespace-wrap', {
['w-1/4']: horizontal,
})}
>
{label || t('layoutRecognize')}
</FormLabel>
<div className={horizontal ? 'w-3/4' : 'w-full'}>
<FormControl>
<SelectWithSearch
{...field}
options={options}
></SelectWithSearch>
</FormControl>
</div>
</div>
<div className="flex pt-1">
<div className={horizontal ? 'w-1/4' : 'w-full'}></div>
<FormMessage />
</div>
</FormItem>
);
}}
/>
);
}

View File

@@ -0,0 +1,55 @@
import { LlmModelType } from '@/constants/knowledge';
import { useTranslate } from '@/hooks/common-hooks';
import { useSelectLlmOptionsByModelType } from '@/hooks/llm-hooks';
import { Form, Select } from 'antd';
import { camelCase } from 'lodash';
import { useMemo } from 'react';
const enum DocumentType {
DeepDOC = 'DeepDOC',
PlainText = 'Plain Text',
}
const LayoutRecognize = () => {
const { t } = useTranslate('knowledgeDetails');
const allOptions = useSelectLlmOptionsByModelType();
const options = useMemo(() => {
const list = [DocumentType.DeepDOC, DocumentType.PlainText].map((x) => ({
label: x === DocumentType.PlainText ? t(camelCase(x)) : 'DeepDoc',
value: x,
}));
const image2TextList = allOptions[LlmModelType.Image2text].map((x) => {
return {
...x,
options: x.options.map((y) => {
return {
...y,
label: (
<div className="flex justify-between items-center gap-2">
{y.label}
<span className="text-red-500 text-sm">Experimental</span>
</div>
),
};
}),
};
});
return [...list, ...image2TextList];
}, [allOptions, t]);
return (
<Form.Item
name={['parser_config', 'layout_recognize']}
label={t('layoutRecognize')}
initialValue={DocumentType.DeepDOC}
tooltip={t('layoutRecognizeTip')}
>
<Select options={options} popupMatchSelectWidth={false} />
</Form.Item>
);
};
export default LayoutRecognize;

View File

@@ -0,0 +1,51 @@
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { CategoricalChartProps } from 'recharts/types/chart/generateCategoricalChart';
interface IProps extends CategoricalChartProps {
data?: Array<{ xAxis: string; yAxis: number }>;
showLegend?: boolean;
}
const RagLineChart = ({ data, showLegend = false }: IProps) => {
return (
<ResponsiveContainer width="100%" height="100%">
<LineChart
// width={500}
// height={300}
data={data}
margin={
{
// top: 5,
// right: 30,
// left: 20,
// bottom: 10,
}
}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="xAxis" />
<YAxis />
<Tooltip />
{showLegend && <Legend />}
<Line
type="monotone"
dataKey="yAxis"
stroke="#8884d8"
activeDot={{ r: 8 }}
/>
{/* <Line type="monotone" dataKey="uv" stroke="#82ca9d" /> */}
</LineChart>
</ResponsiveContainer>
);
};
export default RagLineChart;

View File

@@ -0,0 +1,177 @@
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { zodResolver } from '@hookform/resolvers/zod';
import { PropsWithChildren, useCallback, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { ZodArray, ZodString, z } from 'zod';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { t } from 'i18next';
import { FilterChange, FilterCollection, FilterValue } from './interface';
export type CheckboxFormMultipleProps = {
filters?: FilterCollection[];
value?: FilterValue;
onChange?: FilterChange;
onOpenChange?: (open: boolean) => void;
setOpen(open: boolean): void;
};
function CheckboxFormMultiple({
filters = [],
value,
onChange,
setOpen,
}: CheckboxFormMultipleProps) {
const fieldsDict = filters?.reduce<Record<string, Array<any>>>((pre, cur) => {
pre[cur.field] = [];
return pre;
}, {});
const FormSchema = z.object(
filters.reduce<Record<string, ZodArray<ZodString, 'many'>>>((pre, cur) => {
pre[cur.field] = z.array(z.string());
// .refine((value) => value.some((item) => item), {
// message: 'You have to select at least one item.',
// });
return pre;
}, {}),
);
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: fieldsDict,
});
function onSubmit(data: z.infer<typeof FormSchema>) {
onChange?.(data);
setOpen(false);
}
const onReset = useCallback(() => {
onChange?.(fieldsDict);
setOpen(false);
}, [fieldsDict, onChange, setOpen]);
useEffect(() => {
form.reset(value);
}, [form, value]);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 px-5 py-2.5"
onReset={() => form.reset()}
>
{filters.map((x) => (
<FormField
key={x.field}
control={form.control}
name={x.field}
render={() => (
<FormItem className="space-y-4">
<div>
<FormLabel className="text-base text-text-sub-title-invert">
{x.label}
</FormLabel>
</div>
{x.list.map((item) => (
<FormField
key={item.id}
control={form.control}
name={x.field}
render={({ field }) => {
return (
<div className="flex items-center justify-between text-text-primary text-xs">
<FormItem
key={item.id}
className="flex flex-row space-x-3 space-y-0 items-center "
>
<FormControl>
<Checkbox
checked={field.value?.includes(item.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, item.id])
: field.onChange(
field.value?.filter(
(value) => value !== item.id,
),
);
}}
/>
</FormControl>
<FormLabel>{item.label}</FormLabel>
</FormItem>
<span className=" text-sm">{item.count}</span>
</div>
);
}}
/>
))}
<FormMessage />
</FormItem>
)}
/>
))}
<div className="flex justify-end gap-5">
<Button
type="button"
variant={'outline'}
size={'sm'}
onClick={onReset}
>
{t('common.clear')}
</Button>
<Button type="submit" size={'sm'}>
{t('common.submit')}
</Button>
</div>
</form>
</Form>
);
}
export function FilterPopover({
children,
value,
onChange,
onOpenChange,
filters,
}: PropsWithChildren & Omit<CheckboxFormMultipleProps, 'setOpen'>) {
const [open, setOpen] = useState(false);
const onOpenChangeFun = useCallback(
(e: boolean) => {
onOpenChange?.(e);
setOpen(e);
},
[onOpenChange],
);
return (
<Popover open={open} onOpenChange={onOpenChangeFun}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent className="p-0">
<CheckboxFormMultiple
onChange={onChange}
value={value}
filters={filters}
setOpen={setOpen}
></CheckboxFormMultiple>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,102 @@
import { cn } from '@/lib/utils';
import { Funnel } from 'lucide-react';
import React, {
ChangeEventHandler,
PropsWithChildren,
ReactNode,
useMemo,
} from 'react';
import { IconFont } from '../icon-font';
import { Button, ButtonProps } from '../ui/button';
import { SearchInput } from '../ui/input';
import { CheckboxFormMultipleProps, FilterPopover } from './filter-popover';
interface IProps {
title?: ReactNode;
searchString?: string;
onSearchChange?: ChangeEventHandler<HTMLInputElement>;
showFilter?: boolean;
leftPanel?: ReactNode;
}
export const FilterButton = React.forwardRef<
HTMLButtonElement,
ButtonProps & { count?: number }
>(({ count = 0, ...props }, ref) => {
return (
<Button variant="secondary" {...props} ref={ref}>
{/* <span
className={cn({
'text-text-primary': count > 0,
'text-text-sub-title-invert': count === 0,
})}
>
Filter
</span> */}
{count > 0 && (
<span className="rounded-full bg-text-badge px-1 text-xs ">
{count}
</span>
)}
<Funnel />
</Button>
);
});
export default function ListFilterBar({
title,
children,
searchString,
onSearchChange,
showFilter = true,
leftPanel,
value,
onChange,
onOpenChange,
filters,
className,
icon,
}: PropsWithChildren<IProps & Omit<CheckboxFormMultipleProps, 'setOpen'>> & {
className?: string;
icon?: ReactNode;
}) {
const filterCount = useMemo(() => {
return typeof value === 'object' && value !== null
? Object.values(value).reduce((pre, cur) => {
return pre + cur.length;
}, 0)
: 0;
}, [value]);
return (
<div className={cn('flex justify-between mb-5 items-center', className)}>
<div className="text-2xl font-semibold flex items-center gap-2.5">
{typeof icon === 'string' ? (
<IconFont name={icon} className="size-6"></IconFont>
) : (
icon
)}
{leftPanel || title}
</div>
<div className="flex gap-5 items-center">
{showFilter && (
<FilterPopover
value={value}
onChange={onChange}
filters={filters}
onOpenChange={onOpenChange}
>
<FilterButton count={filterCount}></FilterButton>
</FilterPopover>
)}
<SearchInput
value={searchString}
onChange={onSearchChange}
className="w-32"
></SearchInput>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
export type FilterType = {
id: string;
label: string | JSX.Element;
count?: number;
};
export type FilterCollection = {
field: string;
label: string;
list: FilterType[];
};
export type FilterValue = Record<string, Array<string>>;
export type FilterChange = (value: FilterValue) => void;

View File

@@ -0,0 +1,17 @@
import { useGetPaginationWithRouter } from '@/hooks/logic-hooks';
import { useCallback, useState } from 'react';
import { FilterChange, FilterValue } from './interface';
export function useHandleFilterSubmit() {
const [filterValue, setFilterValue] = useState<FilterValue>({});
const { setPagination } = useGetPaginationWithRouter();
const handleFilterSubmit: FilterChange = useCallback(
(value) => {
setFilterValue(value);
setPagination({ page: 1 });
},
[setPagination],
);
return { filterValue, setFilterValue, handleFilterSubmit };
}

View File

@@ -0,0 +1,3 @@
.llmLabel {
font-size: 14px;
}

View File

@@ -0,0 +1,67 @@
import { LlmModelType } from '@/constants/knowledge';
import { useComposeLlmOptionsByModelTypes } from '@/hooks/llm-hooks';
import { Popover as AntPopover, Select as AntSelect } from 'antd';
import LlmSettingItems from '../llm-setting-items';
interface IProps {
id?: string;
value?: string;
onInitialValue?: (value: string, option: any) => void;
onChange?: (value: string, option: any) => void;
disabled?: boolean;
}
const LLMSelect = ({
id,
value,
onInitialValue,
onChange,
disabled,
}: IProps) => {
const modelOptions = useComposeLlmOptionsByModelTypes([
LlmModelType.Chat,
LlmModelType.Image2text,
]);
if (onInitialValue && value) {
for (const modelOption of modelOptions) {
for (const option of modelOption.options) {
if (option.value === value) {
onInitialValue(value, option);
break;
}
}
}
}
const content = (
<div style={{ width: 400 }}>
<LlmSettingItems
onChange={onChange}
formItemLayout={{ labelCol: { span: 10 }, wrapperCol: { span: 14 } }}
></LlmSettingItems>
</div>
);
return (
<AntPopover
content={content}
trigger="click"
placement="left"
arrow={false}
destroyTooltipOnHide
>
<AntSelect
options={modelOptions}
style={{ width: '100%' }}
dropdownStyle={{ display: 'none' }}
id={id}
value={value}
onChange={onChange}
disabled={disabled}
/>
</AntPopover>
);
};
export default LLMSelect;

View File

@@ -0,0 +1,28 @@
import { getLLMIconName, getLlmNameAndFIdByLlmId } from '@/utils/llm-util';
import { memo } from 'react';
import { LlmIcon } from '../svg-icon';
interface IProps {
id?: string;
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
}
const LLMLabel = ({ value }: IProps) => {
const { llmName, fId } = getLlmNameAndFIdByLlmId(value);
return (
<div className="flex items-center gap-1">
<LlmIcon
name={getLLMIconName(fId, llmName)}
width={20}
height={20}
size={'small'}
/>
<span className="flex-1 truncate"> {llmName}</span>
</div>
);
};
export default memo(LLMLabel);

View File

@@ -0,0 +1,73 @@
import { LlmModelType } from '@/constants/knowledge';
import { useComposeLlmOptionsByModelTypes } from '@/hooks/llm-hooks';
import * as SelectPrimitive from '@radix-ui/react-select';
import { forwardRef, memo, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LlmSettingFieldItems } from '../llm-setting-items/next';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { Select, SelectTrigger, SelectValue } from '../ui/select';
export interface NextInnerLLMSelectProps {
id?: string;
value?: string;
onInitialValue?: (value: string, option: any) => void;
onChange?: (value: string) => void;
disabled?: boolean;
filter?: string;
showSpeech2TextModel?: boolean;
}
const NextInnerLLMSelect = forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
NextInnerLLMSelectProps
>(({ value, disabled, filter, showSpeech2TextModel = false }, ref) => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const ttsModel = useMemo(() => {
return showSpeech2TextModel ? [LlmModelType.Speech2text] : [];
}, [showSpeech2TextModel]);
const modelTypes = useMemo(() => {
if (filter === LlmModelType.Chat) {
return [LlmModelType.Chat];
} else if (filter === LlmModelType.Image2text) {
return [LlmModelType.Image2text, ...ttsModel];
} else {
return [LlmModelType.Chat, LlmModelType.Image2text, ...ttsModel];
}
}, [filter, ttsModel]);
const modelOptions = useComposeLlmOptionsByModelTypes(modelTypes);
return (
<Select disabled={disabled} value={value}>
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<SelectTrigger
onClick={(e) => {
e.preventDefault();
setIsPopoverOpen(true);
}}
ref={ref}
>
<SelectValue placeholder={t('common.pleaseSelect')}>
{
modelOptions
.flatMap((x) => x.options)
.find((x) => x.value === value)?.label
}
</SelectValue>
</SelectTrigger>
</PopoverTrigger>
<PopoverContent side={'left'}>
<LlmSettingFieldItems options={modelOptions}></LlmSettingFieldItems>
</PopoverContent>
</Popover>
</Select>
);
});
NextInnerLLMSelect.displayName = 'LLMSelect';
export const NextLLMSelect = memo(NextInnerLLMSelect);

View File

@@ -0,0 +1,6 @@
.sliderInputNumber {
width: 80px;
}
.variableSlider {
width: 100%;
}

View File

@@ -0,0 +1,350 @@
import {
LlmModelType,
ModelVariableType,
settledModelVariableMap,
} from '@/constants/knowledge';
import { Flex, Form, InputNumber, Select, Slider, Switch, Tooltip } from 'antd';
import camelCase from 'lodash/camelCase';
import { useTranslate } from '@/hooks/common-hooks';
import { useComposeLlmOptionsByModelTypes } from '@/hooks/llm-hooks';
import { setChatVariableEnabledFieldValuePage } from '@/utils/chat';
import { QuestionCircleOutlined } from '@ant-design/icons';
import { useCallback, useMemo } from 'react';
import styles from './index.less';
interface IProps {
prefix?: string;
formItemLayout?: any;
handleParametersChange?(value: ModelVariableType): void;
onChange?(value: string, option: any): void;
}
const LlmSettingItems = ({ prefix, formItemLayout = {}, onChange }: IProps) => {
const form = Form.useFormInstance();
const { t } = useTranslate('chat');
const parameterOptions = Object.values(ModelVariableType).map((x) => ({
label: t(camelCase(x)),
value: x,
}));
const handleParametersChange = useCallback(
(value: ModelVariableType) => {
const variable = settledModelVariableMap[value];
let nextVariable: Record<string, any> = variable;
if (prefix) {
nextVariable = { [prefix]: variable };
}
const variableCheckBoxFieldMap = setChatVariableEnabledFieldValuePage();
form.setFieldsValue({ ...nextVariable, ...variableCheckBoxFieldMap });
},
[form, prefix],
);
const memorizedPrefix = useMemo(() => (prefix ? [prefix] : []), [prefix]);
const modelOptions = useComposeLlmOptionsByModelTypes([
LlmModelType.Chat,
LlmModelType.Image2text,
]);
return (
<>
<Form.Item
label={t('model')}
name="llm_id"
tooltip={t('modelTip')}
{...formItemLayout}
rules={[{ required: true, message: t('modelMessage') }]}
>
<Select
options={modelOptions}
showSearch
popupMatchSelectWidth={false}
onChange={onChange}
/>
</Form.Item>
<div className="border rounded-md">
<div className="flex justify-between bg-slate-100 p-2 mb-2">
<div className="space-x-1 items-center">
<span className="text-lg font-semibold">{t('freedom')}</span>
<Tooltip title={t('freedomTip')}>
<QuestionCircleOutlined></QuestionCircleOutlined>
</Tooltip>
</div>
<div className="w-1/4 min-w-32">
<Form.Item
label={t('freedom')}
name="parameter"
tooltip={t('freedomTip')}
initialValue={ModelVariableType.Precise}
labelCol={{ span: 0 }}
wrapperCol={{ span: 24 }}
className="m-0"
>
<Select<ModelVariableType>
options={parameterOptions}
onChange={handleParametersChange}
/>
</Form.Item>
</div>
</div>
<div className="pr-2">
<Form.Item
label={t('temperature')}
tooltip={t('temperatureTip')}
{...formItemLayout}
>
<Flex gap={20} align="center">
<Form.Item
name={'temperatureEnabled'}
valuePropName="checked"
noStyle
>
<Switch size="small" />
</Form.Item>
<Form.Item
noStyle
dependencies={['temperatureEnabled']}
shouldUpdate
>
{({ getFieldValue }) => {
const disabled = !getFieldValue('temperatureEnabled');
return (
<>
<Flex flex={1}>
<Form.Item
name={[...memorizedPrefix, 'temperature']}
noStyle
>
<Slider
className={styles.variableSlider}
max={1}
step={0.01}
disabled={disabled}
/>
</Form.Item>
</Flex>
<Form.Item
name={[...memorizedPrefix, 'temperature']}
noStyle
>
<InputNumber
className={styles.sliderInputNumber}
max={1}
min={0}
step={0.01}
disabled={disabled}
/>
</Form.Item>
</>
);
}}
</Form.Item>
</Flex>
</Form.Item>
<Form.Item
label={t('topP')}
tooltip={t('topPTip')}
{...formItemLayout}
>
<Flex gap={20} align="center">
<Form.Item name={'topPEnabled'} valuePropName="checked" noStyle>
<Switch size="small" />
</Form.Item>
<Form.Item noStyle dependencies={['topPEnabled']} shouldUpdate>
{({ getFieldValue }) => {
const disabled = !getFieldValue('topPEnabled');
return (
<>
<Flex flex={1}>
<Form.Item name={[...memorizedPrefix, 'top_p']} noStyle>
<Slider
className={styles.variableSlider}
max={1}
step={0.01}
disabled={disabled}
/>
</Form.Item>
</Flex>
<Form.Item name={[...memorizedPrefix, 'top_p']} noStyle>
<InputNumber
className={styles.sliderInputNumber}
max={1}
min={0}
step={0.01}
disabled={disabled}
/>
</Form.Item>
</>
);
}}
</Form.Item>
</Flex>
</Form.Item>
<Form.Item
label={t('presencePenalty')}
tooltip={t('presencePenaltyTip')}
{...formItemLayout}
>
<Flex gap={20} align="center">
<Form.Item
name={'presencePenaltyEnabled'}
valuePropName="checked"
noStyle
>
<Switch size="small" />
</Form.Item>
<Form.Item
noStyle
dependencies={['presencePenaltyEnabled']}
shouldUpdate
>
{({ getFieldValue }) => {
const disabled = !getFieldValue('presencePenaltyEnabled');
return (
<>
<Flex flex={1}>
<Form.Item
name={[...memorizedPrefix, 'presence_penalty']}
noStyle
>
<Slider
className={styles.variableSlider}
max={1}
step={0.01}
disabled={disabled}
/>
</Form.Item>
</Flex>
<Form.Item
name={[...memorizedPrefix, 'presence_penalty']}
noStyle
>
<InputNumber
className={styles.sliderInputNumber}
max={1}
min={0}
step={0.01}
disabled={disabled}
/>
</Form.Item>
</>
);
}}
</Form.Item>
</Flex>
</Form.Item>
<Form.Item
label={t('frequencyPenalty')}
tooltip={t('frequencyPenaltyTip')}
{...formItemLayout}
>
<Flex gap={20} align="center">
<Form.Item
name={'frequencyPenaltyEnabled'}
valuePropName="checked"
noStyle
>
<Switch size="small" />
</Form.Item>
<Form.Item
noStyle
dependencies={['frequencyPenaltyEnabled']}
shouldUpdate
>
{({ getFieldValue }) => {
const disabled = !getFieldValue('frequencyPenaltyEnabled');
return (
<>
<Flex flex={1}>
<Form.Item
name={[...memorizedPrefix, 'frequency_penalty']}
noStyle
>
<Slider
className={styles.variableSlider}
max={1}
step={0.01}
disabled={disabled}
/>
</Form.Item>
</Flex>
<Form.Item
name={[...memorizedPrefix, 'frequency_penalty']}
noStyle
>
<InputNumber
className={styles.sliderInputNumber}
max={1}
min={0}
step={0.01}
disabled={disabled}
/>
</Form.Item>
</>
);
}}
</Form.Item>
</Flex>
</Form.Item>
<Form.Item
label={t('maxTokens')}
tooltip={t('maxTokensTip')}
{...formItemLayout}
>
<Flex gap={20} align="center">
<Form.Item
name={'maxTokensEnabled'}
valuePropName="checked"
noStyle
>
<Switch size="small" />
</Form.Item>
<Form.Item
noStyle
dependencies={['maxTokensEnabled']}
shouldUpdate
>
{({ getFieldValue }) => {
const disabled = !getFieldValue('maxTokensEnabled');
return (
<>
<Flex flex={1}>
<Form.Item
name={[...memorizedPrefix, 'max_tokens']}
noStyle
>
<Slider
className={styles.variableSlider}
max={128000}
disabled={disabled}
/>
</Form.Item>
</Flex>
<Form.Item
name={[...memorizedPrefix, 'max_tokens']}
noStyle
>
<InputNumber
disabled={disabled}
className={styles.sliderInputNumber}
max={128000}
min={0}
/>
</Form.Item>
</>
);
}}
</Form.Item>
</Flex>
</Form.Item>
</div>
</div>
</>
);
};
export default LlmSettingItems;

View File

@@ -0,0 +1,25 @@
import { LlmModelType } from '@/constants/knowledge';
import { useComposeLlmOptionsByModelTypes } from '@/hooks/llm-hooks';
import { useTranslation } from 'react-i18next';
import { SelectWithSearch } from '../originui/select-with-search';
import { RAGFlowFormItem } from '../ragflow-form';
export type LLMFormFieldProps = {
options?: any[];
name?: string;
};
export function LLMFormField({ options, name }: LLMFormFieldProps) {
const { t } = useTranslation();
const modelOptions = useComposeLlmOptionsByModelTypes([
LlmModelType.Chat,
LlmModelType.Image2text,
]);
return (
<RAGFlowFormItem name={name || 'llm_id'} label={t('chat.model')}>
<SelectWithSearch options={options || modelOptions}></SelectWithSearch>
</RAGFlowFormItem>
);
}

View File

@@ -0,0 +1,151 @@
import { ModelVariableType } from '@/constants/knowledge';
import { useTranslate } from '@/hooks/common-hooks';
import { camelCase } from 'lodash';
import { useCallback } from 'react';
import { useFormContext } from 'react-hook-form';
import { z } from 'zod';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '../ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import { LLMFormField } from './llm-form-field';
import { SliderInputSwitchFormField } from './slider';
import { useHandleFreedomChange } from './use-watch-change';
interface LlmSettingFieldItemsProps {
prefix?: string;
options?: any[];
}
export const LLMIdFormField = {
llm_id: z.string(),
};
export const LlmSettingEnabledSchema = {
temperatureEnabled: z.boolean().optional(),
topPEnabled: z.boolean().optional(),
presencePenaltyEnabled: z.boolean().optional(),
frequencyPenaltyEnabled: z.boolean().optional(),
maxTokensEnabled: z.boolean().optional(),
};
export const LlmSettingFieldSchema = {
temperature: z.coerce.number().optional(),
top_p: z.number().optional(),
presence_penalty: z.coerce.number().optional(),
frequency_penalty: z.coerce.number().optional(),
max_tokens: z.number().optional(),
};
export const LlmSettingSchema = {
...LLMIdFormField,
...LlmSettingFieldSchema,
...LlmSettingEnabledSchema,
};
export function LlmSettingFieldItems({
prefix,
options,
}: LlmSettingFieldItemsProps) {
const form = useFormContext();
const { t } = useTranslate('chat');
const getFieldWithPrefix = useCallback(
(name: string) => {
return prefix ? `${prefix}.${name}` : name;
},
[prefix],
);
const handleChange = useHandleFreedomChange(getFieldWithPrefix);
const parameterOptions = Object.values(ModelVariableType).map((x) => ({
label: t(camelCase(x)),
value: x,
}));
return (
<div className="space-y-5">
<LLMFormField options={options}></LLMFormField>
<FormField
control={form.control}
name={'parameter'}
render={({ field }) => (
<FormItem className="flex justify-between items-center">
<FormLabel className="flex-1">{t('freedom')}</FormLabel>
<FormControl>
<Select
{...field}
onValueChange={(val) => {
handleChange(val);
field.onChange(val);
}}
>
<SelectTrigger className="flex-1 !m-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{parameterOptions.map((x) => (
<SelectItem value={x.value} key={x.value}>
{x.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<SliderInputSwitchFormField
name={getFieldWithPrefix('temperature')}
checkName="temperatureEnabled"
label="temperature"
max={1}
step={0.01}
min={0}
></SliderInputSwitchFormField>
<SliderInputSwitchFormField
name={getFieldWithPrefix('top_p')}
checkName="topPEnabled"
label="topP"
max={1}
step={0.01}
min={0}
></SliderInputSwitchFormField>
<SliderInputSwitchFormField
name={getFieldWithPrefix('presence_penalty')}
checkName="presencePenaltyEnabled"
label="presencePenalty"
max={1}
step={0.01}
min={0}
></SliderInputSwitchFormField>
<SliderInputSwitchFormField
name={getFieldWithPrefix('frequency_penalty')}
checkName="frequencyPenaltyEnabled"
label="frequencyPenalty"
max={1}
step={0.01}
min={0}
></SliderInputSwitchFormField>
<SliderInputSwitchFormField
name={getFieldWithPrefix('max_tokens')}
checkName="maxTokensEnabled"
label="maxTokens"
max={128000}
min={0}
></SliderInputSwitchFormField>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { useTranslate } from '@/hooks/common-hooks';
import { cn } from '@/lib/utils';
import { useFormContext } from 'react-hook-form';
import { SingleFormSlider } from '../ui/dual-range-slider';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '../ui/form';
import { NumberInput } from '../ui/input';
import { Switch } from '../ui/switch';
type SliderInputSwitchFormFieldProps = {
max?: number;
min?: number;
step?: number;
name: string;
label: string;
defaultValue?: number;
onChange?: (value: number) => void;
className?: string;
checkName: string;
};
export function SliderInputSwitchFormField({
max,
min,
step,
label,
name,
defaultValue,
onChange,
className,
checkName,
}: SliderInputSwitchFormFieldProps) {
const form = useFormContext();
const disabled = !form.watch(checkName);
const { t } = useTranslate('chat');
return (
<FormField
control={form.control}
name={name}
defaultValue={defaultValue}
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t(`${label}Tip`)}>{t(label)}</FormLabel>
<div
className={cn('flex items-center gap-4 justify-between', className)}
>
<FormField
control={form.control}
name={checkName}
render={({ field }) => (
<FormItem>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormControl>
<SingleFormSlider
{...field}
onChange={(value: number) => {
onChange?.(value);
field.onChange(value);
}}
max={max}
min={min}
step={step}
disabled={disabled}
></SingleFormSlider>
</FormControl>
<FormControl>
<NumberInput
disabled={disabled}
className="h-7 w-20"
max={max}
min={min}
step={step}
{...field}
onChange={(value: number) => {
onChange?.(value);
field.onChange(value);
}}
></NumberInput>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@@ -0,0 +1,52 @@
import { settledModelVariableMap } from '@/constants/knowledge';
import { AgentFormContext } from '@/pages/agent/context';
import useGraphStore from '@/pages/agent/store';
import { setChatVariableEnabledFieldValuePage } from '@/utils/chat';
import { useCallback, useContext } from 'react';
import { useFormContext } from 'react-hook-form';
export function useHandleFreedomChange(
getFieldWithPrefix: (name: string) => string,
) {
const form = useFormContext();
const node = useContext(AgentFormContext);
const updateNodeForm = useGraphStore((state) => state.updateNodeForm);
const setLLMParameters = useCallback(
(values: Record<string, any>, withPrefix: boolean) => {
for (const key in values) {
if (Object.prototype.hasOwnProperty.call(values, key)) {
const realKey = getFieldWithPrefix(key);
const element = values[key as keyof typeof values];
form.setValue(withPrefix ? realKey : key, element);
}
}
},
[form, getFieldWithPrefix],
);
const handleChange = useCallback(
(parameter: string) => {
const currentValues = { ...form.getValues() };
const values =
settledModelVariableMap[
parameter as keyof typeof settledModelVariableMap
];
const nextValues = { ...currentValues, ...values };
if (node?.id) {
updateNodeForm(node?.id, nextValues);
}
const variableCheckBoxFieldMap = setChatVariableEnabledFieldValuePage();
setLLMParameters(values, true);
setLLMParameters(variableCheckBoxFieldMap, false);
},
[form, node?.id, setLLMParameters, updateNodeForm],
);
return handleChange;
}

View File

@@ -0,0 +1,51 @@
import { useTranslate } from '@/hooks/common-hooks';
import { useLlmToolsList } from '@/hooks/plugin-hooks';
import { Select, Space } from 'antd';
interface IProps {
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
}
const LLMToolsSelect = ({ value, onChange, disabled }: IProps) => {
const { t } = useTranslate("llmTools");
const tools = useLlmToolsList();
function wrapTranslation(text: string): string {
if (!text) {
return text;
}
if (text.startsWith("$t:")) {
return t(text.substring(3));
}
return text;
}
const toolOptions = tools.map(t => ({
label: wrapTranslation(t.displayName),
description: wrapTranslation(t.displayDescription),
value: t.name,
title: wrapTranslation(t.displayDescription),
}));
return (
<Select
mode="multiple"
options={toolOptions}
optionRender={option => (
<Space size="large">
{option.label}
{option.data.description}
</Space>
)}
onChange={onChange}
value={value}
disabled={disabled}
></Select>
);
};
export default LLMToolsSelect;

View File

@@ -0,0 +1,23 @@
import { FormLayout } from '@/constants/form';
import { useTranslate } from '@/hooks/common-hooks';
import { SliderInputFormField } from './slider-input-form-field';
interface IProps {
initialValue?: number;
max?: number;
}
export function MaxTokenNumberFormField({ max = 2048, initialValue }: IProps) {
const { t } = useTranslate('knowledgeConfiguration');
return (
<SliderInputFormField
name={'parser_config.chunk_token_num'}
label={t('chunkTokenNumber')}
tooltip={t('chunkTokenNumberTip')}
max={max}
defaultValue={initialValue ?? 0}
layout={FormLayout.Horizontal}
></SliderInputFormField>
);
}

View File

@@ -0,0 +1,37 @@
import { useTranslate } from '@/hooks/common-hooks';
import { Flex, Form, InputNumber, Slider } from 'antd';
interface IProps {
initialValue?: number;
max?: number;
}
const MaxTokenNumber = ({ initialValue = 512, max = 2048 }: IProps) => {
const { t } = useTranslate('knowledgeConfiguration');
return (
<Form.Item label={t('chunkTokenNumber')} tooltip={t('chunkTokenNumberTip')}>
<Flex gap={20} align="center">
<Flex flex={1}>
<Form.Item
name={['parser_config', 'chunk_token_num']}
noStyle
initialValue={initialValue}
rules={[{ required: true, message: t('chunkTokenNumberMessage') }]}
>
<Slider max={max} style={{ width: '100%' }} />
</Form.Item>
</Flex>
<Form.Item
name={['parser_config', 'chunk_token_num']}
noStyle
rules={[{ required: true, message: t('chunkTokenNumberMessage') }]}
>
<InputNumber max={max} min={0} />
</Form.Item>
</Flex>
</Form.Item>
);
};
export default MaxTokenNumber;

View File

@@ -0,0 +1,55 @@
import { Form, InputNumber } from 'antd';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from './ui/form';
import { NumberInput } from './ui/input';
const MessageHistoryWindowSizeItem = ({
initialValue,
}: {
initialValue: number;
}) => {
const { t } = useTranslation();
return (
<Form.Item
name={'message_history_window_size'}
label={t('flow.messageHistoryWindowSize')}
initialValue={initialValue}
tooltip={t('flow.messageHistoryWindowSizeTip')}
>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
);
};
export default MessageHistoryWindowSizeItem;
export function MessageHistoryWindowSizeFormField() {
const form = useFormContext();
const { t } = useTranslation();
return (
<FormField
control={form.control}
name={'message_history_window_size'}
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t('flow.messageHistoryWindowSizeTip')}>
{t('flow.messageHistoryWindowSize')}
</FormLabel>
<FormControl>
<NumberInput {...field}></NumberInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@@ -0,0 +1,37 @@
.messageInputWrapper {
margin-right: 20px;
padding: '0px 0px 10px 0px';
border: 1px solid #d9d9d9;
&:hover {
border-color: #40a9ff;
box-shadow: #40a9ff;
}
border-radius: 8px;
:global(.ant-input-affix-wrapper) {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
}
.documentCard {
:global(.ant-card-body) {
padding: 10px;
position: relative;
width: 100%;
}
}
.listWrapper {
padding: 0 10px;
overflow: auto;
max-height: 170px;
width: 100%;
}
.inputWrapper {
border-radius: 8px;
}
.deleteIcon {
position: absolute;
right: -4px;
top: -4px;
color: #d92d20;
}

View File

@@ -0,0 +1,372 @@
import { useTranslate } from '@/hooks/common-hooks';
import {
useDeleteDocument,
useFetchDocumentInfosByIds,
useRemoveNextDocument,
useUploadAndParseDocument,
} from '@/hooks/document-hooks';
import { cn } from '@/lib/utils';
import { getExtension } from '@/utils/document-util';
import { formatBytes } from '@/utils/file-util';
import {
CloseCircleOutlined,
InfoCircleOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import type { GetProp, UploadFile } from 'antd';
import {
Button,
Card,
Divider,
Flex,
Input,
List,
Space,
Spin,
Typography,
Upload,
UploadProps,
} from 'antd';
import get from 'lodash/get';
import { CircleStop, Paperclip, SendHorizontal } from 'lucide-react';
import {
ChangeEventHandler,
memo,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import FileIcon from '../file-icon';
import styles from './index.less';
type FileType = Parameters<GetProp<UploadProps, 'beforeUpload'>>[0];
const { Text } = Typography;
const { TextArea } = Input;
const getFileId = (file: UploadFile) => get(file, 'response.data.0');
const getFileIds = (fileList: UploadFile[]) => {
const ids = fileList.reduce((pre, cur) => {
return pre.concat(get(cur, 'response.data', []));
}, []);
return ids;
};
const isUploadSuccess = (file: UploadFile) => {
const code = get(file, 'response.code');
return typeof code === 'number' && code === 0;
};
interface IProps {
disabled: boolean;
value: string;
sendDisabled: boolean;
sendLoading: boolean;
onPressEnter(documentIds: string[]): void;
onInputChange: ChangeEventHandler<HTMLTextAreaElement>;
conversationId: string;
uploadMethod?: string;
isShared?: boolean;
showUploadIcon?: boolean;
createConversationBeforeUploadDocument?(message: string): Promise<any>;
stopOutputMessage?(): void;
}
const getBase64 = (file: FileType): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file as any);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
const MessageInput = ({
isShared = false,
disabled,
value,
onPressEnter,
sendDisabled,
sendLoading,
onInputChange,
conversationId,
showUploadIcon = true,
createConversationBeforeUploadDocument,
uploadMethod = 'upload_and_parse',
stopOutputMessage,
}: IProps) => {
const { t } = useTranslate('chat');
const { removeDocument } = useRemoveNextDocument();
const { deleteDocument } = useDeleteDocument();
const { data: documentInfos, setDocumentIds } = useFetchDocumentInfosByIds();
const { uploadAndParseDocument } = useUploadAndParseDocument(uploadMethod);
const conversationIdRef = useRef(conversationId);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const handlePreview = async (file: UploadFile) => {
if (!file.url && !file.preview) {
file.preview = await getBase64(file.originFileObj as FileType);
}
};
const handleChange: UploadProps['onChange'] = async ({
// fileList: newFileList,
file,
}) => {
let nextConversationId: string = conversationId;
if (createConversationBeforeUploadDocument) {
const creatingRet = await createConversationBeforeUploadDocument(
file.name,
);
if (creatingRet?.code === 0) {
nextConversationId = creatingRet.data.id;
}
}
setFileList((list) => {
list.push({
...file,
status: 'uploading',
originFileObj: file as any,
});
return [...list];
});
const ret = await uploadAndParseDocument({
conversationId: nextConversationId,
fileList: [file],
});
setFileList((list) => {
const nextList = list.filter((x) => x.uid !== file.uid);
nextList.push({
...file,
originFileObj: file as any,
response: ret,
percent: 100,
status: ret?.code === 0 ? 'done' : 'error',
});
return nextList;
});
};
const isUploadingFile = fileList.some((x) => x.status === 'uploading');
const handlePressEnter = useCallback(async () => {
if (isUploadingFile) return;
const ids = getFileIds(fileList.filter((x) => isUploadSuccess(x)));
onPressEnter(ids);
setFileList([]);
}, [fileList, onPressEnter, isUploadingFile]);
const handleKeyDown = useCallback(
async (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
// check if it was shift + enter
if (event.key === 'Enter' && event.shiftKey) return;
if (event.key !== 'Enter') return;
if (sendDisabled || isUploadingFile || sendLoading) return;
event.preventDefault();
handlePressEnter();
},
[sendDisabled, isUploadingFile, sendLoading, handlePressEnter],
);
const handleRemove = useCallback(
async (file: UploadFile) => {
const ids = get(file, 'response.data', []);
// Upload Successfully
if (Array.isArray(ids) && ids.length) {
if (isShared) {
await deleteDocument(ids);
} else {
await removeDocument(ids[0]);
}
setFileList((preList) => {
return preList.filter((x) => getFileId(x) !== ids[0]);
});
} else {
// Upload failed
setFileList((preList) => {
return preList.filter((x) => x.uid !== file.uid);
});
}
},
[removeDocument, deleteDocument, isShared],
);
const handleStopOutputMessage = useCallback(() => {
stopOutputMessage?.();
}, [stopOutputMessage]);
const getDocumentInfoById = useCallback(
(id: string) => {
return documentInfos.find((x) => x.id === id);
},
[documentInfos],
);
useEffect(() => {
const ids = getFileIds(fileList);
setDocumentIds(ids);
}, [fileList, setDocumentIds]);
useEffect(() => {
if (
conversationIdRef.current &&
conversationId !== conversationIdRef.current
) {
setFileList([]);
}
conversationIdRef.current = conversationId;
}, [conversationId, setFileList]);
return (
<Flex
gap={1}
vertical
className={cn(styles.messageInputWrapper, 'dark:bg-black')}
>
<TextArea
size="large"
placeholder={t('sendPlaceholder')}
value={value}
allowClear
disabled={disabled}
style={{
border: 'none',
boxShadow: 'none',
padding: '0px 10px',
marginTop: 10,
}}
autoSize={{ minRows: 2, maxRows: 10 }}
onKeyDown={handleKeyDown}
onChange={onInputChange}
/>
<Divider style={{ margin: '5px 30px 10px 0px' }} />
<Flex justify="space-between" align="center">
{fileList.length > 0 && (
<List
grid={{
gutter: 16,
xs: 1,
sm: 1,
md: 1,
lg: 1,
xl: 2,
xxl: 4,
}}
dataSource={fileList}
className={styles.listWrapper}
renderItem={(item) => {
const id = getFileId(item);
const documentInfo = getDocumentInfoById(id);
const fileExtension = getExtension(documentInfo?.name ?? '');
const fileName = item.originFileObj?.name ?? '';
return (
<List.Item>
<Card className={styles.documentCard}>
<Flex gap={10} align="center">
{item.status === 'uploading' ? (
<Spin
indicator={
<LoadingOutlined style={{ fontSize: 24 }} spin />
}
/>
) : item.status === 'error' ? (
<InfoCircleOutlined size={30}></InfoCircleOutlined>
) : (
<FileIcon id={id} name={fileName}></FileIcon>
)}
<Flex vertical style={{ width: '90%' }}>
<Text
ellipsis={{ tooltip: fileName }}
className={styles.nameText}
>
<b> {fileName}</b>
</Text>
{item.status === 'error' ? (
t('uploadFailed')
) : (
<>
{item.percent !== 100 ? (
t('uploading')
) : !item.response ? (
t('parsing')
) : (
<Space>
<span>{fileExtension?.toUpperCase()},</span>
<span>
{formatBytes(
getDocumentInfoById(id)?.size ?? 0,
)}
</span>
</Space>
)}
</>
)}
</Flex>
</Flex>
{item.status !== 'uploading' && (
<span className={styles.deleteIcon}>
<CloseCircleOutlined
onClick={() => handleRemove(item)}
/>
</span>
)}
</Card>
</List.Item>
);
}}
/>
)}
<Flex
gap={5}
align="center"
justify="flex-end"
style={{
paddingRight: 10,
paddingBottom: 10,
width: fileList.length > 0 ? '50%' : '100%',
}}
>
{showUploadIcon && (
<Upload
onPreview={handlePreview}
onChange={handleChange}
multiple={false}
onRemove={handleRemove}
showUploadList={false}
beforeUpload={() => {
return false;
}}
>
<Button type={'primary'} disabled={disabled}>
<Paperclip className="size-4" />
</Button>
</Upload>
)}
{sendLoading ? (
<Button onClick={handleStopOutputMessage}>
<CircleStop className="size-5" />
</Button>
) : (
<Button
type="primary"
onClick={handlePressEnter}
loading={sendLoading}
disabled={sendDisabled || isUploadingFile || sendLoading}
>
<SendHorizontal className="size-5" />
</Button>
)}
</Flex>
</Flex>
</Flex>
);
};
export default memo(MessageInput);

View File

@@ -0,0 +1,188 @@
'use client';
import {
FileUpload,
FileUploadDropzone,
FileUploadItem,
FileUploadItemDelete,
FileUploadItemMetadata,
FileUploadItemPreview,
FileUploadItemProgress,
FileUploadList,
FileUploadTrigger,
type FileUploadProps,
} from '@/components/file-upload';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { t } from 'i18next';
import { CircleStop, Paperclip, Send, Upload, X } from 'lucide-react';
import * as React from 'react';
import { toast } from 'sonner';
interface IProps {
disabled: boolean;
value: string;
sendDisabled: boolean;
sendLoading: boolean;
conversationId: string;
uploadMethod?: string;
isShared?: boolean;
showUploadIcon?: boolean;
isUploading?: boolean;
onPressEnter(...prams: any[]): void;
onInputChange: React.ChangeEventHandler<HTMLTextAreaElement>;
createConversationBeforeUploadDocument?(message: string): Promise<any>;
stopOutputMessage?(): void;
onUpload?: NonNullable<FileUploadProps['onUpload']>;
removeFile?(file: File): void;
}
export function NextMessageInput({
isUploading = false,
value,
sendDisabled,
sendLoading,
disabled,
showUploadIcon = true,
onUpload,
onInputChange,
stopOutputMessage,
onPressEnter,
removeFile,
}: IProps) {
const [files, setFiles] = React.useState<File[]>([]);
const onFileReject = React.useCallback((file: File, message: string) => {
toast(message, {
description: `"${file.name.length > 20 ? `${file.name.slice(0, 20)}...` : file.name}" has been rejected`,
});
}, []);
const submit = React.useCallback(() => {
if (isUploading) return;
onPressEnter();
setFiles([]);
}, [isUploading, onPressEnter]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const onSubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
submit();
},
[submit],
);
const handleRemoveFile = React.useCallback(
(file: File) => () => {
removeFile?.(file);
},
[removeFile],
);
return (
<FileUpload
value={files}
onValueChange={setFiles}
onUpload={onUpload}
onFileReject={onFileReject}
className="relative w-full items-center "
disabled={isUploading || disabled}
>
<FileUploadDropzone
tabIndex={-1}
// Prevents the dropzone from triggering on click
onClick={(event) => event.preventDefault()}
className="absolute top-0 left-0 z-0 flex size-full items-center justify-center rounded-none border-none bg-background/50 p-0 opacity-0 backdrop-blur transition-opacity duration-200 ease-out data-[dragging]:z-10 data-[dragging]:opacity-100"
>
<div className="flex flex-col items-center gap-1 text-center">
<div className="flex items-center justify-center rounded-full border p-2.5">
<Upload className="size-6 text-muted-foreground" />
</div>
<p className="font-medium text-sm">Drag & drop files here</p>
<p className="text-muted-foreground text-xs">
Upload max 5 files each up to 5MB
</p>
</div>
</FileUploadDropzone>
<form
onSubmit={onSubmit}
className="relative flex w-full flex-col gap-2.5 rounded-md border border-input px-3 py-2 outline-none focus-within:ring-1 focus-within:ring-ring/50"
>
<FileUploadList
orientation="horizontal"
className="overflow-x-auto px-0 py-1"
>
{files.map((file, index) => (
<FileUploadItem key={index} value={file} className="max-w-52 p-1.5">
<FileUploadItemPreview className="size-8 [&>svg]:size-5">
<FileUploadItemProgress variant="fill" />
</FileUploadItemPreview>
<FileUploadItemMetadata size="sm" />
<FileUploadItemDelete asChild>
<Button
variant="secondary"
size="icon"
className="-top-1 -right-1 absolute size-4 shrink-0 cursor-pointer rounded-full"
onClick={handleRemoveFile(file)}
>
<X className="size-2.5" />
</Button>
</FileUploadItemDelete>
</FileUploadItem>
))}
</FileUploadList>
<Textarea
value={value}
onChange={onInputChange}
placeholder={t('chat.messagePlaceholder')}
className="field-sizing-content min-h-10 w-full resize-none border-0 bg-transparent p-0 shadow-none focus-visible:ring-0 dark:bg-transparent"
disabled={isUploading || disabled || sendLoading}
onKeyDown={handleKeyDown}
/>
<div
className={cn('flex items-center justify-between gap-1.5', {
'justify-end': !showUploadIcon,
})}
>
{showUploadIcon && (
<FileUploadTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
className="size-7 rounded-sm"
disabled={isUploading || sendLoading}
>
<Paperclip className="size-3.5" />
<span className="sr-only">Attach file</span>
</Button>
</FileUploadTrigger>
)}
{sendLoading ? (
<Button onClick={stopOutputMessage} className="size-5 rounded-sm">
<CircleStop />
</Button>
) : (
<Button
className="size-5 rounded-sm"
disabled={
sendDisabled || isUploading || sendLoading || !value.trim()
}
>
<Send />
<span className="sr-only">Send message</span>
</Button>
)}
</div>
</form>
</FileUpload>
);
}

View File

@@ -0,0 +1,51 @@
import { Form, Input, Modal } from 'antd';
import { IModalProps } from '@/interfaces/common';
import { IFeedbackRequestBody } from '@/interfaces/request/chat';
import { useCallback } from 'react';
type FieldType = {
feedback?: string;
};
const FeedbackModal = ({
visible,
hideModal,
onOk,
loading,
}: IModalProps<IFeedbackRequestBody>) => {
const [form] = Form.useForm();
const handleOk = useCallback(async () => {
const ret = await form.validateFields();
return onOk?.({ thumbup: false, feedback: ret.feedback });
}, [onOk, form]);
return (
<Modal
title="Feedback"
open={visible}
onOk={handleOk}
onCancel={hideModal}
confirmLoading={loading}
>
<Form
name="basic"
labelCol={{ span: 0 }}
wrapperCol={{ span: 24 }}
style={{ maxWidth: 600 }}
autoComplete="off"
form={form}
>
<Form.Item<FieldType>
name="feedback"
rules={[{ required: true, message: 'Please input your feedback!' }]}
>
<Input.TextArea rows={8} placeholder="Please input your feedback!" />
</Form.Item>
</Form>
</Modal>
);
};
export default FeedbackModal;

View File

@@ -0,0 +1,145 @@
import { PromptIcon } from '@/assets/icon/next-icon';
import CopyToClipboard from '@/components/copy-to-clipboard';
import { useSetModalState } from '@/hooks/common-hooks';
import { IRemoveMessageById } from '@/hooks/logic-hooks';
import {
DeleteOutlined,
DislikeOutlined,
LikeOutlined,
PauseCircleOutlined,
SoundOutlined,
SyncOutlined,
} from '@ant-design/icons';
import { Radio, Tooltip } from 'antd';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import FeedbackModal from './feedback-modal';
import { useRemoveMessage, useSendFeedback, useSpeech } from './hooks';
import PromptModal from './prompt-modal';
interface IProps {
messageId: string;
content: string;
prompt?: string;
showLikeButton: boolean;
audioBinary?: string;
showLoudspeaker?: boolean;
}
export const AssistantGroupButton = ({
messageId,
content,
prompt,
audioBinary,
showLikeButton,
showLoudspeaker = true,
}: IProps) => {
const { visible, hideModal, showModal, onFeedbackOk, loading } =
useSendFeedback(messageId);
const {
visible: promptVisible,
hideModal: hidePromptModal,
showModal: showPromptModal,
} = useSetModalState();
const { t } = useTranslation();
const { handleRead, ref, isPlaying } = useSpeech(content, audioBinary);
const handleLike = useCallback(() => {
onFeedbackOk({ thumbup: true });
}, [onFeedbackOk]);
return (
<>
<Radio.Group size="small">
<Radio.Button value="a">
<CopyToClipboard text={content}></CopyToClipboard>
</Radio.Button>
{showLoudspeaker && (
<Radio.Button value="b" onClick={handleRead}>
<Tooltip title={t('chat.read')}>
{isPlaying ? <PauseCircleOutlined /> : <SoundOutlined />}
</Tooltip>
<audio src="" ref={ref}></audio>
</Radio.Button>
)}
{showLikeButton && (
<>
<Radio.Button value="c" onClick={handleLike}>
<LikeOutlined />
</Radio.Button>
<Radio.Button value="d" onClick={showModal}>
<DislikeOutlined />
</Radio.Button>
</>
)}
{prompt && (
<Radio.Button value="e" onClick={showPromptModal}>
<PromptIcon style={{ fontSize: '16px' }} />
</Radio.Button>
)}
</Radio.Group>
{visible && (
<FeedbackModal
visible={visible}
hideModal={hideModal}
onOk={onFeedbackOk}
loading={loading}
></FeedbackModal>
)}
{promptVisible && (
<PromptModal
visible={promptVisible}
hideModal={hidePromptModal}
prompt={prompt}
></PromptModal>
)}
</>
);
};
interface UserGroupButtonProps extends Partial<IRemoveMessageById> {
messageId: string;
content: string;
regenerateMessage?: () => void;
sendLoading: boolean;
}
export const UserGroupButton = ({
content,
messageId,
sendLoading,
removeMessageById,
regenerateMessage,
}: UserGroupButtonProps) => {
const { onRemoveMessage, loading } = useRemoveMessage(
messageId,
removeMessageById,
);
const { t } = useTranslation();
return (
<Radio.Group size="small">
<Radio.Button value="a">
<CopyToClipboard text={content}></CopyToClipboard>
</Radio.Button>
{regenerateMessage && (
<Radio.Button
value="b"
onClick={regenerateMessage}
disabled={sendLoading}
>
<Tooltip title={t('chat.regenerate')}>
<SyncOutlined spin={sendLoading} />
</Tooltip>
</Radio.Button>
)}
{removeMessageById && (
<Radio.Button value="c" onClick={onRemoveMessage} disabled={loading}>
<Tooltip title={t('common.delete')}>
<DeleteOutlined spin={loading} />
</Tooltip>
</Radio.Button>
)}
</Radio.Group>
);
};

View File

@@ -0,0 +1,116 @@
import { useDeleteMessage, useFeedback } from '@/hooks/chat-hooks';
import { useSetModalState } from '@/hooks/common-hooks';
import { IRemoveMessageById, useSpeechWithSse } from '@/hooks/logic-hooks';
import { IFeedbackRequestBody } from '@/interfaces/request/chat';
import { hexStringToUint8Array } from '@/utils/common-util';
import { SpeechPlayer } from 'openai-speech-stream-player';
import { useCallback, useEffect, useRef, useState } from 'react';
export const useSendFeedback = (messageId: string) => {
const { visible, hideModal, showModal } = useSetModalState();
const { feedback, loading } = useFeedback();
const onFeedbackOk = useCallback(
async (params: IFeedbackRequestBody) => {
const ret = await feedback({
...params,
messageId: messageId,
});
if (ret === 0) {
hideModal();
}
},
[feedback, hideModal, messageId],
);
return {
loading,
onFeedbackOk,
visible,
hideModal,
showModal,
};
};
export const useRemoveMessage = (
messageId: string,
removeMessageById?: IRemoveMessageById['removeMessageById'],
) => {
const { deleteMessage, loading } = useDeleteMessage();
const onRemoveMessage = useCallback(async () => {
if (messageId) {
const code = await deleteMessage(messageId);
if (code === 0) {
removeMessageById?.(messageId);
}
}
}, [deleteMessage, messageId, removeMessageById]);
return { onRemoveMessage, loading };
};
export const useSpeech = (content: string, audioBinary?: string) => {
const ref = useRef<HTMLAudioElement>(null);
const { read } = useSpeechWithSse();
const player = useRef<SpeechPlayer>();
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const initialize = useCallback(async () => {
player.current = new SpeechPlayer({
audio: ref.current!,
onPlaying: () => {
setIsPlaying(true);
},
onPause: () => {
setIsPlaying(false);
},
onChunkEnd: () => {},
mimeType: MediaSource.isTypeSupported('audio/mpeg')
? 'audio/mpeg'
: 'audio/mp4; codecs="mp4a.40.2"', // https://stackoverflow.com/questions/64079424/cannot-replay-mp3-in-firefox-using-mediasource-even-though-it-works-in-chrome
});
await player.current.init();
}, []);
const pause = useCallback(() => {
player.current?.pause();
}, []);
const speech = useCallback(async () => {
const response = await read({ text: content });
if (response) {
player?.current?.feedWithResponse(response);
}
}, [read, content]);
const handleRead = useCallback(async () => {
if (isPlaying) {
setIsPlaying(false);
pause();
} else {
setIsPlaying(true);
speech();
}
}, [setIsPlaying, speech, isPlaying, pause]);
useEffect(() => {
if (audioBinary) {
const units = hexStringToUint8Array(audioBinary);
if (units) {
try {
player.current?.feed(units);
} catch (error) {
console.warn(error);
}
}
}
}, [audioBinary]);
useEffect(() => {
initialize();
}, [initialize]);
return { ref, handleRead, isPlaying };
};

View File

@@ -0,0 +1,62 @@
.messageItem {
padding: 24px 0;
.messageItemSection {
display: inline-block;
}
.messageItemSectionLeft {
width: 80%;
}
.messageItemContent {
display: inline-flex;
gap: 20px;
}
.messageItemContentReverse {
flex-direction: row-reverse;
}
.messageTextBase() {
padding: 6px 10px;
border-radius: 8px;
& > p {
margin: 0;
}
}
.messageText {
.chunkText();
.messageTextBase();
// background-color: #e6f4ff;
word-break: break-word;
}
.messageTextDark {
.chunkText();
.messageTextBase();
word-break: break-word;
:global(section.think) {
color: rgb(166, 166, 166);
border-left-color: rgb(78, 78, 86);
}
}
.messageUserText {
.chunkText();
.messageTextBase();
background-color: rgba(255, 255, 255, 0.3);
word-break: break-word;
text-align: justify;
}
.messageEmpty {
width: 300px;
}
.thumbnailImg {
max-width: 20px;
}
}
.messageItemLeft {
text-align: left;
}
.messageItemRight {
text-align: right;
}

View File

@@ -0,0 +1,166 @@
import { ReactComponent as AssistantIcon } from '@/assets/svg/assistant.svg';
import { MessageType } from '@/constants/chat';
import { IReference, IReferenceChunk } from '@/interfaces/database/chat';
import classNames from 'classnames';
import { memo, useCallback, useEffect, useMemo } from 'react';
import {
useFetchDocumentInfosByIds,
useFetchDocumentThumbnailsByIds,
} from '@/hooks/document-hooks';
import { IRegenerateMessage, IRemoveMessageById } from '@/hooks/logic-hooks';
import { cn } from '@/lib/utils';
import { IMessage } from '@/pages/chat/interface';
import MarkdownContent from '@/pages/chat/markdown-content';
import { Avatar, Flex, Space } from 'antd';
import { ReferenceDocumentList } from '../next-message-item/reference-document-list';
import { InnerUploadedMessageFiles } from '../next-message-item/uploaded-message-files';
import { useTheme } from '../theme-provider';
import { AssistantGroupButton, UserGroupButton } from './group-button';
import styles from './index.less';
interface IProps extends Partial<IRemoveMessageById>, IRegenerateMessage {
item: IMessage;
reference: IReference;
loading?: boolean;
sendLoading?: boolean;
visibleAvatar?: boolean;
nickname?: string;
avatar?: string;
avatarDialog?: string | null;
clickDocumentButton?: (documentId: string, chunk: IReferenceChunk) => void;
index: number;
showLikeButton?: boolean;
showLoudspeaker?: boolean;
}
const MessageItem = ({
item,
reference,
loading = false,
avatar,
avatarDialog,
sendLoading = false,
clickDocumentButton,
index,
removeMessageById,
regenerateMessage,
showLikeButton = true,
showLoudspeaker = true,
visibleAvatar = true,
}: IProps) => {
const { theme } = useTheme();
const isAssistant = item.role === MessageType.Assistant;
const isUser = item.role === MessageType.User;
const { data: documentList, setDocumentIds } = useFetchDocumentInfosByIds();
const { data: documentThumbnails, setDocumentIds: setIds } =
useFetchDocumentThumbnailsByIds();
const referenceDocumentList = useMemo(() => {
return reference?.doc_aggs ?? [];
}, [reference?.doc_aggs]);
const handleRegenerateMessage = useCallback(() => {
regenerateMessage?.(item);
}, [regenerateMessage, item]);
useEffect(() => {
const ids = item?.doc_ids ?? [];
if (ids.length) {
setDocumentIds(ids);
const documentIds = ids.filter((x) => !(x in documentThumbnails));
if (documentIds.length) {
setIds(documentIds);
}
}
}, [item.doc_ids, setDocumentIds, setIds, documentThumbnails]);
return (
<div
className={classNames(styles.messageItem, {
[styles.messageItemLeft]: item.role === MessageType.Assistant,
[styles.messageItemRight]: item.role === MessageType.User,
})}
>
<section
className={classNames(styles.messageItemSection, {
[styles.messageItemSectionLeft]: item.role === MessageType.Assistant,
[styles.messageItemSectionRight]: item.role === MessageType.User,
})}
>
<div
className={classNames(styles.messageItemContent, {
[styles.messageItemContentReverse]: item.role === MessageType.User,
})}
>
{visibleAvatar &&
(item.role === MessageType.User ? (
<Avatar size={40} src={avatar ?? '/logo.svg'} />
) : avatarDialog ? (
<Avatar size={40} src={avatarDialog} />
) : (
<AssistantIcon />
))}
<Flex vertical gap={8} flex={1}>
<Space>
{isAssistant ? (
index !== 0 && (
<AssistantGroupButton
messageId={item.id}
content={item.content}
prompt={item.prompt}
showLikeButton={showLikeButton}
audioBinary={item.audio_binary}
showLoudspeaker={showLoudspeaker}
></AssistantGroupButton>
)
) : (
<UserGroupButton
content={item.content}
messageId={item.id}
removeMessageById={removeMessageById}
regenerateMessage={
regenerateMessage && handleRegenerateMessage
}
sendLoading={sendLoading}
></UserGroupButton>
)}
{/* <b>{isAssistant ? '' : nickname}</b> */}
</Space>
<div
className={cn(
isAssistant
? theme === 'dark'
? styles.messageTextDark
: styles.messageText
: styles.messageUserText,
{ '!bg-bg-card': !isAssistant },
)}
>
<MarkdownContent
loading={loading}
content={item.content}
reference={reference}
clickDocumentButton={clickDocumentButton}
></MarkdownContent>
</div>
{isAssistant && referenceDocumentList.length > 0 && (
<ReferenceDocumentList
list={referenceDocumentList}
></ReferenceDocumentList>
)}
{isUser && documentList.length > 0 && (
<InnerUploadedMessageFiles
files={documentList}
></InnerUploadedMessageFiles>
)}
</Flex>
</div>
</section>
</div>
);
};
export default memo(MessageItem);

View File

@@ -0,0 +1,30 @@
import { IModalProps } from '@/interfaces/common';
import { IFeedbackRequestBody } from '@/interfaces/request/chat';
import { Modal, Space } from 'antd';
import HightLightMarkdown from '../highlight-markdown';
import SvgIcon from '../svg-icon';
const PromptModal = ({
visible,
hideModal,
prompt,
}: IModalProps<IFeedbackRequestBody> & { prompt?: string }) => {
return (
<Modal
title={
<Space>
<SvgIcon name={`prompt`} width={18}></SvgIcon>
Prompt
</Space>
}
width={'80%'}
open={visible}
onCancel={hideModal}
footer={null}
>
<HightLightMarkdown>{prompt}</HightLightMarkdown>
</Modal>
);
};
export default PromptModal;

View File

@@ -0,0 +1,75 @@
import { DatasetMetadata } from '@/constants/chat';
import { useTranslate } from '@/hooks/common-hooks';
import { useFormContext, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { SelectWithSearch } from '../originui/select-with-search';
import { RAGFlowFormItem } from '../ragflow-form';
import { MetadataFilterConditions } from './metadata-filter-conditions';
type MetadataFilterProps = {
prefix?: string;
};
export const MetadataFilterSchema = {
meta_data_filter: z
.object({
method: z.string().optional(),
manual: z
.array(
z.object({
key: z.string(),
op: z.string(),
value: z.string(),
}),
)
.optional(),
})
.optional(),
};
export function MetadataFilter({ prefix = '' }: MetadataFilterProps) {
const { t } = useTranslate('chat');
const form = useFormContext();
const methodName = prefix + 'meta_data_filter.method';
const kbIds: string[] = useWatch({
control: form.control,
name: prefix + 'kb_ids',
});
const metadata = useWatch({
control: form.control,
name: methodName,
});
const hasKnowledge = Array.isArray(kbIds) && kbIds.length > 0;
const MetadataOptions = Object.values(DatasetMetadata).map((x) => {
return {
value: x,
label: t(`meta.${x}`),
};
});
return (
<>
{hasKnowledge && (
<RAGFlowFormItem
label={t('metadata')}
name={methodName}
tooltip={t('metadataTip')}
>
<SelectWithSearch
options={MetadataOptions}
triggerClassName="!bg-bg-input"
/>
</RAGFlowFormItem>
)}
{hasKnowledge && metadata === DatasetMetadata.Manual && (
<MetadataFilterConditions
kbIds={kbIds}
prefix={prefix}
></MetadataFilterConditions>
)}
</>
);
}

View File

@@ -0,0 +1,135 @@
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { useFetchKnowledgeMetadata } from '@/hooks/use-knowledge-request';
import { SwitchOperatorOptions } from '@/pages/agent/constant';
import { useBuildSwitchOperatorOptions } from '@/pages/agent/form/switch-form';
import { Plus, X } from 'lucide-react';
import { useCallback } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
export function MetadataFilterConditions({
kbIds,
prefix = '',
}: {
kbIds: string[];
prefix?: string;
}) {
const { t } = useTranslation();
const form = useFormContext();
const name = prefix + 'meta_data_filter.manual';
const metadata = useFetchKnowledgeMetadata(kbIds);
const switchOperatorOptions = useBuildSwitchOperatorOptions();
const { fields, remove, append } = useFieldArray({
name,
control: form.control,
});
const add = useCallback(
(key: string) => () => {
append({
key,
value: '',
op: SwitchOperatorOptions[0].value,
});
},
[append],
);
return (
<section className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<FormLabel>{t('chat.conditions')}</FormLabel>
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant={'ghost'} type="button">
<Plus />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-[300px] !overflow-y-auto scrollbar-auto">
{Object.keys(metadata.data).map((key, idx) => {
return (
<DropdownMenuItem key={idx} onClick={add(key)}>
{key}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="space-y-5">
{fields.map((field, index) => {
const typeField = `${name}.${index}.key`;
return (
<div key={field.id} className="flex w-full items-center gap-2">
<FormField
control={form.control}
name={typeField}
render={({ field }) => (
<FormItem className="flex-1 overflow-hidden">
<FormControl>
<Input
{...field}
placeholder={t('common.pleaseInput')}
></Input>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Separator className="w-3 text-text-secondary" />
<FormField
control={form.control}
name={`${name}.${index}.op`}
render={({ field }) => (
<FormItem className="flex-1 overflow-hidden">
<FormControl>
<SelectWithSearch
{...field}
options={switchOperatorOptions}
></SelectWithSearch>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Separator className="w-3 text-text-secondary" />
<FormField
control={form.control}
name={`${name}.${index}.value`}
render={({ field }) => (
<FormItem className="flex-1 overflow-hidden">
<FormControl>
<Input placeholder={t('common.pleaseInput')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button variant={'ghost'} onClick={() => remove(index)}>
<X className="text-text-sub-title-invert " />
</Button>
</div>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,25 @@
import { useState } from 'react';
export interface IModalManagerChildrenProps {
showModal(): void;
hideModal(): void;
visible: boolean;
}
interface IProps {
children: (props: IModalManagerChildrenProps) => React.ReactNode;
}
const ModalManager = ({ children }: IProps) => {
const [visible, setVisible] = useState(false);
const showModal = () => {
setVisible(true);
};
const hideModal = () => {
setVisible(false);
};
return children({ visible, showModal, hideModal });
};
export default ModalManager;

View File

@@ -0,0 +1,20 @@
import { cn } from '@/lib/utils';
import { Ellipsis } from 'lucide-react';
import React from 'react';
import { Button, ButtonProps } from './ui/button';
export const MoreButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, size, ...props }, ref) => {
return (
<Button
ref={ref}
variant="ghost"
size={size || 'icon'}
className={cn('invisible group-hover:visible size-3.5', className)}
{...props}
>
<Ellipsis />
</Button>
);
},
);

View File

@@ -0,0 +1,51 @@
import {
getExtension,
isSupportedPreviewDocumentType,
} from '@/utils/document-util';
import React from 'react';
interface IProps extends React.PropsWithChildren {
link?: string;
preventDefault?: boolean;
color?: string;
documentName: string;
documentId?: string;
prefix?: string;
className?: string;
}
const NewDocumentLink = ({
children,
link,
preventDefault = false,
color = 'rgb(15, 79, 170)',
documentId,
documentName,
prefix = 'file',
className,
}: IProps) => {
let nextLink = link;
const extension = getExtension(documentName);
if (!link) {
nextLink = `/document/${documentId}?ext=${extension}&prefix=${prefix}`;
}
return (
<a
target="_blank"
onClick={
!preventDefault || isSupportedPreviewDocumentType(extension)
? undefined
: (e) => e.preventDefault()
}
href={nextLink}
rel="noreferrer"
style={{ color: className ? '' : color, wordBreak: 'break-all' }}
className={className}
>
{children}
</a>
);
};
export default NewDocumentLink;

Some files were not shown because too many files have changed in this diff Show More