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,19 @@
import { Button, Result } from 'antd';
import { history } from 'umi';
const NoFoundPage = () => {
return (
<Result
status="404"
title="404"
subTitle="Page not found, please enter a correct address."
extra={
<Button type="primary" onClick={() => history.push('/')}>
Business
</Button>
}
/>
);
};
export default NoFoundPage;

View File

@@ -0,0 +1,34 @@
.image {
width: 100px !important;
object-fit: contain;
}
.imagePreview {
max-width: 50vw;
max-height: 50vh;
object-fit: contain;
}
.content {
flex: 1;
.chunkText;
}
.contentEllipsis {
.multipleLineEllipsis(3);
}
.contentText {
word-break: break-all !important;
}
.chunkCard {
width: 100%;
}
.cardSelected {
background-color: @selectedBackgroundColor;
}
.cardSelectedDark {
background-color: #ffffff2f;
}

View File

@@ -0,0 +1,101 @@
import Image from '@/components/image';
import { IChunk } from '@/interfaces/database/knowledge';
import { Card, Checkbox, CheckboxProps, Flex, Popover, Switch } from 'antd';
import classNames from 'classnames';
import DOMPurify from 'dompurify';
import { useEffect, useState } from 'react';
import { useTheme } from '@/components/theme-provider';
import { ChunkTextMode } from '../../constant';
import styles from './index.less';
interface IProps {
item: IChunk;
checked: boolean;
switchChunk: (available?: number, chunkIds?: string[]) => void;
editChunk: (chunkId: string) => void;
handleCheckboxClick: (chunkId: string, checked: boolean) => void;
selected: boolean;
clickChunkCard: (chunkId: string) => void;
textMode: ChunkTextMode;
}
const ChunkCard = ({
item,
checked,
handleCheckboxClick,
editChunk,
switchChunk,
selected,
clickChunkCard,
textMode,
}: IProps) => {
const available = Number(item.available_int);
const [enabled, setEnabled] = useState(false);
const { theme } = useTheme();
const onChange = (checked: boolean) => {
setEnabled(checked);
switchChunk(available === 0 ? 1 : 0, [item.chunk_id]);
};
const handleCheck: CheckboxProps['onChange'] = (e) => {
handleCheckboxClick(item.chunk_id, e.target.checked);
};
const handleContentDoubleClick = () => {
editChunk(item.chunk_id);
};
const handleContentClick = () => {
clickChunkCard(item.chunk_id);
};
useEffect(() => {
setEnabled(available === 1);
}, [available]);
return (
<Card
className={classNames(styles.chunkCard, {
[`${theme === 'dark' ? styles.cardSelectedDark : styles.cardSelected}`]:
selected,
})}
>
<Flex gap={'middle'} justify={'space-between'}>
<Checkbox onChange={handleCheck} checked={checked}></Checkbox>
{item.image_id && (
<Popover
placement="right"
content={
<Image id={item.image_id} className={styles.imagePreview}></Image>
}
>
<Image id={item.image_id} className={styles.image}></Image>
</Popover>
)}
<section
onDoubleClick={handleContentDoubleClick}
onClick={handleContentClick}
className={styles.content}
>
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(item.content_with_weight),
}}
className={classNames(styles.contentText, {
[styles.contentEllipsis]: textMode === ChunkTextMode.Ellipse,
})}
></div>
</section>
<div>
<Switch checked={enabled} onChange={onChange} />
</div>
</Flex>
</Card>
);
};
export default ChunkCard;

View File

@@ -0,0 +1,140 @@
import EditTag from '@/components/edit-tag';
import { useFetchChunk } from '@/hooks/chunk-hooks';
import { IModalProps } from '@/interfaces/common';
import { IChunk } from '@/interfaces/database/knowledge';
import { DeleteOutlined } from '@ant-design/icons';
import { Divider, Form, Input, Modal, Space, Switch } from 'antd';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDeleteChunkByIds } from '../../hooks';
import {
transformTagFeaturesArrayToObject,
transformTagFeaturesObjectToArray,
} from '../../utils';
import { TagFeatureItem } from './tag-feature-item';
type FieldType = Pick<
IChunk,
'content_with_weight' | 'tag_kwd' | 'question_kwd' | 'important_kwd'
>;
interface kFProps {
doc_id: string;
chunkId: string | undefined;
parserId: string;
}
const ChunkCreatingModal: React.FC<IModalProps<any> & kFProps> = ({
doc_id,
chunkId,
hideModal,
onOk,
loading,
parserId,
}) => {
const [form] = Form.useForm();
const [checked, setChecked] = useState(false);
const { removeChunk } = useDeleteChunkByIds();
const { data } = useFetchChunk(chunkId);
const { t } = useTranslation();
const isTagParser = parserId === 'tag';
const handleOk = useCallback(async () => {
try {
const values = await form.validateFields();
console.log('🚀 ~ handleOk ~ values:', values);
onOk?.({
...values,
tag_feas: transformTagFeaturesArrayToObject(values.tag_feas),
available_int: checked ? 1 : 0, // available_int
});
} catch (errorInfo) {
console.log('Failed:', errorInfo);
}
}, [checked, form, onOk]);
const handleRemove = useCallback(() => {
if (chunkId) {
return removeChunk([chunkId], doc_id);
}
}, [chunkId, doc_id, removeChunk]);
const handleCheck = useCallback(() => {
setChecked(!checked);
}, [checked]);
useEffect(() => {
if (data?.code === 0) {
const { available_int, tag_feas } = data.data;
form.setFieldsValue({
...(data.data || {}),
tag_feas: transformTagFeaturesObjectToArray(tag_feas),
});
setChecked(available_int !== 0);
}
}, [data, form, chunkId]);
return (
<Modal
title={`${chunkId ? t('common.edit') : t('common.create')} ${t('chunk.chunk')}`}
open={true}
onOk={handleOk}
onCancel={hideModal}
okButtonProps={{ loading }}
destroyOnClose
>
<Form form={form} autoComplete="off" layout={'vertical'}>
<Form.Item<FieldType>
label={t('chunk.chunk')}
name="content_with_weight"
rules={[{ required: true, message: t('chunk.chunkMessage') }]}
>
<Input.TextArea autoSize={{ minRows: 4, maxRows: 10 }} />
</Form.Item>
<Form.Item<FieldType> label={t('chunk.keyword')} name="important_kwd">
<EditTag></EditTag>
</Form.Item>
<Form.Item<FieldType>
label={t('chunk.question')}
name="question_kwd"
tooltip={t('chunk.questionTip')}
>
<EditTag></EditTag>
</Form.Item>
{isTagParser && (
<Form.Item<FieldType>
label={t('knowledgeConfiguration.tagName')}
name="tag_kwd"
>
<EditTag></EditTag>
</Form.Item>
)}
{!isTagParser && <TagFeatureItem></TagFeatureItem>}
</Form>
{chunkId && (
<section>
<Divider></Divider>
<Space size={'large'}>
<Switch
checkedChildren={t('chunk.enabled')}
unCheckedChildren={t('chunk.disabled')}
onChange={handleCheck}
checked={checked}
/>
<span onClick={handleRemove}>
<DeleteOutlined /> {t('common.delete')}
</span>
</Space>
</section>
)}
</Modal>
);
};
export default ChunkCreatingModal;

View File

@@ -0,0 +1,107 @@
import {
useFetchKnowledgeBaseConfiguration,
useFetchTagListByKnowledgeIds,
} from '@/hooks/knowledge-hooks';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Button, Form, InputNumber, Select } from 'antd';
import { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { FormListItem } from '../../utils';
const FieldKey = 'tag_feas';
export const TagFeatureItem = () => {
const form = Form.useFormInstance();
const { t } = useTranslation();
const { data: knowledgeConfiguration } = useFetchKnowledgeBaseConfiguration();
const { setKnowledgeIds, list } = useFetchTagListByKnowledgeIds();
const tagKnowledgeIds = useMemo(() => {
return knowledgeConfiguration?.parser_config?.tag_kb_ids ?? [];
}, [knowledgeConfiguration?.parser_config?.tag_kb_ids]);
const options = useMemo(() => {
return list.map((x) => ({
value: x[0],
label: x[0],
}));
}, [list]);
const filterOptions = useCallback(
(index: number) => {
const tags: FormListItem[] = form.getFieldValue(FieldKey) ?? [];
// Exclude it's own current data
const list = tags
.filter((x, idx) => x && index !== idx)
.map((x) => x.tag);
// Exclude the selected data from other options from one's own options.
return options.filter((x) => !list.some((y) => x.value === y));
},
[form, options],
);
useEffect(() => {
setKnowledgeIds(tagKnowledgeIds);
}, [setKnowledgeIds, tagKnowledgeIds]);
return (
<Form.Item label={t('knowledgeConfiguration.tags')}>
<Form.List name={FieldKey} initialValue={[]}>
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<div key={key} className="flex gap-3 items-center">
<div className="flex flex-1 gap-8">
<Form.Item
{...restField}
name={[name, 'tag']}
rules={[
{ required: true, message: t('common.pleaseSelect') },
]}
className="w-2/3"
>
<Select
showSearch
placeholder={t('knowledgeConfiguration.tagName')}
options={filterOptions(name)}
/>
</Form.Item>
<Form.Item
{...restField}
name={[name, 'frequency']}
rules={[
{ required: true, message: t('common.pleaseInput') },
]}
>
<InputNumber
placeholder={t('knowledgeConfiguration.frequency')}
max={10}
min={0}
/>
</Form.Item>
</div>
<MinusCircleOutlined
onClick={() => remove(name)}
className="mb-6"
/>
</div>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => add()}
block
icon={<PlusOutlined />}
>
{t('knowledgeConfiguration.addTag')}
</Button>
</Form.Item>
</>
)}
</Form.List>
</Form.Item>
);
};

View File

@@ -0,0 +1,221 @@
import { ReactComponent as FilterIcon } from '@/assets/filter.svg';
import { KnowledgeRouteKey } from '@/constants/knowledge';
import { IChunkListResult, useSelectChunkList } from '@/hooks/chunk-hooks';
import { useTranslate } from '@/hooks/common-hooks';
import { useKnowledgeBaseId } from '@/hooks/knowledge-hooks';
import {
ArrowLeftOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
DeleteOutlined,
DownOutlined,
FilePdfOutlined,
PlusOutlined,
SearchOutlined,
} from '@ant-design/icons';
import {
Button,
Checkbox,
Flex,
Input,
Menu,
MenuProps,
Popover,
Radio,
RadioChangeEvent,
Segmented,
SegmentedProps,
Space,
Typography,
} from 'antd';
import { useCallback, useMemo, useState } from 'react';
import { Link } from 'umi';
import { ChunkTextMode } from '../../constant';
const { Text } = Typography;
interface IProps
extends Pick<
IChunkListResult,
'searchString' | 'handleInputChange' | 'available' | 'handleSetAvailable'
> {
checked: boolean;
selectAllChunk: (checked: boolean) => void;
createChunk: () => void;
removeChunk: () => void;
switchChunk: (available: number) => void;
changeChunkTextMode(mode: ChunkTextMode): void;
}
const ChunkToolBar = ({
selectAllChunk,
checked,
createChunk,
removeChunk,
switchChunk,
changeChunkTextMode,
available,
handleSetAvailable,
searchString,
handleInputChange,
}: IProps) => {
const data = useSelectChunkList();
const documentInfo = data?.documentInfo;
const knowledgeBaseId = useKnowledgeBaseId();
const [isShowSearchBox, setIsShowSearchBox] = useState(false);
const { t } = useTranslate('chunk');
const handleSelectAllCheck = useCallback(
(e: any) => {
selectAllChunk(e.target.checked);
},
[selectAllChunk],
);
const handleSearchIconClick = () => {
setIsShowSearchBox(true);
};
const handleSearchBlur = () => {
if (!searchString?.trim()) {
setIsShowSearchBox(false);
}
};
const handleDelete = useCallback(() => {
removeChunk();
}, [removeChunk]);
const handleEnabledClick = useCallback(() => {
switchChunk(1);
}, [switchChunk]);
const handleDisabledClick = useCallback(() => {
switchChunk(0);
}, [switchChunk]);
const items: MenuProps['items'] = useMemo(() => {
return [
{
key: '1',
label: (
<>
<Checkbox onChange={handleSelectAllCheck} checked={checked}>
<b>{t('selectAll')}</b>
</Checkbox>
</>
),
},
{ type: 'divider' },
{
key: '2',
label: (
<Space onClick={handleEnabledClick}>
<CheckCircleOutlined />
<b>{t('enabledSelected')}</b>
</Space>
),
},
{
key: '3',
label: (
<Space onClick={handleDisabledClick}>
<CloseCircleOutlined />
<b>{t('disabledSelected')}</b>
</Space>
),
},
{ type: 'divider' },
{
key: '4',
label: (
<Space onClick={handleDelete}>
<DeleteOutlined />
<b>{t('deleteSelected')}</b>
</Space>
),
},
];
}, [
checked,
handleSelectAllCheck,
handleDelete,
handleEnabledClick,
handleDisabledClick,
t,
]);
const content = (
<Menu style={{ width: 200 }} items={items} selectable={false} />
);
const handleFilterChange = (e: RadioChangeEvent) => {
selectAllChunk(false);
handleSetAvailable(e.target.value);
};
const filterContent = (
<Radio.Group onChange={handleFilterChange} value={available}>
<Space direction="vertical">
<Radio value={undefined}>{t('all')}</Radio>
<Radio value={1}>{t('enabled')}</Radio>
<Radio value={0}>{t('disabled')}</Radio>
</Space>
</Radio.Group>
);
return (
<Flex justify="space-between" align="center">
<Space size={'middle'}>
<Link
to={`/knowledge/${KnowledgeRouteKey.Dataset}?id=${knowledgeBaseId}`}
>
<ArrowLeftOutlined />
</Link>
<FilePdfOutlined />
<Text ellipsis={{ tooltip: documentInfo?.name }} style={{ width: 150 }}>
{documentInfo?.name}
</Text>
</Space>
<Space>
<Segmented
options={[
{ label: t(ChunkTextMode.Full), value: ChunkTextMode.Full },
{ label: t(ChunkTextMode.Ellipse), value: ChunkTextMode.Ellipse },
]}
onChange={changeChunkTextMode as SegmentedProps['onChange']}
/>
<Popover content={content} placement="bottom" arrow={false}>
<Button>
{t('bulk')}
<DownOutlined />
</Button>
</Popover>
{isShowSearchBox ? (
<Input
size="middle"
placeholder={t('search')}
prefix={<SearchOutlined />}
allowClear
onChange={handleInputChange}
onBlur={handleSearchBlur}
value={searchString}
/>
) : (
<Button icon={<SearchOutlined />} onClick={handleSearchIconClick} />
)}
<Popover content={filterContent} placement="bottom" arrow={false}>
<Button icon={<FilterIcon />} />
</Popover>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={() => createChunk()}
/>
</Space>
</Flex>
);
};
export default ChunkToolBar;

View File

@@ -0,0 +1,55 @@
import { useGetKnowledgeSearchParams } from '@/hooks/route-hook';
import { api_host } from '@/utils/api';
import { useSize } from 'ahooks';
import { CustomTextRenderer } from 'node_modules/react-pdf/dist/esm/shared/types';
import { useCallback, useEffect, useMemo, useState } from 'react';
export const useDocumentResizeObserver = () => {
const [containerWidth, setContainerWidth] = useState<number>();
const [containerRef, setContainerRef] = useState<HTMLElement | null>(null);
const size = useSize(containerRef);
const onResize = useCallback((width?: number) => {
if (width) {
setContainerWidth(width);
}
}, []);
useEffect(() => {
onResize(size?.width);
}, [size?.width, onResize]);
return { containerWidth, setContainerRef };
};
function highlightPattern(text: string, pattern: string, pageNumber: number) {
if (pageNumber === 2) {
return `<mark>${text}</mark>`;
}
if (text.trim() !== '' && pattern.match(text)) {
// return pattern.replace(text, (value) => `<mark>${value}</mark>`);
return `<mark>${text}</mark>`;
}
return text.replace(pattern, (value) => `<mark>${value}</mark>`);
}
export const useHighlightText = (searchText: string = '') => {
const textRenderer: CustomTextRenderer = useCallback(
(textItem) => {
return highlightPattern(textItem.str, searchText, textItem.pageNumber);
},
[searchText],
);
return textRenderer;
};
export const useGetDocumentUrl = () => {
const { documentId } = useGetKnowledgeSearchParams();
const url = useMemo(() => {
return `${api_host}/document/get/${documentId}`;
}, [documentId]);
return url;
};

View File

@@ -0,0 +1,12 @@
.documentContainer {
width: 100%;
height: calc(100vh - 284px);
position: relative;
:global(.PdfHighlighter) {
overflow-x: hidden;
}
:global(.Highlight--scrolledTo .Highlight__part) {
overflow-x: hidden;
background-color: rgba(255, 226, 143, 1);
}
}

View File

@@ -0,0 +1,121 @@
import { Skeleton } from 'antd';
import { memo, useEffect, useRef } from 'react';
import {
AreaHighlight,
Highlight,
IHighlight,
PdfHighlighter,
PdfLoader,
Popup,
} from 'react-pdf-highlighter';
import { useGetDocumentUrl } from './hooks';
import { useCatchDocumentError } from '@/components/pdf-previewer/hooks';
import FileError from '@/pages/document-viewer/file-error';
import styles from './index.less';
interface IProps {
highlights: IHighlight[];
setWidthAndHeight: (width: number, height: number) => void;
}
const HighlightPopup = ({
comment,
}: {
comment: { text: string; emoji: string };
}) =>
comment.text ? (
<div className="Highlight__popup">
{comment.emoji} {comment.text}
</div>
) : null;
// TODO: merge with DocumentPreviewer
const Preview = ({ highlights: state, setWidthAndHeight }: IProps) => {
const url = useGetDocumentUrl();
const ref = useRef<(highlight: IHighlight) => void>(() => {});
const error = useCatchDocumentError(url);
const resetHash = () => {};
useEffect(() => {
if (state.length > 0) {
ref?.current(state[0]);
}
}, [state]);
return (
<div className={styles.documentContainer}>
<PdfLoader
url={url}
beforeLoad={<Skeleton active />}
workerSrc="/pdfjs-dist/pdf.worker.min.js"
errorMessage={<FileError>{error}</FileError>}
>
{(pdfDocument) => {
pdfDocument.getPage(1).then((page) => {
const viewport = page.getViewport({ scale: 1 });
const width = viewport.width;
const height = viewport.height;
setWidthAndHeight(width, height);
});
return (
<PdfHighlighter
pdfDocument={pdfDocument}
enableAreaSelection={(event) => event.altKey}
onScrollChange={resetHash}
scrollRef={(scrollTo) => {
ref.current = scrollTo;
}}
onSelectionFinished={() => null}
highlightTransform={(
highlight,
index,
setTip,
hideTip,
viewportToScaled,
screenshot,
isScrolledTo,
) => {
const isTextHighlight = !Boolean(
highlight.content && highlight.content.image,
);
const component = isTextHighlight ? (
<Highlight
isScrolledTo={isScrolledTo}
position={highlight.position}
comment={highlight.comment}
/>
) : (
<AreaHighlight
isScrolledTo={isScrolledTo}
highlight={highlight}
onChange={() => {}}
/>
);
return (
<Popup
popupContent={<HighlightPopup {...highlight} />}
onMouseOver={(popupContent) =>
setTip(highlight, () => popupContent)
}
onMouseOut={hideTip}
key={index}
>
{component}
</Popup>
);
}}
highlights={state}
/>
);
}}
</PdfLoader>
</div>
);
};
export default memo(Preview);

View File

@@ -0,0 +1,4 @@
export enum ChunkTextMode {
Full = 'full',
Ellipse = 'ellipse',
}

View File

@@ -0,0 +1,129 @@
import {
useCreateChunk,
useDeleteChunk,
useSelectChunkList,
} from '@/hooks/chunk-hooks';
import { useSetModalState, useShowDeleteConfirm } from '@/hooks/common-hooks';
import { useGetKnowledgeSearchParams } from '@/hooks/route-hook';
import { IChunk } from '@/interfaces/database/knowledge';
import { buildChunkHighlights } from '@/utils/document-util';
import { useCallback, useMemo, useState } from 'react';
import { IHighlight } from 'react-pdf-highlighter';
import { ChunkTextMode } from './constant';
export const useHandleChunkCardClick = () => {
const [selectedChunkId, setSelectedChunkId] = useState<string>('');
const handleChunkCardClick = useCallback((chunkId: string) => {
setSelectedChunkId(chunkId);
}, []);
return { handleChunkCardClick, selectedChunkId };
};
export const useGetSelectedChunk = (selectedChunkId: string) => {
const data = useSelectChunkList();
return (
data?.data?.find((x) => x.chunk_id === selectedChunkId) ?? ({} as IChunk)
);
};
export const useGetChunkHighlights = (selectedChunkId: string) => {
const [size, setSize] = useState({ width: 849, height: 1200 });
const selectedChunk: IChunk = useGetSelectedChunk(selectedChunkId);
const highlights: IHighlight[] = useMemo(() => {
return buildChunkHighlights(selectedChunk, size);
}, [selectedChunk, size]);
const setWidthAndHeight = useCallback((width: number, height: number) => {
setSize((pre) => {
if (pre.height !== height || pre.width !== width) {
return { height, width };
}
return pre;
});
}, []);
return { highlights, setWidthAndHeight };
};
// Switch chunk text to be fully displayed or ellipse
export const useChangeChunkTextMode = () => {
const [textMode, setTextMode] = useState<ChunkTextMode>(ChunkTextMode.Full);
const changeChunkTextMode = useCallback((mode: ChunkTextMode) => {
setTextMode(mode);
}, []);
return { textMode, changeChunkTextMode };
};
export const useDeleteChunkByIds = (): {
removeChunk: (chunkIds: string[], documentId: string) => Promise<number>;
} => {
const { deleteChunk } = useDeleteChunk();
const showDeleteConfirm = useShowDeleteConfirm();
const removeChunk = useCallback(
(chunkIds: string[], documentId: string) => () => {
return deleteChunk({ chunkIds, doc_id: documentId });
},
[deleteChunk],
);
const onRemoveChunk = useCallback(
(chunkIds: string[], documentId: string): Promise<number> => {
return showDeleteConfirm({ onOk: removeChunk(chunkIds, documentId) });
},
[removeChunk, showDeleteConfirm],
);
return {
removeChunk: onRemoveChunk,
};
};
export const useUpdateChunk = () => {
const [chunkId, setChunkId] = useState<string | undefined>('');
const {
visible: chunkUpdatingVisible,
hideModal: hideChunkUpdatingModal,
showModal,
} = useSetModalState();
const { createChunk, loading } = useCreateChunk();
const { documentId } = useGetKnowledgeSearchParams();
const onChunkUpdatingOk = useCallback(
async (params: IChunk) => {
const code = await createChunk({
...params,
doc_id: documentId,
chunk_id: chunkId,
});
if (code === 0) {
hideChunkUpdatingModal();
}
},
[createChunk, hideChunkUpdatingModal, chunkId, documentId],
);
const handleShowChunkUpdatingModal = useCallback(
async (id?: string) => {
setChunkId(id);
showModal();
},
[showModal],
);
return {
chunkUpdatingLoading: loading,
onChunkUpdatingOk,
chunkUpdatingVisible,
hideChunkUpdatingModal,
showChunkUpdatingModal: handleShowChunkUpdatingModal,
chunkId,
documentId,
};
};

View File

@@ -0,0 +1,92 @@
.chunkPage {
padding: 24px;
display: flex;
// height: calc(100vh - 112px);
flex-direction: column;
.filter {
margin: 10px 0;
display: flex;
height: 32px;
justify-content: space-between;
}
.pagePdfWrapper {
width: 60%;
}
.pageWrapper {
width: 100%;
}
.pageContent {
flex: 1;
width: 100%;
padding-right: 12px;
overflow-y: auto;
.spin {
min-height: 400px;
}
}
.documentPreview {
width: 40%;
height: 100%;
}
.chunkContainer {
display: flex;
height: calc(100vh - 332px);
}
.chunkOtherContainer {
width: 100%;
}
.pageFooter {
padding-top: 10px;
height: 32px;
}
}
.container {
height: 100px;
display: flex;
flex-direction: column;
justify-content: space-between;
.content {
display: flex;
justify-content: space-between;
.context {
flex: 1;
// width: 207px;
height: 88px;
overflow: hidden;
}
}
.footer {
height: 20px;
.text {
margin-left: 10px;
}
}
}
.card {
:global {
.ant-card-body {
padding: 10px;
margin: 0;
}
margin-bottom: 10px;
}
cursor: pointer;
}

View File

@@ -0,0 +1,202 @@
import { useFetchNextChunkList, useSwitchChunk } from '@/hooks/chunk-hooks';
import type { PaginationProps } from 'antd';
import { Divider, Flex, Pagination, Space, Spin, message } from 'antd';
import classNames from 'classnames';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ChunkCard from './components/chunk-card';
import CreatingModal from './components/chunk-creating-modal';
import ChunkToolBar from './components/chunk-toolbar';
import DocumentPreview from './components/document-preview/preview';
import {
useChangeChunkTextMode,
useDeleteChunkByIds,
useGetChunkHighlights,
useHandleChunkCardClick,
useUpdateChunk,
} from './hooks';
import styles from './index.less';
const Chunk = () => {
const [selectedChunkIds, setSelectedChunkIds] = useState<string[]>([]);
const { removeChunk } = useDeleteChunkByIds();
const {
data: { documentInfo, data = [], total },
pagination,
loading,
searchString,
handleInputChange,
available,
handleSetAvailable,
} = useFetchNextChunkList();
const { handleChunkCardClick, selectedChunkId } = useHandleChunkCardClick();
const isPdf = documentInfo?.type === 'pdf';
const { t } = useTranslation();
const { changeChunkTextMode, textMode } = useChangeChunkTextMode();
const { switchChunk } = useSwitchChunk();
const {
chunkUpdatingLoading,
onChunkUpdatingOk,
showChunkUpdatingModal,
hideChunkUpdatingModal,
chunkId,
chunkUpdatingVisible,
documentId,
} = useUpdateChunk();
const onPaginationChange: PaginationProps['onShowSizeChange'] = (
page,
size,
) => {
setSelectedChunkIds([]);
pagination.onChange?.(page, size);
};
const selectAllChunk = useCallback(
(checked: boolean) => {
setSelectedChunkIds(checked ? data.map((x) => x.chunk_id) : []);
},
[data],
);
const handleSingleCheckboxClick = useCallback(
(chunkId: string, checked: boolean) => {
setSelectedChunkIds((previousIds) => {
const idx = previousIds.findIndex((x) => x === chunkId);
const nextIds = [...previousIds];
if (checked && idx === -1) {
nextIds.push(chunkId);
} else if (!checked && idx !== -1) {
nextIds.splice(idx, 1);
}
return nextIds;
});
},
[],
);
const showSelectedChunkWarning = useCallback(() => {
message.warning(t('message.pleaseSelectChunk'));
}, [t]);
const handleRemoveChunk = useCallback(async () => {
if (selectedChunkIds.length > 0) {
const resCode: number = await removeChunk(selectedChunkIds, documentId);
if (resCode === 0) {
setSelectedChunkIds([]);
}
} else {
showSelectedChunkWarning();
}
}, [selectedChunkIds, documentId, removeChunk, showSelectedChunkWarning]);
const handleSwitchChunk = useCallback(
async (available?: number, chunkIds?: string[]) => {
let ids = chunkIds;
if (!chunkIds) {
ids = selectedChunkIds;
if (selectedChunkIds.length === 0) {
showSelectedChunkWarning();
return;
}
}
const resCode: number = await switchChunk({
chunk_ids: ids,
available_int: available,
doc_id: documentId,
});
if (!chunkIds && resCode === 0) {
}
},
[switchChunk, documentId, selectedChunkIds, showSelectedChunkWarning],
);
const { highlights, setWidthAndHeight } =
useGetChunkHighlights(selectedChunkId);
return (
<>
<div className={styles.chunkPage}>
<ChunkToolBar
selectAllChunk={selectAllChunk}
createChunk={showChunkUpdatingModal}
removeChunk={handleRemoveChunk}
checked={selectedChunkIds.length === data.length}
switchChunk={handleSwitchChunk}
changeChunkTextMode={changeChunkTextMode}
searchString={searchString}
handleInputChange={handleInputChange}
available={available}
handleSetAvailable={handleSetAvailable}
></ChunkToolBar>
<Divider></Divider>
<Flex flex={1} gap={'middle'}>
<Flex
vertical
className={isPdf ? styles.pagePdfWrapper : styles.pageWrapper}
>
<Spin spinning={loading} className={styles.spin} size="large">
<div className={styles.pageContent}>
<Space
direction="vertical"
size={'middle'}
className={classNames(styles.chunkContainer, {
[styles.chunkOtherContainer]: !isPdf,
})}
>
{data.map((item) => (
<ChunkCard
item={item}
key={item.chunk_id}
editChunk={showChunkUpdatingModal}
checked={selectedChunkIds.some(
(x) => x === item.chunk_id,
)}
handleCheckboxClick={handleSingleCheckboxClick}
switchChunk={handleSwitchChunk}
clickChunkCard={handleChunkCardClick}
selected={item.chunk_id === selectedChunkId}
textMode={textMode}
></ChunkCard>
))}
</Space>
</div>
</Spin>
<div className={styles.pageFooter}>
<Pagination
{...pagination}
total={total}
size={'small'}
onChange={onPaginationChange}
/>
</div>
</Flex>
{isPdf && (
<section className={styles.documentPreview}>
<DocumentPreview
highlights={highlights}
setWidthAndHeight={setWidthAndHeight}
></DocumentPreview>
</section>
)}
</Flex>
</div>
{chunkUpdatingVisible && (
<CreatingModal
doc_id={documentId}
chunkId={chunkId}
hideModal={hideChunkUpdatingModal}
visible={chunkUpdatingVisible}
loading={chunkUpdatingLoading}
onOk={onChunkUpdatingOk}
parserId={documentInfo.parser_id}
/>
)}
</>
);
};
export default Chunk;

View File

@@ -0,0 +1,24 @@
export type FormListItem = {
frequency: number;
tag: string;
};
export function transformTagFeaturesArrayToObject(
list: Array<FormListItem> = [],
) {
return list.reduce<Record<string, number>>((pre, cur) => {
pre[cur.tag] = cur.frequency;
return pre;
}, {});
}
export function transformTagFeaturesObjectToArray(
object: Record<string, number> = {},
) {
return Object.keys(object).reduce<Array<FormListItem>>((pre, key) => {
pre.push({ frequency: object[key], tag: key });
return pre;
}, []);
}

View File

@@ -0,0 +1,7 @@
import { Outlet } from 'umi';
export const KnowledgeDataset = () => {
return <Outlet></Outlet>;
};
export default KnowledgeDataset;

View File

@@ -0,0 +1,17 @@
import { RunningStatus } from '@/constants/knowledge';
export const RunningStatusMap = {
[RunningStatus.UNSTART]: {
label: 'UNSTART',
color: 'cyan',
},
[RunningStatus.RUNNING]: {
label: 'Parsing',
color: 'blue',
},
[RunningStatus.CANCEL]: { label: 'CANCEL', color: 'orange' },
[RunningStatus.DONE]: { label: 'SUCCESS', color: 'geekblue' },
[RunningStatus.FAIL]: { label: 'FAIL', color: 'red' },
};
export * from '@/constants/knowledge';

View File

@@ -0,0 +1,49 @@
import { IModalManagerChildrenProps } from '@/components/modal-manager';
import { Form, Input, Modal } from 'antd';
import React from 'react';
type FieldType = {
name?: string;
};
interface IProps extends Omit<IModalManagerChildrenProps, 'showModal'> {
loading: boolean;
onOk: (name: string) => void;
showModal?(): void;
}
const FileCreatingModal: React.FC<IProps> = ({ visible, hideModal, onOk }) => {
const [form] = Form.useForm();
const handleOk = async () => {
const values = await form.validateFields();
onOk(values.name);
};
return (
<Modal
title="File Name"
open={visible}
onOk={handleOk}
onCancel={hideModal}
>
<Form
form={form}
name="validateOnly"
labelCol={{ span: 4 }}
wrapperCol={{ span: 20 }}
style={{ maxWidth: 600 }}
autoComplete="off"
>
<Form.Item<FieldType>
label="File Name"
name="name"
rules={[{ required: true, message: 'Please input name!' }]}
>
<Input />
</Form.Item>
</Form>
</Modal>
);
};
export default FileCreatingModal;

View File

@@ -0,0 +1,240 @@
import { ReactComponent as CancelIcon } from '@/assets/svg/cancel.svg';
import { ReactComponent as DeleteIcon } from '@/assets/svg/delete.svg';
import { ReactComponent as DisableIcon } from '@/assets/svg/disable.svg';
import { ReactComponent as EnableIcon } from '@/assets/svg/enable.svg';
import { ReactComponent as RunIcon } from '@/assets/svg/run.svg';
import { useShowDeleteConfirm, useTranslate } from '@/hooks/common-hooks';
import {
useRemoveNextDocument,
useRunNextDocument,
useSetNextDocumentStatus,
} from '@/hooks/document-hooks';
import { IDocumentInfo } from '@/interfaces/database/document';
import {
DownOutlined,
FileOutlined,
FileTextOutlined,
PlusOutlined,
SearchOutlined,
} from '@ant-design/icons';
import { Button, Dropdown, Flex, Input, MenuProps, Space } from 'antd';
import { useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import { RunningStatus } from './constant';
import styles from './index.less';
interface IProps {
selectedRowKeys: string[];
showCreateModal(): void;
showWebCrawlModal(): void;
showDocumentUploadModal(): void;
searchString: string;
handleInputChange: React.ChangeEventHandler<HTMLInputElement>;
documents: IDocumentInfo[];
}
const DocumentToolbar = ({
searchString,
selectedRowKeys,
showCreateModal,
showDocumentUploadModal,
handleInputChange,
documents,
}: IProps) => {
const { t } = useTranslate('knowledgeDetails');
const { removeDocument } = useRemoveNextDocument();
const showDeleteConfirm = useShowDeleteConfirm();
const { runDocumentByIds } = useRunNextDocument();
const { setDocumentStatus } = useSetNextDocumentStatus();
const actionItems: MenuProps['items'] = useMemo(() => {
return [
{
key: '1',
onClick: showDocumentUploadModal,
label: (
<div>
<Button type="link">
<Space>
<FileTextOutlined />
{t('localFiles')}
</Space>
</Button>
</div>
),
},
{ type: 'divider' },
{
key: '3',
onClick: showCreateModal,
label: (
<div>
<Button type="link">
<FileOutlined />
{t('emptyFiles')}
</Button>
</div>
),
},
];
}, [showDocumentUploadModal, showCreateModal, t]);
const handleDelete = useCallback(() => {
const deletedKeys = selectedRowKeys.filter(
(x) =>
!documents
.filter((y) => y.run === RunningStatus.RUNNING)
.some((y) => y.id === x),
);
if (deletedKeys.length === 0) {
toast.error(t('theDocumentBeingParsedCannotBeDeleted'));
return;
}
showDeleteConfirm({
onOk: () => {
removeDocument(deletedKeys);
},
});
}, [selectedRowKeys, showDeleteConfirm, documents, t, removeDocument]);
const runDocument = useCallback(
(run: number) => {
runDocumentByIds({
documentIds: selectedRowKeys,
run,
shouldDelete: false,
});
},
[runDocumentByIds, selectedRowKeys],
);
const handleRunClick = useCallback(() => {
runDocument(1);
}, [runDocument]);
const handleCancelClick = useCallback(() => {
runDocument(2);
}, [runDocument]);
const onChangeStatus = useCallback(
(enabled: boolean) => {
selectedRowKeys.forEach((id) => {
setDocumentStatus({ status: enabled, documentId: id });
});
},
[selectedRowKeys, setDocumentStatus],
);
const handleEnableClick = useCallback(() => {
onChangeStatus(true);
}, [onChangeStatus]);
const handleDisableClick = useCallback(() => {
onChangeStatus(false);
}, [onChangeStatus]);
const disabled = selectedRowKeys.length === 0;
const items: MenuProps['items'] = useMemo(() => {
return [
{
key: '0',
onClick: handleEnableClick,
label: (
<Flex gap={10}>
<EnableIcon></EnableIcon>
<b>{t('enabled')}</b>
</Flex>
),
},
{
key: '1',
onClick: handleDisableClick,
label: (
<Flex gap={10}>
<DisableIcon></DisableIcon>
<b>{t('disabled')}</b>
</Flex>
),
},
{ type: 'divider' },
{
key: '2',
onClick: handleRunClick,
label: (
<Flex gap={10}>
<RunIcon></RunIcon>
<b>{t('run')}</b>
</Flex>
),
},
{
key: '3',
onClick: handleCancelClick,
label: (
<Flex gap={10}>
<CancelIcon />
<b>{t('cancel')}</b>
</Flex>
),
},
{ type: 'divider' },
{
key: '4',
onClick: handleDelete,
label: (
<Flex gap={10}>
<span className={styles.deleteIconWrapper}>
<DeleteIcon width={18} />
</span>
<b>{t('delete', { keyPrefix: 'common' })}</b>
</Flex>
),
},
];
}, [
handleDelete,
handleRunClick,
handleCancelClick,
t,
handleDisableClick,
handleEnableClick,
]);
return (
<div className={styles.filter}>
<Dropdown
menu={{ items }}
placement="bottom"
arrow={false}
disabled={disabled}
>
<Button>
<Space>
<b> {t('bulk')}</b>
<DownOutlined />
</Space>
</Button>
</Dropdown>
<Space>
<Input
placeholder={t('searchFiles')}
value={searchString}
style={{ width: 220 }}
allowClear
onChange={handleInputChange}
prefix={<SearchOutlined />}
/>
<Dropdown menu={{ items: actionItems }} trigger={['click']}>
<Button type="primary" icon={<PlusOutlined />}>
{t('addFile')}
</Button>
</Dropdown>
</Space>
</div>
);
};
export default DocumentToolbar;

View File

@@ -0,0 +1,364 @@
import { useSetModalState } from '@/hooks/common-hooks';
import {
useCreateNextDocument,
useNextWebCrawl,
useRunNextDocument,
useSaveNextDocumentName,
useSetDocumentMeta,
useSetNextDocumentParser,
useUploadNextDocument,
} from '@/hooks/document-hooks';
import { useGetKnowledgeSearchParams } from '@/hooks/route-hook';
import { IDocumentInfo } from '@/interfaces/database/document';
import { IChangeParserConfigRequestBody } from '@/interfaces/request/document';
import { UploadFile } from 'antd';
import { TableRowSelection } from 'antd/es/table/interface';
import { useCallback, useState } from 'react';
import { useNavigate } from 'umi';
import { KnowledgeRouteKey } from './constant';
export const useNavigateToOtherPage = () => {
const navigate = useNavigate();
const { knowledgeId } = useGetKnowledgeSearchParams();
const linkToUploadPage = useCallback(() => {
navigate(`/knowledge/dataset/upload?id=${knowledgeId}`);
}, [navigate, knowledgeId]);
const toChunk = useCallback(
(id: string) => {
navigate(
`/knowledge/${KnowledgeRouteKey.Dataset}/chunk?id=${knowledgeId}&doc_id=${id}`,
);
},
[navigate, knowledgeId],
);
return { linkToUploadPage, toChunk };
};
export const useRenameDocument = (documentId: string) => {
const { saveName, loading } = useSaveNextDocumentName();
const {
visible: renameVisible,
hideModal: hideRenameModal,
showModal: showRenameModal,
} = useSetModalState();
const onRenameOk = useCallback(
async (name: string) => {
const ret = await saveName({ documentId, name });
if (ret === 0) {
hideRenameModal();
}
},
[hideRenameModal, saveName, documentId],
);
return {
renameLoading: loading,
onRenameOk,
renameVisible,
hideRenameModal,
showRenameModal,
};
};
export const useCreateEmptyDocument = () => {
const { createDocument, loading } = useCreateNextDocument();
const {
visible: createVisible,
hideModal: hideCreateModal,
showModal: showCreateModal,
} = useSetModalState();
const onCreateOk = useCallback(
async (name: string) => {
const ret = await createDocument(name);
if (ret === 0) {
hideCreateModal();
}
},
[hideCreateModal, createDocument],
);
return {
createLoading: loading,
onCreateOk,
createVisible,
hideCreateModal,
showCreateModal,
};
};
export const useChangeDocumentParser = (documentId: string) => {
const { setDocumentParser, loading } = useSetNextDocumentParser();
const {
visible: changeParserVisible,
hideModal: hideChangeParserModal,
showModal: showChangeParserModal,
} = useSetModalState();
const onChangeParserOk = useCallback(
async (parserId: string, parserConfig: IChangeParserConfigRequestBody) => {
const ret = await setDocumentParser({
parserId,
documentId,
parserConfig,
});
if (ret === 0) {
hideChangeParserModal();
}
},
[hideChangeParserModal, setDocumentParser, documentId],
);
return {
changeParserLoading: loading,
onChangeParserOk,
changeParserVisible,
hideChangeParserModal,
showChangeParserModal,
};
};
export const useGetRowSelection = () => {
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const rowSelection: TableRowSelection<IDocumentInfo> = {
selectedRowKeys,
onChange: (newSelectedRowKeys: React.Key[]) => {
setSelectedRowKeys(newSelectedRowKeys);
},
};
return rowSelection;
};
export const useHandleUploadDocument = () => {
const {
visible: documentUploadVisible,
hideModal: hideDocumentUploadModal,
showModal: showDocumentUploadModal,
} = useSetModalState();
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [uploadProgress, setUploadProgress] = useState<number>(0);
const { uploadDocument, loading } = useUploadNextDocument();
const { runDocumentByIds } = useRunNextDocument();
const onDocumentUploadOk = useCallback(
async ({
parseOnCreation,
directoryFileList,
}: {
directoryFileList: UploadFile[];
parseOnCreation: boolean;
}): Promise<number | undefined> => {
const processFileGroup = async (filesPart: UploadFile[]) => {
// set status to uploading on files
setFileList(
fileList.map((file) => {
if (!filesPart.includes(file)) {
return file;
}
let newFile = file;
newFile.status = 'uploading';
newFile.percent = 1;
return newFile;
}),
);
const ret = await uploadDocument(filesPart);
const files = ret?.data || [];
const successfulFilenames = files.map((file: any) => file.name);
// set status to done or error on files (based on response)
setFileList(
fileList.map((file) => {
if (!filesPart.includes(file)) {
return file;
}
let newFile = file;
newFile.status = successfulFilenames.includes(file.name)
? 'done'
: 'error';
newFile.percent = 100;
newFile.response = ret.message;
return newFile;
}),
);
return {
code: ret?.code,
fileIds: files.map((file: any) => file.id),
totalSuccess: successfulFilenames.length,
};
};
const totalFiles = fileList.length;
if (directoryFileList.length > 0) {
const ret = await uploadDocument(directoryFileList);
if (ret?.code === 0) {
hideDocumentUploadModal();
}
if (totalFiles === 0) {
return 0;
}
}
if (totalFiles === 0) {
console.log('No files to upload');
hideDocumentUploadModal();
return 0;
}
let totalSuccess = 0;
let codes = [];
let toRunFileIds: any[] = [];
for (let i = 0; i < totalFiles; i += 10) {
setUploadProgress(Math.floor((i / totalFiles) * 100));
const files = fileList.slice(i, i + 10);
const {
code,
totalSuccess: count,
fileIds,
} = await processFileGroup(files);
codes.push(code);
totalSuccess += count;
toRunFileIds = toRunFileIds.concat(fileIds);
}
const allSuccess = codes.every((code) => code === 0);
const any500 = codes.some((code) => code === 500);
let code = 500;
if (allSuccess || (any500 && totalSuccess === totalFiles)) {
code = 0;
hideDocumentUploadModal();
}
if (parseOnCreation) {
await runDocumentByIds({
documentIds: toRunFileIds,
run: 1,
shouldDelete: false,
});
}
setUploadProgress(100);
return code;
},
[fileList, uploadDocument, hideDocumentUploadModal, runDocumentByIds],
);
return {
documentUploadLoading: loading,
onDocumentUploadOk,
documentUploadVisible,
hideDocumentUploadModal,
showDocumentUploadModal,
uploadFileList: fileList,
setUploadFileList: setFileList,
uploadProgress,
setUploadProgress,
};
};
export const useHandleWebCrawl = () => {
const {
visible: webCrawlUploadVisible,
hideModal: hideWebCrawlUploadModal,
showModal: showWebCrawlUploadModal,
} = useSetModalState();
const { webCrawl, loading } = useNextWebCrawl();
const onWebCrawlUploadOk = useCallback(
async (name: string, url: string) => {
const ret = await webCrawl({ name, url });
if (ret === 0) {
hideWebCrawlUploadModal();
return 0;
}
return -1;
},
[webCrawl, hideWebCrawlUploadModal],
);
return {
webCrawlUploadLoading: loading,
onWebCrawlUploadOk,
webCrawlUploadVisible,
hideWebCrawlUploadModal,
showWebCrawlUploadModal,
};
};
export const useHandleRunDocumentByIds = (id: string) => {
const { runDocumentByIds, loading } = useRunNextDocument();
const [currentId, setCurrentId] = useState<string>('');
const isLoading = loading && currentId !== '' && currentId === id;
const handleRunDocumentByIds = async (
documentId: string,
isRunning: boolean,
shouldDelete: boolean = false,
) => {
if (isLoading) {
return;
}
setCurrentId(documentId);
try {
await runDocumentByIds({
documentIds: [documentId],
run: isRunning ? 2 : 1,
shouldDelete,
});
setCurrentId('');
} catch (error) {
setCurrentId('');
}
};
return {
handleRunDocumentByIds,
loading: isLoading,
};
};
export const useShowMetaModal = (documentId: string) => {
const { setDocumentMeta, loading } = useSetDocumentMeta();
const {
visible: setMetaVisible,
hideModal: hideSetMetaModal,
showModal: showSetMetaModal,
} = useSetModalState();
const onSetMetaModalOk = useCallback(
async (meta: string) => {
const ret = await setDocumentMeta({
documentId,
meta,
});
if (ret === 0) {
hideSetMetaModal();
}
},
[setDocumentMeta, documentId, hideSetMetaModal],
);
return {
setMetaLoading: loading,
onSetMetaModalOk,
setMetaVisible,
hideSetMetaModal,
showSetMetaModal,
};
};

View File

@@ -0,0 +1,54 @@
.datasetWrapper {
padding: 30px 30px 0;
height: 100%;
}
.documentTable {
tbody {
// height: calc(100vh - 508px);
}
}
.filter {
height: 32px;
display: flex;
margin: 10px 0;
justify-content: space-between;
padding: 24px 0;
align-items: center;
}
.deleteIconWrapper {
width: 22px;
text-align: center;
}
.img {
height: 24px;
width: 24px;
display: inline-block;
vertical-align: middle;
}
.column {
min-width: 200px;
}
.toChunks {
cursor: pointer;
}
.pageInputNumber {
width: 220px;
}
.questionIcon {
margin-inline-start: 4px;
color: rgba(0, 0, 0, 0.45);
cursor: help;
writing-mode: horizontal-tb;
}
.nameText {
color: #1677ff;
}

View File

@@ -0,0 +1,275 @@
import ChunkMethodModal from '@/components/chunk-method-modal';
import SvgIcon from '@/components/svg-icon';
import {
useFetchNextDocumentList,
useSetNextDocumentStatus,
} from '@/hooks/document-hooks';
import { useSetSelectedRecord } from '@/hooks/logic-hooks';
import { useSelectParserList } from '@/hooks/user-setting-hooks';
import { getExtension } from '@/utils/document-util';
import { Divider, Flex, Switch, Table, Tooltip, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useTranslation } from 'react-i18next';
import CreateFileModal from './create-file-modal';
import DocumentToolbar from './document-toolbar';
import {
useChangeDocumentParser,
useCreateEmptyDocument,
useGetRowSelection,
useHandleUploadDocument,
useHandleWebCrawl,
useNavigateToOtherPage,
useRenameDocument,
useShowMetaModal,
} from './hooks';
import ParsingActionCell from './parsing-action-cell';
import ParsingStatusCell from './parsing-status-cell';
import RenameModal from './rename-modal';
import WebCrawlModal from './web-crawl-modal';
import FileUploadModal from '@/components/file-upload-modal';
import { RunningStatus } from '@/constants/knowledge';
import { IDocumentInfo } from '@/interfaces/database/document';
import { formatDate } from '@/utils/date';
import { CircleHelp } from 'lucide-react';
import styles from './index.less';
import { SetMetaModal } from './set-meta-modal';
const { Text } = Typography;
const KnowledgeFile = () => {
const { searchString, documents, pagination, handleInputChange } =
useFetchNextDocumentList();
const parserList = useSelectParserList();
const { setDocumentStatus } = useSetNextDocumentStatus();
const { toChunk } = useNavigateToOtherPage();
const { currentRecord, setRecord } = useSetSelectedRecord<IDocumentInfo>();
const {
renameLoading,
onRenameOk,
renameVisible,
hideRenameModal,
showRenameModal,
} = useRenameDocument(currentRecord.id);
const {
createLoading,
onCreateOk,
createVisible,
hideCreateModal,
showCreateModal,
} = useCreateEmptyDocument();
const {
changeParserLoading,
onChangeParserOk,
changeParserVisible,
hideChangeParserModal,
showChangeParserModal,
} = useChangeDocumentParser(currentRecord.id);
const {
documentUploadVisible,
hideDocumentUploadModal,
showDocumentUploadModal,
onDocumentUploadOk,
documentUploadLoading,
uploadFileList,
setUploadFileList,
uploadProgress,
setUploadProgress,
} = useHandleUploadDocument();
const {
webCrawlUploadVisible,
hideWebCrawlUploadModal,
showWebCrawlUploadModal,
onWebCrawlUploadOk,
webCrawlUploadLoading,
} = useHandleWebCrawl();
const { t } = useTranslation('translation', {
keyPrefix: 'knowledgeDetails',
});
const {
showSetMetaModal,
hideSetMetaModal,
setMetaVisible,
setMetaLoading,
onSetMetaModalOk,
} = useShowMetaModal(currentRecord.id);
const rowSelection = useGetRowSelection();
const columns: ColumnsType<IDocumentInfo> = [
{
title: t('name'),
dataIndex: 'name',
key: 'name',
fixed: 'left',
render: (text: any, { id, thumbnail, name }) => (
<div className={styles.toChunks} onClick={() => toChunk(id)}>
<Flex gap={10} align="center">
{thumbnail ? (
<img className={styles.img} src={thumbnail} alt="" />
) : (
<SvgIcon
name={`file-icon/${getExtension(name)}`}
width={24}
></SvgIcon>
)}
<Text ellipsis={{ tooltip: text }} className={styles.nameText}>
{text}
</Text>
</Flex>
</div>
),
},
{
title: t('chunkNumber'),
dataIndex: 'chunk_num',
key: 'chunk_num',
},
{
title: t('uploadDate'),
dataIndex: 'create_time',
key: 'create_time',
render(value) {
return formatDate(value);
},
},
{
title: t('chunkMethod'),
dataIndex: 'parser_id',
key: 'parser_id',
render: (text) => {
return parserList.find((x) => x.value === text)?.label;
},
},
{
title: t('enabled'),
key: 'status',
dataIndex: 'status',
render: (_, { status, id }) => (
<>
<Switch
checked={status === '1'}
onChange={(e) => {
setDocumentStatus({ status: e, documentId: id });
}}
/>
</>
),
},
{
title: (
<span className="flex items-center gap-2">
{t('parsingStatus')}
<Tooltip title={t('parsingStatusTip')}>
<CircleHelp className="size-3" />
</Tooltip>
</span>
),
dataIndex: 'run',
key: 'run',
filters: Object.values(RunningStatus).map((value) => ({
text: t(`runningStatus${value}`),
value: value,
})),
onFilter: (value, record: IDocumentInfo) => record.run === value,
render: (text, record) => {
return <ParsingStatusCell record={record}></ParsingStatusCell>;
},
},
{
title: t('action'),
key: 'action',
render: (_, record) => (
<ParsingActionCell
setCurrentRecord={setRecord}
showRenameModal={showRenameModal}
showChangeParserModal={showChangeParserModal}
showSetMetaModal={showSetMetaModal}
record={record}
></ParsingActionCell>
),
},
];
const finalColumns = columns.map((x) => ({
...x,
className: `${styles.column}`,
}));
return (
<div className={styles.datasetWrapper}>
<h3>{t('dataset')}</h3>
<p>{t('datasetDescription')}</p>
<Divider></Divider>
<DocumentToolbar
selectedRowKeys={rowSelection.selectedRowKeys as string[]}
showCreateModal={showCreateModal}
showWebCrawlModal={showWebCrawlUploadModal}
showDocumentUploadModal={showDocumentUploadModal}
searchString={searchString}
handleInputChange={handleInputChange}
documents={documents}
></DocumentToolbar>
<Table
rowKey="id"
columns={finalColumns}
dataSource={documents}
pagination={pagination}
rowSelection={rowSelection}
className={styles.documentTable}
scroll={{ scrollToFirstRowOnChange: true, x: 1300 }}
/>
<CreateFileModal
visible={createVisible}
hideModal={hideCreateModal}
loading={createLoading}
onOk={onCreateOk}
/>
<ChunkMethodModal
documentId={currentRecord.id}
parserId={currentRecord.parser_id}
parserConfig={currentRecord.parser_config}
documentExtension={getExtension(currentRecord.name)}
onOk={onChangeParserOk}
visible={changeParserVisible}
hideModal={hideChangeParserModal}
loading={changeParserLoading}
/>
<RenameModal
visible={renameVisible}
onOk={onRenameOk}
loading={renameLoading}
hideModal={hideRenameModal}
initialName={currentRecord.name}
></RenameModal>
<FileUploadModal
visible={documentUploadVisible}
hideModal={hideDocumentUploadModal}
loading={documentUploadLoading}
onOk={onDocumentUploadOk}
uploadFileList={uploadFileList}
setUploadFileList={setUploadFileList}
uploadProgress={uploadProgress}
setUploadProgress={setUploadProgress}
></FileUploadModal>
<WebCrawlModal
visible={webCrawlUploadVisible}
hideModal={hideWebCrawlUploadModal}
loading={webCrawlUploadLoading}
onOk={onWebCrawlUploadOk}
></WebCrawlModal>
{setMetaVisible && (
<SetMetaModal
visible={setMetaVisible}
hideModal={hideSetMetaModal}
onOk={onSetMetaModalOk}
loading={setMetaLoading}
initialMetaData={currentRecord.meta_fields}
></SetMetaModal>
)}
</div>
);
};
export default KnowledgeFile;

View File

@@ -0,0 +1,3 @@
.iconButton {
padding: 4px 8px;
}

View File

@@ -0,0 +1,149 @@
import { useShowDeleteConfirm, useTranslate } from '@/hooks/common-hooks';
import { useRemoveNextDocument } from '@/hooks/document-hooks';
import { IDocumentInfo } from '@/interfaces/database/document';
import { downloadDocument } from '@/utils/file-util';
import {
DeleteOutlined,
DownloadOutlined,
EditOutlined,
ToolOutlined,
} from '@ant-design/icons';
import { Button, Dropdown, MenuProps, Space, Tooltip } from 'antd';
import { isParserRunning } from '../utils';
import { useCallback } from 'react';
import { DocumentType } from '../constant';
import styles from './index.less';
interface IProps {
record: IDocumentInfo;
setCurrentRecord: (record: IDocumentInfo) => void;
showRenameModal: () => void;
showChangeParserModal: () => void;
showSetMetaModal: () => void;
}
const ParsingActionCell = ({
record,
setCurrentRecord,
showRenameModal,
showChangeParserModal,
showSetMetaModal,
}: IProps) => {
const documentId = record.id;
const isRunning = isParserRunning(record.run);
const { t } = useTranslate('knowledgeDetails');
const { removeDocument } = useRemoveNextDocument();
const showDeleteConfirm = useShowDeleteConfirm();
const isVirtualDocument = record.type === DocumentType.Virtual;
const onRmDocument = () => {
if (!isRunning) {
showDeleteConfirm({
onOk: () => removeDocument([documentId]),
content: record?.parser_config?.graphrag?.use_graphrag
? t('deleteDocumentConfirmContent')
: '',
});
}
};
const onDownloadDocument = () => {
downloadDocument({
id: documentId,
filename: record.name,
});
};
const setRecord = useCallback(() => {
setCurrentRecord(record);
}, [record, setCurrentRecord]);
const onShowRenameModal = () => {
setRecord();
showRenameModal();
};
const onShowChangeParserModal = () => {
setRecord();
showChangeParserModal();
};
const onShowSetMetaModal = useCallback(() => {
setRecord();
showSetMetaModal();
}, [setRecord, showSetMetaModal]);
const chunkItems: MenuProps['items'] = [
{
key: '1',
label: (
<div className="flex flex-col">
<Button type="link" onClick={onShowChangeParserModal}>
{t('chunkMethod')}
</Button>
</div>
),
},
{ type: 'divider' },
{
key: '2',
label: (
<div className="flex flex-col">
<Button type="link" onClick={onShowSetMetaModal}>
{t('setMetaData')}
</Button>
</div>
),
},
];
return (
<Space size={0}>
{isVirtualDocument || (
<Dropdown
menu={{ items: chunkItems }}
trigger={['click']}
disabled={isRunning || record.parser_id === 'tag'}
>
<Button type="text" className={styles.iconButton}>
<ToolOutlined size={20} />
</Button>
</Dropdown>
)}
<Tooltip title={t('rename', { keyPrefix: 'common' })}>
<Button
type="text"
disabled={isRunning}
onClick={onShowRenameModal}
className={styles.iconButton}
>
<EditOutlined size={20} />
</Button>
</Tooltip>
<Tooltip title={t('delete', { keyPrefix: 'common' })}>
<Button
type="text"
disabled={isRunning}
onClick={onRmDocument}
className={styles.iconButton}
>
<DeleteOutlined size={20} />
</Button>
</Tooltip>
{isVirtualDocument || (
<Tooltip title={t('download', { keyPrefix: 'common' })}>
<Button
type="text"
disabled={isRunning}
onClick={onDownloadDocument}
className={styles.iconButton}
>
<DownloadOutlined size={20} />
</Button>
</Tooltip>
)}
</Space>
);
};
export default ParsingActionCell;

View File

@@ -0,0 +1,36 @@
.popoverContent {
width: 40vw;
.popoverContentItem {
display: flex;
gap: 10px;
}
.popoverContentText {
white-space: pre-line;
max-height: 50vh;
overflow: auto;
.popoverContentErrorLabel {
color: red;
}
}
}
.operationIcon {
text-align: center;
display: flex;
&:hover {
cursor: pointer;
}
}
.operationIconSpin {
animation: spin 1s linear infinite;
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}

View File

@@ -0,0 +1,143 @@
import { ReactComponent as CancelIcon } from '@/assets/svg/cancel.svg';
import { ReactComponent as RefreshIcon } from '@/assets/svg/refresh.svg';
import { ReactComponent as RunIcon } from '@/assets/svg/run.svg';
import { useTranslate } from '@/hooks/common-hooks';
import { IDocumentInfo } from '@/interfaces/database/document';
import {
Badge,
DescriptionsProps,
Flex,
Popconfirm,
Popover,
Space,
Tag,
} from 'antd';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import reactStringReplace from 'react-string-replace';
import { DocumentType, RunningStatus, RunningStatusMap } from '../constant';
import { useHandleRunDocumentByIds } from '../hooks';
import { isParserRunning } from '../utils';
import styles from './index.less';
const iconMap = {
[RunningStatus.UNSTART]: RunIcon,
[RunningStatus.RUNNING]: CancelIcon,
[RunningStatus.CANCEL]: RefreshIcon,
[RunningStatus.DONE]: RefreshIcon,
[RunningStatus.FAIL]: RefreshIcon,
};
interface IProps {
record: IDocumentInfo;
}
const PopoverContent = ({ record }: IProps) => {
const { t } = useTranslate('knowledgeDetails');
const replaceText = (text: string) => {
// Remove duplicate \n
const nextText = text.replace(/(\n)\1+/g, '$1');
const replacedText = reactStringReplace(
nextText,
/(\[ERROR\].+\s)/g,
(match, i) => {
return (
<span key={i} className={styles.popoverContentErrorLabel}>
{match}
</span>
);
},
);
return replacedText;
};
const items: DescriptionsProps['items'] = [
{
key: 'process_begin_at',
label: t('processBeginAt'),
children: record.process_begin_at,
},
{
key: 'process_duration',
label: t('processDuration'),
children: `${record.process_duration.toFixed(2)} s`,
},
{
key: 'progress_msg',
label: t('progressMsg'),
children: replaceText(record.progress_msg.trim()),
},
];
return (
<Flex vertical className={styles.popoverContent}>
{items.map((x, idx) => {
return (
<div key={x.key} className={idx < 2 ? styles.popoverContentItem : ''}>
<b>{x.label}:</b>
<div className={styles.popoverContentText}>{x.children}</div>
</div>
);
})}
</Flex>
);
};
export const ParsingStatusCell = ({ record }: IProps) => {
const text = record.run;
const runningStatus = RunningStatusMap[text];
const { t } = useTranslation();
const { handleRunDocumentByIds } = useHandleRunDocumentByIds(record.id);
const isRunning = isParserRunning(text);
const OperationIcon = iconMap[text];
const label = t(`knowledgeDetails.runningStatus${text}`);
const handleOperationIconClick =
(shouldDelete: boolean = false) =>
() => {
handleRunDocumentByIds(record.id, isRunning, shouldDelete);
};
return record.type === DocumentType.Virtual ? null : (
<Flex justify={'space-between'} align="center">
<Popover content={<PopoverContent record={record}></PopoverContent>}>
<Tag color={runningStatus.color}>
{isRunning ? (
<Space>
<Badge color={runningStatus.color} />
{label}
<span>{(record.progress * 100).toFixed(2)}%</span>
</Space>
) : (
label
)}
</Tag>
</Popover>
<Popconfirm
title={t(`knowledgeDetails.redo`, { chunkNum: record.chunk_num })}
onConfirm={handleOperationIconClick(true)}
onCancel={handleOperationIconClick(false)}
disabled={record.chunk_num === 0}
okText={t('common.yes')}
cancelText={t('common.no')}
>
<div
className={classNames(styles.operationIcon)}
onClick={
record.chunk_num === 0 ? handleOperationIconClick(false) : () => {}
}
>
<OperationIcon />
</div>
</Popconfirm>
</Flex>
);
};
export default ParsingStatusCell;

View File

@@ -0,0 +1,75 @@
import { IModalManagerChildrenProps } from '@/components/modal-manager';
import { useTranslate } from '@/hooks/common-hooks';
import { Form, Input, Modal } from 'antd';
import { useEffect } from 'react';
interface IProps extends Omit<IModalManagerChildrenProps, 'showModal'> {
loading: boolean;
initialName: string;
onOk: (name: string) => void;
showModal?(): void;
}
const RenameModal = ({
visible,
onOk,
loading,
initialName,
hideModal,
}: IProps) => {
const [form] = Form.useForm();
const { t } = useTranslate('common');
type FieldType = {
name?: string;
};
const handleOk = async () => {
const ret = await form.validateFields();
onOk(ret.name);
};
const onFinish = (values: any) => {
console.log('Success:', values);
};
const onFinishFailed = (errorInfo: any) => {
console.log('Failed:', errorInfo);
};
useEffect(() => {
if (visible) {
form.setFieldValue('name', initialName);
}
}, [initialName, form, visible]);
return (
<Modal
title={t('rename')}
open={visible}
onOk={handleOk}
onCancel={hideModal}
okButtonProps={{ loading }}
>
<Form
name="basic"
labelCol={{ span: 4 }}
wrapperCol={{ span: 20 }}
style={{ maxWidth: 600 }}
onFinish={onFinish}
onFinishFailed={onFinishFailed}
autoComplete="off"
form={form}
>
<Form.Item<FieldType>
label={t('name')}
name="name"
rules={[{ required: true, message: t('namePlaceholder') }]}
>
<Input />
</Form.Item>
</Form>
</Modal>
);
};
export default RenameModal;

View File

@@ -0,0 +1,81 @@
import { IModalProps } from '@/interfaces/common';
import { IDocumentInfo } from '@/interfaces/database/document';
import Editor, { loader } from '@monaco-editor/react';
import { Form, Modal } from 'antd';
import DOMPurify from 'dompurify';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
loader.config({ paths: { vs: '/vs' } });
type FieldType = {
meta?: string;
};
export function SetMetaModal({
visible,
hideModal,
onOk,
initialMetaData,
}: IModalProps<any> & { initialMetaData?: IDocumentInfo['meta_fields'] }) {
const { t } = useTranslation();
const [form] = Form.useForm();
const handleOk = useCallback(async () => {
const values = await form.validateFields();
onOk?.(values.meta);
}, [form, onOk]);
useEffect(() => {
form.setFieldValue('meta', JSON.stringify(initialMetaData, null, 4));
}, [form, initialMetaData]);
return (
<Modal
title={t('knowledgeDetails.setMetaData')}
open={visible}
onOk={handleOk}
onCancel={hideModal}
>
<Form
name="basic"
initialValues={{ remember: true }}
autoComplete="off"
layout={'vertical'}
form={form}
>
<Form.Item<FieldType>
label={t('knowledgeDetails.metaData')}
name="meta"
rules={[
{
required: true,
validator(rule, value) {
try {
JSON.parse(value);
return Promise.resolve();
} catch (error) {
return Promise.reject(
new Error(t('knowledgeDetails.pleaseInputJson')),
);
}
},
},
]}
tooltip={
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(
t('knowledgeDetails.documentMetaTips'),
),
}}
></div>
}
>
<Editor height={200} defaultLanguage="json" theme="vs-dark" />
</Form.Item>
</Form>
</Modal>
);
}

View File

@@ -0,0 +1,6 @@
import { RunningStatus } from './constant';
export const isParserRunning = (text: RunningStatus) => {
const isRunning = text === RunningStatus.RUNNING;
return isRunning;
};

View File

@@ -0,0 +1,67 @@
import { IModalManagerChildrenProps } from '@/components/modal-manager';
import { useTranslate } from '@/hooks/common-hooks';
import { Form, Input, Modal } from 'antd';
import React from 'react';
interface IProps extends Omit<IModalManagerChildrenProps, 'showModal'> {
loading: boolean;
onOk: (name: string, url: string) => void;
showModal?(): void;
}
const WebCrawlModal: React.FC<IProps> = ({ visible, hideModal, onOk }) => {
const [form] = Form.useForm();
const { t } = useTranslate('knowledgeDetails');
const handleOk = async () => {
const values = await form.validateFields();
onOk(values.name, values.url);
};
return (
<Modal
title={t('webCrawl')}
open={visible}
onOk={handleOk}
onCancel={hideModal}
>
<Form
form={form}
name="validateOnly"
labelCol={{ span: 4 }}
wrapperCol={{ span: 20 }}
style={{ maxWidth: 600 }}
autoComplete="off"
>
<Form.Item
label="Name"
name="name"
rules={[
{ required: true, message: 'Please input name!' },
{
max: 10,
message: 'The maximum length of name is 128 characters',
},
]}
>
<Input placeholder="Document name" />
</Form.Item>
<Form.Item
label="URL"
name="url"
rules={[
{ required: true, message: 'Please input url!' },
{
pattern: new RegExp(
'(https?|ftp|file)://[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]',
),
message: 'Please enter a valid URL!',
},
]}
>
<Input placeholder="https://www.baidu.com" />
</Form.Item>
</Form>
</Modal>
);
};
export default WebCrawlModal;

View File

@@ -0,0 +1,241 @@
const nodes = [
{
type: '"ORGANIZATION"',
description:
'"厦门象屿是一家公司其营业收入和市场占有率在2018年至2022年间有所变化。"',
source_id: '0',
id: '"厦门象屿"',
},
{
type: '"EVENT"',
description:
'"2018年是一个时间点标志着厦门象屿营业收入和市场占有率的记录开始。"',
source_id: '0',
entity_type: '"EVENT"',
id: '"2018"',
},
{
type: '"EVENT"',
description:
'"2019年是一个时间点厦门象屿的营业收入和市场占有率在此期间有所变化。"',
source_id: '0',
entity_type: '"EVENT"',
id: '"2019"',
},
{
type: '"EVENT"',
description:
'"2020年是一个时间点厦门象屿的营业收入和市场占有率在此期间有所变化。"',
source_id: '0',
entity_type: '"EVENT"',
id: '"2020"',
},
{
type: '"EVENT"',
description:
'"2021年是一个时间点厦门象屿的营业收入和市场占有率在此期间有所变化。"',
source_id: '0',
entity_type: '"EVENT"',
id: '"2021"',
},
{
type: '"EVENT"',
description:
'"2022年是一个时间点厦门象屿的营业收入和市场占有率在此期间有所变化。"',
source_id: '0',
entity_type: '"EVENT"',
id: '"2022"',
},
{
type: '"ORGANIZATION"',
description:
'"厦门象屿股份有限公司是一家公司中文简称为厦门象屿外文名称为Xiamen Xiangyu Co.,Ltd.外文名称缩写为Xiangyu法定代表人为邓启东。"',
source_id: '1',
id: '"厦门象屿股份有限公司"',
},
{
type: '"PERSON"',
description: '"邓启东是厦门象屿股份有限公司的法定代表人。"',
source_id: '1',
entity_type: '"PERSON"',
id: '"邓启东"',
},
{
type: '"GEO"',
description: '"厦门是一个地理位置,与厦门象屿股份有限公司相关。"',
source_id: '1',
entity_type: '"GEO"',
id: '"厦门"',
},
{
type: '"PERSON"',
description:
'"廖杰 is the Board Secretary, responsible for handling board-related matters and communications."',
source_id: '2',
id: '"廖杰"',
},
{
type: '"PERSON"',
description:
'"史经洋 is the Securities Affairs Representative, responsible for handling securities-related matters and communications."',
source_id: '2',
entity_type: '"PERSON"',
id: '"史经洋"',
},
{
type: '"GEO"',
description:
'"A geographic location in Xiamen, specifically in the Free Trade Zone, where the company\'s office is situated."',
source_id: '2',
entity_type: '"GEO"',
id: '"厦门市湖里区自由贸易试验区厦门片区"',
},
{
type: '"GEO"',
description:
'"The building where the company\'s office is located, situated at Xiangyu Road, Xiamen."',
source_id: '2',
entity_type: '"GEO"',
id: '"象屿集团大厦"',
},
{
type: '"EVENT"',
description:
'"Refers to the year 2021, used for comparing financial metrics with the year 2022."',
source_id: '3',
id: '"2021年"',
},
{
type: '"EVENT"',
description:
'"Refers to the year 2022, used for presenting current financial metrics and comparing them with the year 2021."',
source_id: '3',
entity_type: '"EVENT"',
id: '"2022年"',
},
{
type: '"EVENT"',
description:
'"Indicates the focus on key financial metrics in the table, such as weighted averages and percentages."',
source_id: '3',
entity_type: '"EVENT"',
id: '"主要财务指标"',
},
].map(({ type, ...x }) => ({ ...x }));
const edges = [
{
weight: 2.0,
description: '"厦门象屿在2018年的营业收入和市场占有率被记录。"',
source_id: '0',
source: '"厦门象屿"',
target: '"2018"',
},
{
weight: 2.0,
description: '"厦门象屿在2019年的营业收入和市场占有率有所变化。"',
source_id: '0',
source: '"厦门象屿"',
target: '"2019"',
},
{
weight: 2.0,
description: '"厦门象屿在2020年的营业收入和市场占有率有所变化。"',
source_id: '0',
source: '"厦门象屿"',
target: '"2020"',
},
{
weight: 2.0,
description: '"厦门象屿在2021年的营业收入和市场占有率有所变化。"',
source_id: '0',
source: '"厦门象屿"',
target: '"2021"',
},
{
weight: 2.0,
description: '"厦门象屿在2022年的营业收入和市场占有率有所变化。"',
source_id: '0',
source: '"厦门象屿"',
target: '"2022"',
},
{
weight: 2.0,
description: '"厦门象屿股份有限公司的法定代表人是邓启东。"',
source_id: '1',
source: '"厦门象屿股份有限公司"',
target: '"邓启东"',
},
{
weight: 2.0,
description: '"厦门象屿股份有限公司位于厦门。"',
source_id: '1',
source: '"厦门象屿股份有限公司"',
target: '"厦门"',
},
{
weight: 2.0,
description:
'"廖杰\'s office is located in the Xiangyu Group Building, indicating his workplace."',
source_id: '2',
source: '"廖杰"',
target: '"象屿集团大厦"',
},
{
weight: 2.0,
description:
'"廖杰 works in the Xiamen Free Trade Zone, a specific area within Xiamen."',
source_id: '2',
source: '"廖杰"',
target: '"厦门市湖里区自由贸易试验区厦门片区"',
},
{
weight: 2.0,
description:
'"史经洋\'s office is also located in the Xiangyu Group Building, indicating his workplace."',
source_id: '2',
source: '"史经洋"',
target: '"象屿集团大厦"',
},
{
weight: 2.0,
description:
'"史经洋 works in the Xiamen Free Trade Zone, a specific area within Xiamen."',
source_id: '2',
source: '"史经洋"',
target: '"厦门市湖里区自由贸易试验区厦门片区"',
},
{
weight: 2.0,
description:
'"The years 2021 and 2022 are related as they are used for comparing financial metrics, showing changes and adjustments over time."',
source_id: '3',
source: '"2021年"',
target: '"2022年"',
},
{
weight: 2.0,
description:
'"The \'主要财务指标\' is related to the year 2021 as it provides the basis for financial comparisons and adjustments."',
source_id: '3',
source: '"2021年"',
target: '"主要财务指标"',
},
{
weight: 2.0,
description:
'"The \'主要财务指标\' is related to the year 2022 as it presents the current financial metrics and their changes compared to 2021."',
source_id: '3',
source: '"2022年"',
target: '"主要财务指标"',
},
];
export const graphData = {
directed: false,
multigraph: false,
graph: {},
nodes,
edges,
combos: [],
};

View File

@@ -0,0 +1,141 @@
import { ElementDatum, Graph, IElementEvent } from '@antv/g6';
import isEmpty from 'lodash/isEmpty';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { buildNodesAndCombos } from './util';
import styles from './index.less';
const TooltipColorMap = {
combo: 'red',
node: 'black',
edge: 'blue',
};
interface IProps {
data: any;
show: boolean;
}
const ForceGraph = ({ data, show }: IProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const graphRef = useRef<Graph | null>(null);
const nextData = useMemo(() => {
if (!isEmpty(data)) {
const graphData = data;
const mi = buildNodesAndCombos(graphData.nodes);
return { edges: graphData.edges, ...mi };
}
return { nodes: [], edges: [] };
}, [data]);
const render = useCallback(() => {
const graph = new Graph({
container: containerRef.current!,
autoFit: 'view',
autoResize: true,
behaviors: [
'drag-element',
'drag-canvas',
'zoom-canvas',
'collapse-expand',
{
type: 'hover-activate',
degree: 1, // 👈🏻 Activate relations.
},
],
plugins: [
{
type: 'tooltip',
enterable: true,
getContent: (e: IElementEvent, items: ElementDatum) => {
if (Array.isArray(items)) {
if (items.some((x) => x?.isCombo)) {
return `<p style="font-weight:600;color:red">${items?.[0]?.data?.label}</p>`;
}
let result = ``;
items.forEach((item) => {
result += `<section style="color:${TooltipColorMap[e['targetType'] as keyof typeof TooltipColorMap]};"><h3>${item?.id}</h3>`;
if (item?.entity_type) {
result += `<div style="padding-bottom: 6px;"><b>Entity type: </b>${item?.entity_type}</div>`;
}
if (item?.weight) {
result += `<div><b>Weight: </b>${item?.weight}</div>`;
}
if (item?.description) {
result += `<p>${item?.description}</p>`;
}
});
return result + '</section>';
}
return undefined;
},
},
],
layout: {
type: 'combo-combined',
preventOverlap: true,
comboPadding: 1,
spacing: 100,
},
node: {
style: {
size: 150,
labelText: (d) => d.id,
// labelPadding: 30,
labelFontSize: 40,
// labelOffsetX: 20,
labelOffsetY: 20,
labelPlacement: 'center',
labelWordWrap: true,
},
palette: {
type: 'group',
field: (d) => {
return d?.entity_type as string;
},
},
},
edge: {
style: (model) => {
const weight: number = Number(model?.weight) || 2;
const lineWeight = weight * 4;
return {
stroke: '#99ADD1',
lineWidth: lineWeight > 10 ? 10 : lineWeight,
};
},
},
});
if (graphRef.current) {
graphRef.current.destroy();
}
graphRef.current = graph;
graph.setData(nextData);
graph.render();
}, [nextData]);
useEffect(() => {
if (!isEmpty(data)) {
render();
}
}, [data, render]);
return (
<div
ref={containerRef}
className={styles.forceContainer}
style={{
width: '100%',
height: '100%',
display: show ? 'block' : 'none',
}}
/>
);
};
export default ForceGraph;

View File

@@ -0,0 +1,5 @@
.forceContainer {
:global(.tooltip) {
border-radius: 10px !important;
}
}

View File

@@ -0,0 +1,31 @@
import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog';
import { Button } from '@/components/ui/button';
import { useFetchKnowledgeGraph } from '@/hooks/knowledge-hooks';
import { Trash2 } from 'lucide-react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import ForceGraph from './force-graph';
import { useDeleteKnowledgeGraph } from './use-delete-graph';
const KnowledgeGraph: React.FC = () => {
const { data } = useFetchKnowledgeGraph();
const { t } = useTranslation();
const { handleDeleteKnowledgeGraph } = useDeleteKnowledgeGraph();
return (
<section className={'w-full h-full relative'}>
<ConfirmDeleteDialog onOk={handleDeleteKnowledgeGraph}>
<Button
variant="outline"
size={'sm'}
className="absolute right-0 top-0 z-50"
>
<Trash2 /> {t('common.delete')}
</Button>
</ConfirmDeleteDialog>
<ForceGraph data={data?.graph} show></ForceGraph>
</section>
);
};
export default KnowledgeGraph;

View File

@@ -0,0 +1,21 @@
import {
useKnowledgeBaseId,
useRemoveKnowledgeGraph,
} from '@/hooks/knowledge-hooks';
import { useCallback } from 'react';
import { useNavigate } from 'umi';
export function useDeleteKnowledgeGraph() {
const { removeKnowledgeGraph, loading } = useRemoveKnowledgeGraph();
const navigate = useNavigate();
const knowledgeBaseId = useKnowledgeBaseId();
const handleDeleteKnowledgeGraph = useCallback(async () => {
const ret = await removeKnowledgeGraph();
if (ret === 0) {
navigate(`/knowledge/dataset?id=${knowledgeBaseId}`);
}
}, [knowledgeBaseId, navigate, removeKnowledgeGraph]);
return { handleDeleteKnowledgeGraph, loading };
}

View File

@@ -0,0 +1,94 @@
import { isEmpty } from 'lodash';
import { v4 as uuid } from 'uuid';
class KeyGenerator {
idx = 0;
chars: string[] = [];
constructor() {
const chars = Array(26)
.fill(1)
.map((x, idx) => String.fromCharCode(97 + idx)); // 26 char
this.chars = chars;
}
generateKey() {
const key = this.chars[this.idx];
this.idx++;
return key;
}
}
// Classify nodes based on edge relationships
export class Converter {
keyGenerator;
dict: Record<string, string> = {}; // key is node id, value is combo
constructor() {
this.keyGenerator = new KeyGenerator();
}
buildDict(edges: { source: string; target: string }[]) {
edges.forEach((x) => {
if (this.dict[x.source] && !this.dict[x.target]) {
this.dict[x.target] = this.dict[x.source];
} else if (!this.dict[x.source] && this.dict[x.target]) {
this.dict[x.source] = this.dict[x.target];
} else if (!this.dict[x.source] && !this.dict[x.target]) {
this.dict[x.source] = this.dict[x.target] =
this.keyGenerator.generateKey();
}
});
return this.dict;
}
buildNodesAndCombos(nodes: any[], edges: any[]) {
this.buildDict(edges);
const nextNodes = nodes.map((x) => ({ ...x, combo: this.dict[x.id] }));
const combos = Object.values(this.dict).reduce<any[]>((pre, cur) => {
if (pre.every((x) => x.id !== cur)) {
pre.push({
id: cur,
data: {
label: `Combo ${cur}`,
},
});
}
return pre;
}, []);
return { nodes: nextNodes, combos };
}
}
export const isDataExist = (data: any) => {
return (
data?.data && typeof data?.data !== 'boolean' && !isEmpty(data?.data?.graph)
);
};
const findCombo = (communities: string[]) => {
const combo = Array.isArray(communities) ? communities[0] : undefined;
return combo;
};
export const buildNodesAndCombos = (nodes: any[]) => {
const combos: any[] = [];
nodes.forEach((x) => {
const combo = findCombo(x?.communities);
if (combo && combos.every((y) => y.data.label !== combo)) {
combos.push({
isCombo: true,
id: uuid(),
data: {
label: combo,
},
});
}
});
const nextNodes = nodes.map((x) => {
return {
...x,
combo: combos.find((y) => y.data.label === findCombo(x?.communities))?.id,
};
});
return { nodes: nextNodes, combos };
};

View File

@@ -0,0 +1,77 @@
import SvgIcon from '@/components/svg-icon';
import { useTranslate } from '@/hooks/common-hooks';
import { useSelectParserList } from '@/hooks/user-setting-hooks';
import { Col, Divider, Empty, Row, Typography } from 'antd';
import DOMPurify from 'dompurify';
import camelCase from 'lodash/camelCase';
import { useMemo } from 'react';
import styles from './index.less';
import { TagTabs } from './tag-tabs';
import { ImageMap } from './utils';
const { Text } = Typography;
const CategoryPanel = ({ chunkMethod }: { chunkMethod: string }) => {
const parserList = useSelectParserList();
const { t } = useTranslate('knowledgeConfiguration');
const item = useMemo(() => {
const item = parserList.find((x) => x.value === chunkMethod);
if (item) {
return {
title: item.label,
description: t(camelCase(item.value)),
};
}
return { title: '', description: '' };
}, [parserList, chunkMethod, t]);
const imageList = useMemo(() => {
if (chunkMethod in ImageMap) {
return ImageMap[chunkMethod as keyof typeof ImageMap];
}
return [];
}, [chunkMethod]);
return (
<section className={styles.categoryPanelWrapper}>
{imageList.length > 0 ? (
<>
<h5 className="font-semibold text-base mt-0 mb-1">
{`"${item.title}" ${t('methodTitle')}`}
</h5>
<p
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(item.description),
}}
></p>
<h5 className="font-semibold text-base mt-4 mb-1">{`"${item.title}" ${t('methodExamples')}`}</h5>
<Text>{t('methodExamplesDescription')}</Text>
<Row gutter={[10, 10]} className={styles.imageRow}>
{imageList.map((x) => (
<Col span={12} key={x}>
<SvgIcon
name={x}
width={'100%'}
className={styles.image}
></SvgIcon>
</Col>
))}
</Row>
<h5 className="font-semibold text-base mt-4 mb-1">
{item.title} {t('dialogueExamplesTitle')}
</h5>
<Divider></Divider>
</>
) : (
<Empty description={''} image={null}>
<p>{t('methodEmpty')}</p>
<SvgIcon name={'chunk-method/chunk-empty'} width={'100%'}></SvgIcon>
</Empty>
)}
{chunkMethod === 'tag' && <TagTabs></TagTabs>}
</section>
);
};
export default CategoryPanel;

View File

@@ -0,0 +1,31 @@
import {
AutoKeywordsItem,
AutoQuestionsItem,
} from '@/components/auto-keywords-item';
import PageRank from '@/components/page-rank';
import ParseConfiguration from '@/components/parse-configuration';
import GraphRagItems from '@/components/parse-configuration/graph-rag-items';
import { TagItems } from '../tag-item';
import { ChunkMethodItem, EmbeddingModelItem } from './common-item';
export function AudioConfiguration() {
return (
<>
<EmbeddingModelItem></EmbeddingModelItem>
<ChunkMethodItem></ChunkMethodItem>
<PageRank></PageRank>
<>
<AutoKeywordsItem></AutoKeywordsItem>
<AutoQuestionsItem></AutoQuestionsItem>
</>
<ParseConfiguration></ParseConfiguration>
<GraphRagItems marginBottom></GraphRagItems>
<TagItems></TagItems>
</>
);
}

View File

@@ -0,0 +1,33 @@
import {
AutoKeywordsItem,
AutoQuestionsItem,
} from '@/components/auto-keywords-item';
import LayoutRecognize from '@/components/layout-recognize';
import PageRank from '@/components/page-rank';
import ParseConfiguration from '@/components/parse-configuration';
import GraphRagItems from '@/components/parse-configuration/graph-rag-items';
import { TagItems } from '../tag-item';
import { ChunkMethodItem, EmbeddingModelItem } from './common-item';
export function BookConfiguration() {
return (
<>
<LayoutRecognize></LayoutRecognize>
<EmbeddingModelItem></EmbeddingModelItem>
<ChunkMethodItem></ChunkMethodItem>
<PageRank></PageRank>
<>
<AutoKeywordsItem></AutoKeywordsItem>
<AutoQuestionsItem></AutoQuestionsItem>
</>
<ParseConfiguration></ParseConfiguration>
<GraphRagItems marginBottom></GraphRagItems>
<TagItems></TagItems>
</>
);
}

View File

@@ -0,0 +1,52 @@
import { useTranslate } from '@/hooks/common-hooks';
import { useHandleChunkMethodSelectChange } from '@/hooks/logic-hooks';
import { Form, Select } from 'antd';
import { memo } from 'react';
import {
useHasParsedDocument,
useSelectChunkMethodList,
useSelectEmbeddingModelOptions,
} from '../hooks';
export const EmbeddingModelItem = memo(function EmbeddingModelItem() {
const { t } = useTranslate('knowledgeConfiguration');
const embeddingModelOptions = useSelectEmbeddingModelOptions();
const disabled = useHasParsedDocument();
return (
<Form.Item
name="embd_id"
label={t('embeddingModel')}
rules={[{ required: true }]}
tooltip={t('embeddingModelTip')}
>
<Select
placeholder={t('embeddingModelPlaceholder')}
options={embeddingModelOptions}
disabled={disabled}
></Select>
</Form.Item>
);
});
export const ChunkMethodItem = memo(function ChunkMethodItem() {
const { t } = useTranslate('knowledgeConfiguration');
const form = Form.useFormInstance();
const handleChunkMethodSelectChange = useHandleChunkMethodSelectChange(form);
const parserList = useSelectChunkMethodList();
return (
<Form.Item
name="parser_id"
label={t('chunkMethod')}
tooltip={t('chunkMethodTip')}
rules={[{ required: true }]}
>
<Select
placeholder={t('chunkMethodPlaceholder')}
onChange={handleChunkMethodSelectChange}
options={parserList}
></Select>
</Form.Item>
);
});

View File

@@ -0,0 +1,31 @@
import {
AutoKeywordsItem,
AutoQuestionsItem,
} from '@/components/auto-keywords-item';
import PageRank from '@/components/page-rank';
import ParseConfiguration from '@/components/parse-configuration';
import GraphRagItems from '@/components/parse-configuration/graph-rag-items';
import { TagItems } from '../tag-item';
import { ChunkMethodItem, EmbeddingModelItem } from './common-item';
export function EmailConfiguration() {
return (
<>
<EmbeddingModelItem></EmbeddingModelItem>
<ChunkMethodItem></ChunkMethodItem>
<PageRank></PageRank>
<>
<AutoKeywordsItem></AutoKeywordsItem>
<AutoQuestionsItem></AutoQuestionsItem>
</>
<ParseConfiguration></ParseConfiguration>
<GraphRagItems marginBottom></GraphRagItems>
<TagItems></TagItems>
</>
);
}

View File

@@ -0,0 +1,133 @@
import { DocumentParserType } from '@/constants/knowledge';
import { useTranslate } from '@/hooks/common-hooks';
import { normFile } from '@/utils/file-util';
import { PlusOutlined } from '@ant-design/icons';
import { Button, Form, Input, Radio, Space, Upload } from 'antd';
import { FormInstance } from 'antd/lib';
import { useEffect, useMemo, useState } from 'react';
import {
useFetchKnowledgeConfigurationOnMount,
useSubmitKnowledgeConfiguration,
} from '../hooks';
import { AudioConfiguration } from './audio';
import { BookConfiguration } from './book';
import { EmailConfiguration } from './email';
import { KnowledgeGraphConfiguration } from './knowledge-graph';
import { LawsConfiguration } from './laws';
import { ManualConfiguration } from './manual';
import { NaiveConfiguration } from './naive';
import { OneConfiguration } from './one';
import { PaperConfiguration } from './paper';
import { PictureConfiguration } from './picture';
import { PresentationConfiguration } from './presentation';
import { QAConfiguration } from './qa';
import { ResumeConfiguration } from './resume';
import { TableConfiguration } from './table';
import { TagConfiguration } from './tag';
import styles from '../index.less';
const ConfigurationComponentMap = {
[DocumentParserType.Naive]: NaiveConfiguration,
[DocumentParserType.Qa]: QAConfiguration,
[DocumentParserType.Resume]: ResumeConfiguration,
[DocumentParserType.Manual]: ManualConfiguration,
[DocumentParserType.Table]: TableConfiguration,
[DocumentParserType.Paper]: PaperConfiguration,
[DocumentParserType.Book]: BookConfiguration,
[DocumentParserType.Laws]: LawsConfiguration,
[DocumentParserType.Presentation]: PresentationConfiguration,
[DocumentParserType.Picture]: PictureConfiguration,
[DocumentParserType.One]: OneConfiguration,
[DocumentParserType.Audio]: AudioConfiguration,
[DocumentParserType.Email]: EmailConfiguration,
[DocumentParserType.Tag]: TagConfiguration,
[DocumentParserType.KnowledgeGraph]: KnowledgeGraphConfiguration,
};
function EmptyComponent() {
return <div></div>;
}
export const ConfigurationForm = ({ form }: { form: FormInstance }) => {
const { submitKnowledgeConfiguration, submitLoading, navigateToDataset } =
useSubmitKnowledgeConfiguration(form);
const { t } = useTranslate('knowledgeConfiguration');
const [finalParserId, setFinalParserId] = useState<DocumentParserType>();
const knowledgeDetails = useFetchKnowledgeConfigurationOnMount(form);
const parserId: DocumentParserType = Form.useWatch('parser_id', form);
const ConfigurationComponent = useMemo(() => {
return finalParserId
? ConfigurationComponentMap[finalParserId]
: EmptyComponent;
}, [finalParserId]);
useEffect(() => {
setFinalParserId(parserId);
}, [parserId]);
useEffect(() => {
setFinalParserId(knowledgeDetails.parser_id as DocumentParserType);
}, [knowledgeDetails.parser_id]);
return (
<Form form={form} name="validateOnly" layout="vertical" autoComplete="off">
<Form.Item name="name" label={t('name')} rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item
name="avatar"
label={t('photo')}
valuePropName="fileList"
getValueFromEvent={normFile}
>
<Upload
listType="picture-card"
maxCount={1}
beforeUpload={() => false}
showUploadList={{ showPreviewIcon: false, showRemoveIcon: false }}
>
<button style={{ border: 0, background: 'none' }} type="button">
<PlusOutlined />
<div style={{ marginTop: 8 }}>{t('upload')}</div>
</button>
</Upload>
</Form.Item>
<Form.Item name="description" label={t('description')}>
<Input />
</Form.Item>
<Form.Item
name="permission"
label={t('permissions')}
tooltip={t('permissionsTip')}
rules={[{ required: true }]}
>
<Radio.Group>
<Radio value="me">{t('me')}</Radio>
<Radio value="team">{t('team')}</Radio>
</Radio.Group>
</Form.Item>
<ConfigurationComponent></ConfigurationComponent>
<Form.Item>
<div className={styles.buttonWrapper}>
<Space>
<Button size={'middle'} onClick={navigateToDataset}>
{t('cancel')}
</Button>
<Button
type="primary"
size={'middle'}
loading={submitLoading}
onClick={submitKnowledgeConfiguration}
>
{t('save')}
</Button>
</Space>
</div>
</Form.Item>
</Form>
);
};

View File

@@ -0,0 +1,22 @@
import Delimiter from '@/components/delimiter';
import EntityTypesItem from '@/components/entity-types-item';
import MaxTokenNumber from '@/components/max-token-number';
import PageRank from '@/components/page-rank';
import { ChunkMethodItem, EmbeddingModelItem } from './common-item';
export function KnowledgeGraphConfiguration() {
return (
<>
<EmbeddingModelItem></EmbeddingModelItem>
<ChunkMethodItem></ChunkMethodItem>
<PageRank></PageRank>
<>
<EntityTypesItem></EntityTypesItem>
<MaxTokenNumber max={8192 * 2}></MaxTokenNumber>
<Delimiter></Delimiter>
</>
</>
);
}

View File

@@ -0,0 +1,33 @@
import {
AutoKeywordsItem,
AutoQuestionsItem,
} from '@/components/auto-keywords-item';
import LayoutRecognize from '@/components/layout-recognize';
import PageRank from '@/components/page-rank';
import ParseConfiguration from '@/components/parse-configuration';
import GraphRagItems from '@/components/parse-configuration/graph-rag-items';
import { TagItems } from '../tag-item';
import { ChunkMethodItem, EmbeddingModelItem } from './common-item';
export function LawsConfiguration() {
return (
<>
<LayoutRecognize></LayoutRecognize>
<EmbeddingModelItem></EmbeddingModelItem>
<ChunkMethodItem></ChunkMethodItem>
<PageRank></PageRank>
<>
<AutoKeywordsItem></AutoKeywordsItem>
<AutoQuestionsItem></AutoQuestionsItem>
</>
<ParseConfiguration></ParseConfiguration>
<GraphRagItems marginBottom></GraphRagItems>
<TagItems></TagItems>
</>
);
}

View File

@@ -0,0 +1,33 @@
import {
AutoKeywordsItem,
AutoQuestionsItem,
} from '@/components/auto-keywords-item';
import LayoutRecognize from '@/components/layout-recognize';
import PageRank from '@/components/page-rank';
import ParseConfiguration from '@/components/parse-configuration';
import GraphRagItems from '@/components/parse-configuration/graph-rag-items';
import { TagItems } from '../tag-item';
import { ChunkMethodItem, EmbeddingModelItem } from './common-item';
export function ManualConfiguration() {
return (
<>
<LayoutRecognize></LayoutRecognize>
<EmbeddingModelItem></EmbeddingModelItem>
<ChunkMethodItem></ChunkMethodItem>
<PageRank></PageRank>
<>
<AutoKeywordsItem></AutoKeywordsItem>
<AutoQuestionsItem></AutoQuestionsItem>
</>
<ParseConfiguration></ParseConfiguration>
<GraphRagItems marginBottom></GraphRagItems>
<TagItems></TagItems>
</>
);
}

View File

@@ -0,0 +1,43 @@
import {
AutoKeywordsItem,
AutoQuestionsItem,
} from '@/components/auto-keywords-item';
import { DatasetConfigurationContainer } from '@/components/dataset-configuration-container';
import Delimiter from '@/components/delimiter';
import ExcelToHtml from '@/components/excel-to-html';
import LayoutRecognize from '@/components/layout-recognize';
import MaxTokenNumber from '@/components/max-token-number';
import PageRank from '@/components/page-rank';
import ParseConfiguration from '@/components/parse-configuration';
import GraphRagItems from '@/components/parse-configuration/graph-rag-items';
import { Divider } from 'antd';
import { TagItems } from '../tag-item';
import { ChunkMethodItem, EmbeddingModelItem } from './common-item';
export function NaiveConfiguration() {
return (
<section className="space-y-4 mb-4">
<DatasetConfigurationContainer>
<LayoutRecognize></LayoutRecognize>
<EmbeddingModelItem></EmbeddingModelItem>
<ChunkMethodItem></ChunkMethodItem>
<MaxTokenNumber></MaxTokenNumber>
<Delimiter></Delimiter>
</DatasetConfigurationContainer>
<Divider></Divider>
<DatasetConfigurationContainer>
<PageRank></PageRank>
<AutoKeywordsItem></AutoKeywordsItem>
<AutoQuestionsItem></AutoQuestionsItem>
<ExcelToHtml></ExcelToHtml>
<TagItems></TagItems>
</DatasetConfigurationContainer>
<Divider></Divider>
<DatasetConfigurationContainer>
<ParseConfiguration></ParseConfiguration>
</DatasetConfigurationContainer>
<Divider></Divider>
<GraphRagItems></GraphRagItems>
</section>
);
}

View File

@@ -0,0 +1,30 @@
import {
AutoKeywordsItem,
AutoQuestionsItem,
} from '@/components/auto-keywords-item';
import LayoutRecognize from '@/components/layout-recognize';
import PageRank from '@/components/page-rank';
import GraphRagItems from '@/components/parse-configuration/graph-rag-items';
import { TagItems } from '../tag-item';
import { ChunkMethodItem, EmbeddingModelItem } from './common-item';
export function OneConfiguration() {
return (
<>
<LayoutRecognize></LayoutRecognize>
<EmbeddingModelItem></EmbeddingModelItem>
<ChunkMethodItem></ChunkMethodItem>
<PageRank></PageRank>
<>
<AutoKeywordsItem></AutoKeywordsItem>
<AutoQuestionsItem></AutoQuestionsItem>
</>
<GraphRagItems marginBottom></GraphRagItems>
<TagItems></TagItems>
</>
);
}

View File

@@ -0,0 +1,33 @@
import {
AutoKeywordsItem,
AutoQuestionsItem,
} from '@/components/auto-keywords-item';
import LayoutRecognize from '@/components/layout-recognize';
import PageRank from '@/components/page-rank';
import ParseConfiguration from '@/components/parse-configuration';
import GraphRagItems from '@/components/parse-configuration/graph-rag-items';
import { TagItems } from '../tag-item';
import { ChunkMethodItem, EmbeddingModelItem } from './common-item';
export function PaperConfiguration() {
return (
<>
<LayoutRecognize></LayoutRecognize>
<EmbeddingModelItem></EmbeddingModelItem>
<ChunkMethodItem></ChunkMethodItem>
<PageRank></PageRank>
<>
<AutoKeywordsItem></AutoKeywordsItem>
<AutoQuestionsItem></AutoQuestionsItem>
</>
<ParseConfiguration></ParseConfiguration>
<GraphRagItems marginBottom></GraphRagItems>
<TagItems></TagItems>
</>
);
}

View File

@@ -0,0 +1,24 @@
import {
AutoKeywordsItem,
AutoQuestionsItem,
} from '@/components/auto-keywords-item';
import PageRank from '@/components/page-rank';
import { TagItems } from '../tag-item';
import { ChunkMethodItem, EmbeddingModelItem } from './common-item';
export function PictureConfiguration() {
return (
<>
<EmbeddingModelItem></EmbeddingModelItem>
<ChunkMethodItem></ChunkMethodItem>
<PageRank></PageRank>
<>
<AutoKeywordsItem></AutoKeywordsItem>
<AutoQuestionsItem></AutoQuestionsItem>
</>
<TagItems></TagItems>
</>
);
}

View File

@@ -0,0 +1,33 @@
import {
AutoKeywordsItem,
AutoQuestionsItem,
} from '@/components/auto-keywords-item';
import LayoutRecognize from '@/components/layout-recognize';
import PageRank from '@/components/page-rank';
import ParseConfiguration from '@/components/parse-configuration';
import GraphRagItems from '@/components/parse-configuration/graph-rag-items';
import { TagItems } from '../tag-item';
import { ChunkMethodItem, EmbeddingModelItem } from './common-item';
export function PresentationConfiguration() {
return (
<>
<LayoutRecognize></LayoutRecognize>
<EmbeddingModelItem></EmbeddingModelItem>
<ChunkMethodItem></ChunkMethodItem>
<PageRank></PageRank>
<>
<AutoKeywordsItem></AutoKeywordsItem>
<AutoQuestionsItem></AutoQuestionsItem>
</>
<ParseConfiguration></ParseConfiguration>
<GraphRagItems marginBottom></GraphRagItems>
<TagItems></TagItems>
</>
);
}

View File

@@ -0,0 +1,16 @@
import PageRank from '@/components/page-rank';
import { TagItems } from '../tag-item';
import { ChunkMethodItem, EmbeddingModelItem } from './common-item';
export function QAConfiguration() {
return (
<>
<EmbeddingModelItem></EmbeddingModelItem>
<ChunkMethodItem></ChunkMethodItem>
<PageRank></PageRank>
<TagItems></TagItems>
</>
);
}

View File

@@ -0,0 +1,16 @@
import PageRank from '@/components/page-rank';
import { TagItems } from '../tag-item';
import { ChunkMethodItem, EmbeddingModelItem } from './common-item';
export function ResumeConfiguration() {
return (
<>
<EmbeddingModelItem></EmbeddingModelItem>
<ChunkMethodItem></ChunkMethodItem>
<PageRank></PageRank>
<TagItems></TagItems>
</>
);
}

View File

@@ -0,0 +1,13 @@
import PageRank from '@/components/page-rank';
import { ChunkMethodItem, EmbeddingModelItem } from './common-item';
export function TableConfiguration() {
return (
<>
<EmbeddingModelItem></EmbeddingModelItem>
<ChunkMethodItem></ChunkMethodItem>
<PageRank></PageRank>
</>
);
}

View File

@@ -0,0 +1,13 @@
import PageRank from '@/components/page-rank';
import { ChunkMethodItem, EmbeddingModelItem } from './common-item';
export function TagConfiguration() {
return (
<>
<EmbeddingModelItem></EmbeddingModelItem>
<ChunkMethodItem></ChunkMethodItem>
<PageRank></PageRank>
</>
);
}

View File

@@ -0,0 +1,121 @@
import { LlmModelType } from '@/constants/knowledge';
import { useSetModalState } from '@/hooks/common-hooks';
import {
useFetchKnowledgeBaseConfiguration,
useUpdateKnowledge,
} from '@/hooks/knowledge-hooks';
import { useSelectLlmOptionsByModelType } from '@/hooks/llm-hooks';
import { useNavigateToDataset } from '@/hooks/route-hook';
import { useSelectParserList } from '@/hooks/user-setting-hooks';
import {
getBase64FromUploadFileList,
getUploadFileListFromBase64,
} from '@/utils/file-util';
import { useIsFetching } from '@tanstack/react-query';
import { Form, UploadFile } from 'antd';
import { FormInstance } from 'antd/lib';
import pick from 'lodash/pick';
import { useCallback, useEffect, useState } from 'react';
export const useSubmitKnowledgeConfiguration = (form: FormInstance) => {
const { saveKnowledgeConfiguration, loading } = useUpdateKnowledge();
const navigateToDataset = useNavigateToDataset();
const submitKnowledgeConfiguration = useCallback(async () => {
const values = await form.validateFields();
const avatar = await getBase64FromUploadFileList(values.avatar);
saveKnowledgeConfiguration({
...values,
avatar,
});
navigateToDataset();
}, [saveKnowledgeConfiguration, form, navigateToDataset]);
return {
submitKnowledgeConfiguration,
submitLoading: loading,
navigateToDataset,
};
};
// The value that does not need to be displayed in the analysis method Select
const HiddenFields = ['email', 'picture', 'audio'];
export function useSelectChunkMethodList() {
const parserList = useSelectParserList();
return parserList.filter((x) => !HiddenFields.some((y) => y === x.value));
}
export function useSelectEmbeddingModelOptions() {
const allOptions = useSelectLlmOptionsByModelType();
return allOptions[LlmModelType.Embedding];
}
export function useHasParsedDocument() {
const { data: knowledgeDetails } = useFetchKnowledgeBaseConfiguration();
return knowledgeDetails.chunk_num > 0;
}
export const useFetchKnowledgeConfigurationOnMount = (form: FormInstance) => {
const { data: knowledgeDetails } = useFetchKnowledgeBaseConfiguration();
useEffect(() => {
const fileList: UploadFile[] = getUploadFileListFromBase64(
knowledgeDetails.avatar,
);
form.setFieldsValue({
...pick(knowledgeDetails, [
'description',
'name',
'permission',
'embd_id',
'parser_id',
'language',
'parser_config',
'pagerank',
]),
avatar: fileList,
});
}, [form, knowledgeDetails]);
return knowledgeDetails;
};
export const useSelectKnowledgeDetailsLoading = () =>
useIsFetching({ queryKey: ['fetchKnowledgeDetail'] }) > 0;
export const useHandleChunkMethodChange = () => {
const [form] = Form.useForm();
const chunkMethod = Form.useWatch('parser_id', form);
useEffect(() => {
console.log('🚀 ~ useHandleChunkMethodChange ~ chunkMethod:', chunkMethod);
}, [chunkMethod]);
return { form, chunkMethod };
};
export const useRenameKnowledgeTag = () => {
const [tag, setTag] = useState<string>('');
const {
visible: tagRenameVisible,
hideModal: hideTagRenameModal,
showModal: showFileRenameModal,
} = useSetModalState();
const handleShowTagRenameModal = useCallback(
(record: string) => {
setTag(record);
showFileRenameModal();
},
[showFileRenameModal],
);
return {
initialName: tag,
tagRenameVisible,
hideTagRenameModal,
showTagRenameModal: handleShowTagRenameModal,
};
};

View File

@@ -0,0 +1,45 @@
.tags {
margin-bottom: 24px;
}
.preset {
display: flex;
height: 80px;
background-color: rgba(0, 0, 0, 0.1);
border-radius: 5px;
padding: 5px;
margin-bottom: 24px;
.left {
flex: 1;
}
.right {
width: 100px;
border-left: 1px solid rgba(0, 0, 0, 0.4);
margin: 10px 0px;
padding: 5px;
}
}
.configurationWrapper {
padding: 0 52px;
.buttonWrapper {
text-align: right;
}
.variableSlider {
width: 100%;
}
}
.categoryPanelWrapper {
.topTitle {
margin-top: 0;
}
.imageRow {
margin-top: 16px;
}
.image {
width: 100%;
}
}

View File

@@ -0,0 +1,40 @@
import { Col, Divider, Row, Spin, Typography } from 'antd';
import CategoryPanel from './category-panel';
import { ConfigurationForm } from './configuration';
import {
useHandleChunkMethodChange,
useSelectKnowledgeDetailsLoading,
} from './hooks';
import { useTranslate } from '@/hooks/common-hooks';
import styles from './index.less';
const { Title } = Typography;
const Configuration = () => {
const loading = useSelectKnowledgeDetailsLoading();
const { form, chunkMethod } = useHandleChunkMethodChange();
const { t } = useTranslate('knowledgeConfiguration');
return (
<div className={styles.configurationWrapper}>
<Title level={5}>
{t('configuration', { keyPrefix: 'knowledgeDetails' })}
</Title>
<p>{t('titleDescription')}</p>
<Divider></Divider>
<Spin spinning={loading}>
<Row gutter={32}>
<Col span={8}>
<ConfigurationForm form={form}></ConfigurationForm>
</Col>
<Col span={16}>
<CategoryPanel chunkMethod={chunkMethod}></CategoryPanel>
</Col>
</Row>
</Spin>
</div>
);
};
export default Configuration;

View File

@@ -0,0 +1,90 @@
import { useFetchKnowledgeList } from '@/hooks/knowledge-hooks';
import { UserOutlined } from '@ant-design/icons';
import { Avatar, Flex, Form, InputNumber, Select, Slider, Space } from 'antd';
import DOMPurify from 'dompurify';
import { useTranslation } from 'react-i18next';
export const TagSetItem = () => {
const { t } = useTranslation();
const { list: knowledgeList } = useFetchKnowledgeList(true);
const knowledgeOptions = knowledgeList
.filter((x) => x.parser_id === 'tag')
.map((x) => ({
label: (
<Space>
<Avatar size={20} icon={<UserOutlined />} src={x.avatar} />
{x.name}
</Space>
),
value: x.id,
}));
return (
<Form.Item
label={t('knowledgeConfiguration.tagSet')}
name={['parser_config', 'tag_kb_ids']}
tooltip={
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(t('knowledgeConfiguration.tagSetTip')),
}}
></div>
}
rules={[
{
message: t('chat.knowledgeBasesMessage'),
type: 'array',
},
]}
>
<Select
mode="multiple"
options={knowledgeOptions}
placeholder={t('chat.knowledgeBasesMessage')}
></Select>
</Form.Item>
);
};
export const TopNTagsItem = () => {
const { t } = useTranslation();
return (
<Form.Item label={t('knowledgeConfiguration.topnTags')}>
<Flex gap={20} align="center">
<Flex flex={1}>
<Form.Item
name={['parser_config', 'topn_tags']}
noStyle
initialValue={3}
>
<Slider max={10} min={1} style={{ width: '100%' }} />
</Form.Item>
</Flex>
<Form.Item name={['parser_config', 'topn_tags']} noStyle>
<InputNumber max={10} min={1} />
</Form.Item>
</Flex>
</Form.Item>
);
};
export function TagItems() {
return (
<>
<TagSetItem></TagSetItem>
<Form.Item noStyle dependencies={[['parser_config', 'tag_kb_ids']]}>
{({ getFieldValue }) => {
const ids: string[] = getFieldValue(['parser_config', 'tag_kb_ids']);
return (
Array.isArray(ids) &&
ids.length > 0 && <TopNTagsItem></TopNTagsItem>
);
}}
</Form.Item>
</>
);
}

View File

@@ -0,0 +1,307 @@
'use client';
import {
ColumnDef,
ColumnFiltersState,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { ArrowUpDown, Pencil, Trash2 } from 'lucide-react';
import * as React from 'react';
import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useDeleteTag, useFetchTagList } from '@/hooks/knowledge-hooks';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useRenameKnowledgeTag } from '../hooks';
import { RenameDialog } from './rename-dialog';
export type ITag = {
tag: string;
frequency: number;
};
export function TagTable() {
const { t } = useTranslation();
const { list } = useFetchTagList();
const [tagList, setTagList] = useState<ITag[]>([]);
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[],
);
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const { deleteTag } = useDeleteTag();
useEffect(() => {
setTagList(list.map((x) => ({ tag: x[0], frequency: x[1] })));
}, [list]);
const handleDeleteTag = useCallback(
(tags: string[]) => () => {
deleteTag(tags);
},
[deleteTag],
);
const {
showTagRenameModal,
hideTagRenameModal,
tagRenameVisible,
initialName,
} = useRenameKnowledgeTag();
const columns: ColumnDef<ITag>[] = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'tag',
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
>
{t('knowledgeConfiguration.tagName')}
<ArrowUpDown />
</Button>
);
},
cell: ({ row }) => {
const value: string = row.getValue('tag');
return <div>{value}</div>;
},
},
{
accessorKey: 'frequency',
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
>
{t('knowledgeConfiguration.frequency')}
<ArrowUpDown />
</Button>
);
},
cell: ({ row }) => (
<div className="capitalize ">{row.getValue('frequency')}</div>
),
},
{
id: 'actions',
enableHiding: false,
header: t('common.action'),
cell: ({ row }) => {
return (
<div className="flex gap-1">
<Tooltip>
<ConfirmDeleteDialog onOk={handleDeleteTag([row.original.tag])}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon">
<Trash2 />
</Button>
</TooltipTrigger>
</ConfirmDeleteDialog>
<TooltipContent>
<p>{t('common.delete')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => showTagRenameModal(row.original.tag)}
>
<Pencil />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('common.rename')}</p>
</TooltipContent>
</Tooltip>
</div>
);
},
},
];
const table = useReactTable({
data: tagList,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
const selectedRowLength = table.getFilteredSelectedRowModel().rows.length;
return (
<TooltipProvider>
<div className="w-full">
<div className="flex items-center justify-between py-4 ">
<Input
placeholder={t('knowledgeConfiguration.searchTags')}
value={(table.getColumn('tag')?.getFilterValue() as string) ?? ''}
onChange={(event) =>
table.getColumn('tag')?.setFilterValue(event.target.value)
}
className="w-1/2"
/>
{selectedRowLength > 0 && (
<ConfirmDeleteDialog
onOk={handleDeleteTag(
table
.getFilteredSelectedRowModel()
.rows.map((x) => x.original.tag),
)}
>
<Button variant="outline" size="icon">
<Trash2 />
</Button>
</ConfirmDeleteDialog>
)}
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{selectedRowLength} of {table.getFilteredRowModel().rows.length}{' '}
row(s) selected.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
{t('common.previousPage')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
{t('common.nextPage')}
</Button>
</div>
</div>
</div>
{tagRenameVisible && (
<RenameDialog
hideModal={hideTagRenameModal}
initialName={initialName}
></RenameDialog>
)}
</TooltipProvider>
);
}

View File

@@ -0,0 +1,40 @@
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { LoadingButton } from '@/components/ui/loading-button';
import { useTagIsRenaming } from '@/hooks/knowledge-hooks';
import { IModalProps } from '@/interfaces/common';
import { TagRenameId } from '@/pages/add-knowledge/constant';
import { useTranslation } from 'react-i18next';
import { RenameForm } from './rename-form';
export function RenameDialog({
hideModal,
initialName,
}: IModalProps<any> & { initialName: string }) {
const { t } = useTranslation();
const loading = useTagIsRenaming();
return (
<Dialog open onOpenChange={hideModal}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t('common.rename')}</DialogTitle>
</DialogHeader>
<RenameForm
initialName={initialName}
hideModal={hideModal}
></RenameForm>
<DialogFooter>
<LoadingButton type="submit" form={TagRenameId} loading={loading}>
{t('common.save')}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { useRenameTag } from '@/hooks/knowledge-hooks';
import { IModalProps } from '@/interfaces/common';
import { TagRenameId } from '@/pages/add-knowledge/constant';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
export function RenameForm({
initialName,
hideModal,
}: IModalProps<any> & { initialName: string }) {
const { t } = useTranslation();
const FormSchema = z.object({
name: z
.string()
.min(1, {
message: t('common.namePlaceholder'),
})
.trim(),
});
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: '',
},
});
const { renameTag } = useRenameTag();
async function onSubmit(data: z.infer<typeof FormSchema>) {
const ret = await renameTag({ fromTag: initialName, toTag: data.name });
if (ret) {
hideModal?.();
}
}
useEffect(() => {
form.setValue('name', initialName);
}, [form, initialName]);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
id={TagRenameId}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.name')}</FormLabel>
<FormControl>
<Input
placeholder={t('common.namePlaceholder')}
{...field}
autoComplete="off"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
}

View File

@@ -0,0 +1,40 @@
import { Segmented } from 'antd';
import { SegmentedLabeledOption } from 'antd/es/segmented';
import { upperFirst } from 'lodash';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { TagTable } from './tag-table';
import { TagWordCloud } from './tag-word-cloud';
enum TagType {
Cloud = 'cloud',
Table = 'table',
}
const TagContentMap = {
[TagType.Cloud]: <TagWordCloud></TagWordCloud>,
[TagType.Table]: <TagTable></TagTable>,
};
export function TagTabs() {
const [value, setValue] = useState<TagType>(TagType.Cloud);
const { t } = useTranslation();
const options: SegmentedLabeledOption[] = [TagType.Cloud, TagType.Table].map(
(x) => ({
label: t(`knowledgeConfiguration.tag${upperFirst(x)}`),
value: x,
}),
);
return (
<section className="mt-4">
<Segmented
value={value}
options={options}
onChange={(val) => setValue(val as TagType)}
/>
{TagContentMap[value]}
</section>
);
}

View File

@@ -0,0 +1,62 @@
import { useFetchTagList } from '@/hooks/knowledge-hooks';
import { Chart } from '@antv/g2';
import { sumBy } from 'lodash';
import { useCallback, useEffect, useMemo, useRef } from 'react';
export function TagWordCloud() {
const domRef = useRef<HTMLDivElement>(null);
let chartRef = useRef<Chart>();
const { list } = useFetchTagList();
const { list: tagList } = useMemo(() => {
const nextList = list.sort((a, b) => b[1] - a[1]).slice(0, 256);
return {
list: nextList.map((x) => ({ text: x[0], value: x[1], name: x[0] })),
sumValue: sumBy(nextList, (x: [string, number]) => x[1]),
length: nextList.length,
};
}, [list]);
const renderWordCloud = useCallback(() => {
if (domRef.current) {
chartRef.current = new Chart({ container: domRef.current });
chartRef.current.options({
type: 'wordCloud',
autoFit: true,
layout: {
fontSize: [10, 50],
// fontSize: (d: any) => {
// if (d.value) {
// return (d.value / sumValue) * 100 * (length / 10);
// }
// return 0;
// },
},
data: {
type: 'inline',
value: tagList,
},
encode: { color: 'text' },
legend: false,
tooltip: {
title: 'name', // title
items: ['value'], // data item
},
});
chartRef.current.render();
}
}, [tagList]);
useEffect(() => {
renderWordCloud();
return () => {
chartRef.current?.destroy();
};
}, [renderWordCloud]);
return <div ref={domRef} className="w-full h-[38vh]"></div>;
}

View File

@@ -0,0 +1,20 @@
const getImageName = (prefix: string, length: number) =>
new Array(length)
.fill(0)
.map((x, idx) => `chunk-method/${prefix}-0${idx + 1}`);
export const ImageMap = {
book: getImageName('book', 4),
laws: getImageName('law', 2),
manual: getImageName('manual', 4),
picture: getImageName('media', 2),
naive: getImageName('naive', 2),
paper: getImageName('paper', 2),
presentation: getImageName('presentation', 2),
qa: getImageName('qa', 2),
resume: getImageName('resume', 2),
table: getImageName('table', 2),
one: getImageName('one', 2),
knowledge_graph: getImageName('knowledge-graph', 2),
tag: getImageName('tag', 2),
};

View File

@@ -0,0 +1,63 @@
.sidebarWrapper {
max-width: 288px;
padding: 32px 24px 24px 24px;
flex-direction: column;
.sidebarTop {
text-align: center;
.knowledgeLogo {
}
.knowledgeTitle {
font-size: 16px;
line-height: 24px;
font-weight: @fontWeight700;
margin-bottom: 6px;
}
.knowledgeDescription {
font-size: 12px;
font-weight: @fontWeight600;
color: @gray8;
margin: 0;
}
padding-bottom: 20px;
}
.divider {
height: 2px;
background-image: linear-gradient(
to right,
@gray11 0%,
@gray11 50%,
transparent 50%
);
background-size: 10px 2px;
background-repeat: repeat-x;
}
.menuWrapper {
padding-top: 10px;
.menu {
border: none;
font-size: @fontSize16;
font-weight: @fontWeight600;
:global(.ant-menu-item) {
display: flex;
align-items: center;
}
}
.defaultWidth {
width: 240px;
}
.minWidth {
width: 50px;
}
.menuText {
color: @gray3;
font-size: @fontSize14;
font-weight: @fontWeight700;
}
}
}

View File

@@ -0,0 +1,135 @@
import { ReactComponent as ConfigurationIcon } from '@/assets/svg/knowledge-configration.svg';
import { ReactComponent as DatasetIcon } from '@/assets/svg/knowledge-dataset.svg';
import { ReactComponent as TestingIcon } from '@/assets/svg/knowledge-testing.svg';
import {
useFetchKnowledgeBaseConfiguration,
useFetchKnowledgeGraph,
} from '@/hooks/knowledge-hooks';
import {
useGetKnowledgeSearchParams,
useSecondPathName,
} from '@/hooks/route-hook';
import { getWidth } from '@/utils';
import { Avatar, Menu, MenuProps, Space } from 'antd';
import classNames from 'classnames';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'umi';
import { KnowledgeRouteKey } from '../../constant';
import { isEmpty } from 'lodash';
import { GitGraph } from 'lucide-react';
import styles from './index.less';
const KnowledgeSidebar = () => {
let navigate = useNavigate();
const activeKey = useSecondPathName();
const { knowledgeId } = useGetKnowledgeSearchParams();
const [windowWidth, setWindowWidth] = useState(getWidth());
const { t } = useTranslation();
const { data: knowledgeDetails } = useFetchKnowledgeBaseConfiguration();
const handleSelect: MenuProps['onSelect'] = (e) => {
navigate(`/knowledge/${e.key}?id=${knowledgeId}`);
};
const { data } = useFetchKnowledgeGraph();
type MenuItem = Required<MenuProps>['items'][number];
const getItem = useCallback(
(
label: string,
key: React.Key,
icon?: React.ReactNode,
disabled?: boolean,
children?: MenuItem[],
type?: 'group',
): MenuItem => {
return {
key,
icon,
children,
label: t(`knowledgeDetails.${label}`),
type,
disabled,
} as MenuItem;
},
[t],
);
const items: MenuItem[] = useMemo(() => {
const list = [
getItem(
KnowledgeRouteKey.Dataset, // TODO: Change icon color when selected
KnowledgeRouteKey.Dataset,
<DatasetIcon />,
),
getItem(
KnowledgeRouteKey.Testing,
KnowledgeRouteKey.Testing,
<TestingIcon />,
),
getItem(
KnowledgeRouteKey.Configuration,
KnowledgeRouteKey.Configuration,
<ConfigurationIcon />,
),
];
if (!isEmpty(data?.graph)) {
list.push(
getItem(
KnowledgeRouteKey.KnowledgeGraph,
KnowledgeRouteKey.KnowledgeGraph,
<GitGraph />,
),
);
}
return list;
}, [data, getItem]);
useEffect(() => {
const widthSize = () => {
const width = getWidth();
setWindowWidth(width);
};
window.addEventListener('resize', widthSize);
return () => {
window.removeEventListener('resize', widthSize);
};
}, []);
return (
<div className={styles.sidebarWrapper}>
<div className={styles.sidebarTop}>
<Space size={8} direction="vertical">
<Avatar size={64} src={knowledgeDetails.avatar} />
<div className={styles.knowledgeTitle}>{knowledgeDetails.name}</div>
</Space>
<p className={styles.knowledgeDescription}>
{knowledgeDetails.description}
</p>
</div>
<div className={styles.divider}></div>
<div className={styles.menuWrapper}>
<Menu
selectedKeys={[activeKey]}
// mode="inline"
className={classNames(styles.menu, {
[styles.defaultWidth]: windowWidth.width > 957,
[styles.minWidth]: windowWidth.width <= 957,
})}
// inlineCollapsed={collapsed}
items={items}
onSelect={handleSelect}
/>
</div>
</div>
);
};
export default KnowledgeSidebar;

View File

@@ -0,0 +1,4 @@
.testingWrapper {
flex: 1;
height: 100%;
}

View File

@@ -0,0 +1,68 @@
import {
useTestChunkAllRetrieval,
useTestChunkRetrieval,
} from '@/hooks/knowledge-hooks';
import { Flex, Form } from 'antd';
import { useMemo, useState } from 'react';
import TestingControl from './testing-control';
import TestingResult from './testing-result';
import styles from './index.less';
const KnowledgeTesting = () => {
const [form] = Form.useForm();
const {
data: retrievalData,
testChunk,
loading: retrievalLoading,
} = useTestChunkRetrieval();
const {
data: allRetrievalData,
testChunkAll,
loading: allRetrievalLoading,
} = useTestChunkAllRetrieval();
const [selectedDocumentIds, setSelectedDocumentIds] = useState<string[]>([]);
const handleTesting = async (documentIds: string[] = []) => {
const values = await form.validateFields();
const params = {
...values,
vector_similarity_weight: 1 - values.vector_similarity_weight,
};
if (Array.isArray(documentIds) && documentIds.length > 0) {
testChunk({
...params,
doc_ids: documentIds,
});
} else {
testChunkAll({
...params,
doc_ids: [],
});
}
};
const testingResult = useMemo(() => {
return selectedDocumentIds.length > 0 ? retrievalData : allRetrievalData;
}, [allRetrievalData, retrievalData, selectedDocumentIds.length]);
return (
<Flex className={styles.testingWrapper} gap={16}>
<TestingControl
form={form}
handleTesting={handleTesting}
selectedDocumentIds={selectedDocumentIds}
></TestingControl>
<TestingResult
data={testingResult}
loading={retrievalLoading || allRetrievalLoading}
handleTesting={handleTesting}
selectedDocumentIds={selectedDocumentIds}
setSelectedDocumentIds={setSelectedDocumentIds}
></TestingResult>
</Flex>
);
};
export default KnowledgeTesting;

View File

@@ -0,0 +1,29 @@
.testingControlWrapper {
width: 350px;
background-color: rgba(255, 255, 255, 0.1);
padding: 30px 20px;
overflow: auto;
height: calc(100vh - 160px);
.historyTitle {
padding: 30px 0 20px;
}
.historyIcon {
vertical-align: middle;
}
.historyCardWrapper {
width: 100%;
}
.historyCard {
width: 100%;
:global(.ant-card-body) {
padding: 10px;
}
}
.historyText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
}

View File

@@ -0,0 +1,109 @@
import Rerank from '@/components/rerank';
import SimilaritySlider from '@/components/similarity-slider';
import { useTranslate } from '@/hooks/common-hooks';
import { useChunkIsTesting } from '@/hooks/knowledge-hooks';
import { Button, Card, Divider, Flex, Form, Input } from 'antd';
import { FormInstance } from 'antd/lib';
import { LabelWordCloud } from './label-word-cloud';
import { CrossLanguageItem } from '@/components/cross-language-item';
import { UseKnowledgeGraphItem } from '@/components/use-knowledge-graph-item';
import styles from './index.less';
type FieldType = {
similarity_threshold?: number;
vector_similarity_weight?: number;
question: string;
};
interface IProps {
form: FormInstance;
handleTesting: (documentIds?: string[]) => Promise<any>;
selectedDocumentIds: string[];
}
const TestingControl = ({
form,
handleTesting,
selectedDocumentIds,
}: IProps) => {
const question = Form.useWatch('question', { form, preserve: true });
const loading = useChunkIsTesting();
const { t } = useTranslate('knowledgeDetails');
const buttonDisabled =
!question || (typeof question === 'string' && question.trim() === '');
const onClick = () => {
handleTesting(selectedDocumentIds);
};
return (
<section className={styles.testingControlWrapper}>
<div>
<b>{t('testing')}</b>
</div>
<p>{t('testingDescription')}</p>
<Divider></Divider>
<section>
<Form name="testing" layout="vertical" form={form}>
<SimilaritySlider isTooltipShown></SimilaritySlider>
<Rerank></Rerank>
<UseKnowledgeGraphItem filedName={['use_kg']}></UseKnowledgeGraphItem>
<CrossLanguageItem name={'cross_languages'}></CrossLanguageItem>
<Card size="small" title={t('testText')}>
<Form.Item<FieldType>
name={'question'}
rules={[{ required: true, message: t('testTextPlaceholder') }]}
>
<Input.TextArea autoSize={{ minRows: 8 }}></Input.TextArea>
</Form.Item>
<Flex justify={'end'}>
<Button
type="primary"
size="small"
onClick={onClick}
disabled={buttonDisabled}
loading={loading}
>
{t('testingLabel')}
</Button>
</Flex>
</Card>
</Form>
</section>
<LabelWordCloud></LabelWordCloud>
{/* <section>
<div className={styles.historyTitle}>
<Space size={'middle'}>
<HistoryOutlined className={styles.historyIcon} />
<b>Test history</b>
</Space>
</div>
<Space
direction="vertical"
size={'middle'}
className={styles.historyCardWrapper}
>
{list.map((x) => (
<Card className={styles.historyCard} key={x}>
<Flex justify={'space-between'} gap={'small'}>
<span>{x}</span>
<div className={styles.historyText}>
content dcjsjl snldsh svnodvn svnodrfn svjdoghdtbnhdo
sdvhodhbuid sldghdrlh
</div>
<Flex gap={'small'}>
<span>time</span>
<DeleteOutlined></DeleteOutlined>
</Flex>
</Flex>
</Card>
))}
</Space>
</section> */}
</section>
);
};
export default TestingControl;

View File

@@ -0,0 +1,59 @@
import { useSelectTestingResult } from '@/hooks/knowledge-hooks';
import { Chart } from '@antv/g2';
import { useCallback, useEffect, useMemo, useRef } from 'react';
export function LabelWordCloud() {
const domRef = useRef<HTMLDivElement>(null);
let chartRef = useRef<Chart>();
const { labels } = useSelectTestingResult();
const list = useMemo(() => {
if (!labels) {
return [];
}
return Object.keys(labels).reduce<
Array<{ text: string; name: string; value: number }>
>((pre, cur) => {
pre.push({ name: cur, text: cur, value: labels[cur] });
return pre;
}, []);
}, [labels]);
const renderWordCloud = useCallback(() => {
if (domRef.current && list.length) {
chartRef.current = new Chart({ container: domRef.current });
chartRef.current.options({
type: 'wordCloud',
autoFit: true,
layout: {
fontSize: [6, 15],
},
data: {
type: 'inline',
value: list,
},
encode: { color: 'text' },
legend: false,
tooltip: {
title: 'name', // title
items: ['value'], // data item
},
});
chartRef.current.render();
}
}, [list]);
useEffect(() => {
renderWordCloud();
return () => {
chartRef.current?.destroy();
};
}, [renderWordCloud]);
return <div ref={domRef} className="w-full h-[13vh]"></div>;
}

View File

@@ -0,0 +1,46 @@
.testingResultWrapper {
flex: 1;
background-color: rgba(255, 255, 255, 0.1);
padding: 30px 20px;
overflow: auto;
height: calc(100vh - 160px);
display: flex;
flex-direction: column;
justify-content: space-between;
.selectFilesCollapse {
:global(.ant-collapse-header) {
padding-left: 22px;
}
margin-bottom: 32px;
overflow-y: auto;
}
.selectFilesTitle {
padding-right: 10px;
}
.similarityCircle {
width: 24px;
height: 24px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.1);
font-size: 10px;
font-weight: normal;
}
.similarityText {
font-size: 12px;
font-weight: 500;
}
.image {
width: 100%;
max-height: 30vh;
object-fit: contain;
}
}
.imagePreview {
display: block;
max-width: 45vw;
max-height: 40vh;
}

View File

@@ -0,0 +1,147 @@
import { ReactComponent as SelectedFilesCollapseIcon } from '@/assets/svg/selected-files-collapse.svg';
import { useTranslate } from '@/hooks/common-hooks';
import { ITestingChunk, ITestingResult } from '@/interfaces/database/knowledge';
import {
Card,
Collapse,
Empty,
Flex,
Image,
Pagination,
PaginationProps,
Space,
} from 'antd';
import camelCase from 'lodash/camelCase';
import SelectFiles from './select-files';
import { useGetPaginationWithRouter } from '@/hooks/logic-hooks';
import { api_host } from '@/utils/api';
import { showImage } from '@/utils/chat';
import { useCallback } from 'react';
import styles from './index.less';
const similarityList: Array<{ field: keyof ITestingChunk; label: string }> = [
{ field: 'similarity', label: 'Hybrid Similarity' },
{ field: 'term_similarity', label: 'Term Similarity' },
{ field: 'vector_similarity', label: 'Vector Similarity' },
];
const ChunkTitle = ({ item }: { item: ITestingChunk }) => {
const { t } = useTranslate('knowledgeDetails');
return (
<Flex gap={10}>
{similarityList.map((x) => (
<Space key={x.field}>
<span className={styles.similarityCircle}>
{((item[x.field] as number) * 100).toFixed(2)}
</span>
<span className={styles.similarityText}>{t(camelCase(x.field))}</span>
</Space>
))}
</Flex>
);
};
interface IProps {
handleTesting: (documentIds?: string[]) => Promise<any>;
selectedDocumentIds: string[];
setSelectedDocumentIds: (ids: string[]) => void;
data?: ITestingResult;
loading?: boolean;
}
const TestingResult = ({
handleTesting,
selectedDocumentIds,
setSelectedDocumentIds,
data,
loading,
}: IProps) => {
const { documents, chunks, total } = data || {};
const { t } = useTranslate('knowledgeDetails');
const { pagination, setPagination } = useGetPaginationWithRouter();
const onChange: PaginationProps['onChange'] = (pageNumber, pageSize) => {
pagination.onChange?.(pageNumber, pageSize);
handleTesting(selectedDocumentIds);
};
const onTesting = useCallback(
(ids: string[]) => {
setPagination({ page: 1 });
handleTesting(ids);
},
[setPagination, handleTesting],
);
return (
<section className={styles.testingResultWrapper}>
<Collapse
expandIcon={() => (
<SelectedFilesCollapseIcon></SelectedFilesCollapseIcon>
)}
className={styles.selectFilesCollapse}
items={[
{
key: '1',
label: (
<Flex
justify={'space-between'}
align="center"
className={styles.selectFilesTitle}
>
<Space>
<span>
{selectedDocumentIds?.length ?? 0}/{documents?.length ?? 0}
</span>
{t('filesSelected')}
</Space>
</Flex>
),
children: (
<div>
<SelectFiles
setSelectedDocumentIds={setSelectedDocumentIds}
handleTesting={onTesting}
></SelectFiles>
</div>
),
},
]}
/>
<Flex
gap={'large'}
vertical
flex={1}
className={styles.selectFilesCollapse}
>
{loading === false && chunks && chunks.length > 0 ? (
chunks?.map((x) => (
<Card key={x.chunk_id} title={<ChunkTitle item={x}></ChunkTitle>}>
<div className="flex justify-center">
{showImage(x.doc_type_kwd) && (
<Image
id={x.image_id}
className={'object-contain max-h-[30vh] w-full text-center'}
src={`${api_host}/document/image/${x.image_id}`}
></Image>
)}
</div>
<div className="pt-4">{x.content_with_weight}</div>
</Card>
))
) : loading === false && chunks && chunks.length === 0 ? (
<Empty></Empty>
) : null}
</Flex>
<Pagination
{...pagination}
size={'small'}
total={total}
onChange={onChange}
/>
</section>
);
};
export default TestingResult;

View File

@@ -0,0 +1,73 @@
import NewDocumentLink from '@/components/new-document-link';
import { useTranslate } from '@/hooks/common-hooks';
import { useAllTestingResult } from '@/hooks/knowledge-hooks';
import { ITestingDocument } from '@/interfaces/database/knowledge';
import { EyeOutlined } from '@ant-design/icons';
import { Button, Table, TableProps, Tooltip } from 'antd';
interface IProps {
handleTesting: (ids: string[]) => void;
setSelectedDocumentIds: (ids: string[]) => void;
}
const SelectFiles = ({ setSelectedDocumentIds, handleTesting }: IProps) => {
const { documents } = useAllTestingResult();
const { t } = useTranslate('fileManager');
const columns: TableProps<ITestingDocument>['columns'] = [
{
title: 'Name',
dataIndex: 'doc_name',
key: 'doc_name',
render: (text) => <p>{text}</p>,
},
{
title: 'Hits',
dataIndex: 'count',
key: 'count',
width: 80,
},
{
title: 'View',
key: 'view',
width: 50,
render: (_, { doc_id, doc_name }) => (
<NewDocumentLink
documentName={doc_name}
documentId={doc_id}
prefix="document"
>
<Tooltip title={t('preview')}>
<Button type="text">
<EyeOutlined size={20} />
</Button>
</Tooltip>
</NewDocumentLink>
),
},
];
const rowSelection = {
onChange: (selectedRowKeys: React.Key[]) => {
setSelectedDocumentIds(selectedRowKeys as string[]);
handleTesting(selectedRowKeys as string[]);
},
getCheckboxProps: (record: ITestingDocument) => ({
disabled: record.doc_name === 'Disabled User', // Column configuration not to be checked
name: record.doc_name,
}),
};
return (
<Table
columns={columns}
dataSource={documents}
showHeader={false}
rowSelection={rowSelection}
rowKey={'doc_id'}
/>
);
};
export default SelectFiles;

View File

@@ -0,0 +1,21 @@
import { KnowledgeRouteKey } from '@/constants/knowledge';
export const routeMap = {
[KnowledgeRouteKey.Dataset]: 'Dataset',
[KnowledgeRouteKey.Testing]: 'Retrieval testing',
[KnowledgeRouteKey.Configuration]: 'Configuration',
};
export enum KnowledgeDatasetRouteKey {
Chunk = 'chunk',
File = 'file',
}
export const datasetRouteMap = {
[KnowledgeDatasetRouteKey.Chunk]: 'Chunk',
[KnowledgeDatasetRouteKey.File]: 'File Upload',
};
export * from '@/constants/knowledge';
export const TagRenameId = 'tagRename';

View File

@@ -0,0 +1,19 @@
.container {
display: flex;
height: 100%;
width: 100%;
.contentWrapper {
flex: 1;
overflow-x: auto;
height: 100%;
background-color: rgba(255, 255, 255, 0.1);
padding: 16px 20px 28px 40px;
display: flex;
flex-direction: column;
}
.content {
background-color: rgba(255, 255, 255, 0.1);
margin-top: 16px;
flex: 1;
}
}

View File

@@ -0,0 +1,74 @@
import { useKnowledgeBaseId } from '@/hooks/knowledge-hooks';
import {
useNavigateWithFromState,
useSecondPathName,
useThirdPathName,
} from '@/hooks/route-hook';
import { Breadcrumb } from 'antd';
import { ItemType } from 'antd/es/breadcrumb/Breadcrumb';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, Outlet } from 'umi';
import Siderbar from './components/knowledge-sidebar';
import { KnowledgeDatasetRouteKey, KnowledgeRouteKey } from './constant';
import styles from './index.less';
const KnowledgeAdding = () => {
const knowledgeBaseId = useKnowledgeBaseId();
const { t } = useTranslation();
const activeKey: KnowledgeRouteKey =
(useSecondPathName() as KnowledgeRouteKey) || KnowledgeRouteKey.Dataset;
const datasetActiveKey: KnowledgeDatasetRouteKey =
useThirdPathName() as KnowledgeDatasetRouteKey;
const gotoList = useNavigateWithFromState();
const breadcrumbItems: ItemType[] = useMemo(() => {
const items: ItemType[] = [
{
title: (
<a onClick={() => gotoList('/knowledge')}>
{t('header.knowledgeBase')}
</a>
),
},
{
title: datasetActiveKey ? (
<Link
to={`/knowledge/${KnowledgeRouteKey.Dataset}?id=${knowledgeBaseId}`}
>
{t(`knowledgeDetails.${activeKey}`)}
</Link>
) : (
t(`knowledgeDetails.${activeKey}`)
),
},
];
if (datasetActiveKey) {
items.push({
title: t(`knowledgeDetails.${datasetActiveKey}`),
});
}
return items;
}, [activeKey, datasetActiveKey, gotoList, knowledgeBaseId, t]);
return (
<>
<div className={styles.container}>
<Siderbar></Siderbar>
<div className={styles.contentWrapper}>
<Breadcrumb items={breadcrumbItems} />
<div className={styles.content}>
<Outlet></Outlet>
</div>
</div>
</div>
</>
);
};
export default KnowledgeAdding;

View File

@@ -0,0 +1,18 @@
.contextMenu {
background: rgba(255, 255, 255, 0.1);
border-style: solid;
box-shadow: 10px 19px 20px rgba(0, 0, 0, 10%);
position: absolute;
z-index: 10;
button {
border: none;
display: block;
padding: 0.5em;
text-align: left;
width: 100%;
}
button:hover {
background: rgba(255, 255, 255, 0.1);
}
}

View File

@@ -0,0 +1,107 @@
import { NodeMouseHandler, useReactFlow } from '@xyflow/react';
import { useCallback, useRef, useState } from 'react';
import styles from './index.less';
export interface INodeContextMenu {
id: string;
top: number;
left: number;
right?: number;
bottom?: number;
[key: string]: unknown;
}
export function NodeContextMenu({
id,
top,
left,
right,
bottom,
...props
}: INodeContextMenu) {
const { getNode, setNodes, addNodes, setEdges } = useReactFlow();
const duplicateNode = useCallback(() => {
const node = getNode(id);
const position = {
x: node?.position?.x || 0 + 50,
y: node?.position?.y || 0 + 50,
};
addNodes({
...(node || {}),
data: node?.data,
selected: false,
dragging: false,
id: `${node?.id}-copy`,
position,
});
}, [id, getNode, addNodes]);
const deleteNode = useCallback(() => {
setNodes((nodes) => nodes.filter((node) => node.id !== id));
setEdges((edges) => edges.filter((edge) => edge.source !== id));
}, [id, setNodes, setEdges]);
return (
<div
style={{ top, left, right, bottom }}
className={styles.contextMenu}
{...props}
>
<p style={{ margin: '0.5em' }}>
<small>node: {id}</small>
</p>
<button onClick={duplicateNode} type={'button'}>
duplicate
</button>
<button onClick={deleteNode} type={'button'}>
delete
</button>
</div>
);
}
/* @deprecated
*/
export const useHandleNodeContextMenu = (sideWidth: number) => {
const [menu, setMenu] = useState<INodeContextMenu>({} as INodeContextMenu);
const ref = useRef<any>(null);
const onNodeContextMenu: NodeMouseHandler = useCallback(
(event, node) => {
// Prevent native context menu from showing
event.preventDefault();
// Calculate position of the context menu. We want to make sure it
// doesn't get positioned off-screen.
const pane = ref.current?.getBoundingClientRect();
// setMenu({
// id: node.id,
// top: event.clientY < pane.height - 200 ? event.clientY : 0,
// left: event.clientX < pane.width - 200 ? event.clientX : 0,
// right: event.clientX >= pane.width - 200 ? pane.width - event.clientX : 0,
// bottom:
// event.clientY >= pane.height - 200 ? pane.height - event.clientY : 0,
// });
setMenu({
id: node.id,
top: event.clientY - 144,
left: event.clientX - sideWidth,
// top: event.clientY < pane.height - 200 ? event.clientY - 72 : 0,
// left: event.clientX < pane.width - 200 ? event.clientX : 0,
});
},
[sideWidth],
);
// Close the context menu if it's open whenever the window is clicked.
const onPaneClick = useCallback(
() => setMenu({} as INodeContextMenu),
[setMenu],
);
return { onNodeContextMenu, menu, onPaneClick, ref };
};

View File

@@ -0,0 +1,56 @@
import {
createContext,
ReactNode,
useCallback,
useContext,
useRef,
} from 'react';
interface DropdownContextType {
canShowDropdown: () => boolean;
setActiveDropdown: (type: 'handle' | 'drag') => void;
clearActiveDropdown: () => void;
}
const DropdownContext = createContext<DropdownContextType | null>(null);
export const useDropdownManager = () => {
const context = useContext(DropdownContext);
if (!context) {
throw new Error('useDropdownManager must be used within DropdownProvider');
}
return context;
};
interface DropdownProviderProps {
children: ReactNode;
}
export const DropdownProvider = ({ children }: DropdownProviderProps) => {
const activeDropdownRef = useRef<'handle' | 'drag' | null>(null);
const canShowDropdown = useCallback(() => {
const current = activeDropdownRef.current;
return !current;
}, []);
const setActiveDropdown = useCallback((type: 'handle' | 'drag') => {
activeDropdownRef.current = type;
}, []);
const clearActiveDropdown = useCallback(() => {
activeDropdownRef.current = null;
}, []);
const value: DropdownContextType = {
canShowDropdown,
setActiveDropdown,
clearActiveDropdown,
};
return (
<DropdownContext.Provider value={value}>
{children}
</DropdownContext.Provider>
);
};

View File

@@ -0,0 +1,138 @@
import {
BaseEdge,
Edge,
EdgeLabelRenderer,
EdgeProps,
getBezierPath,
} from '@xyflow/react';
import { memo } from 'react';
import useGraphStore from '../../store';
import { useFetchAgent } from '@/hooks/use-agent-request';
import { cn } from '@/lib/utils';
import { useMemo } from 'react';
import { NodeHandleId, Operator } from '../../constant';
function InnerButtonEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
source,
target,
style = {},
markerEnd,
selected,
data,
sourceHandleId,
}: EdgeProps<Edge<{ isHovered: boolean }>>) {
const { deleteEdgeById, getOperatorTypeFromId } = useGraphStore(
(state) => state,
);
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const selectedStyle = useMemo(() => {
return selected
? { strokeWidth: 1, stroke: 'rgb(var(--accent-primary))' }
: {};
}, [selected]);
const isTargetPlaceholder = useMemo(() => {
return getOperatorTypeFromId(target) === Operator.Placeholder;
}, [getOperatorTypeFromId, target]);
const placeholderHighlightStyle = useMemo(() => {
const isHighlighted = isTargetPlaceholder;
return isHighlighted
? { strokeWidth: 2, stroke: 'rgb(var(--accent-primary))' }
: {};
}, [isTargetPlaceholder]);
const onEdgeClick = () => {
deleteEdgeById(id);
};
// highlight the nodes that the workflow passes through
const { data: flowDetail } = useFetchAgent();
const showHighlight = useMemo(() => {
const path = flowDetail?.dsl?.path ?? [];
const idx = path.findIndex((x) => x === target);
if (idx !== -1) {
let index = idx - 1;
while (index >= 0) {
if (path[index] === source) {
return { strokeWidth: 1, stroke: 'rgb(var(--accent-primary))' };
}
index--;
}
return {};
}
return {};
}, [flowDetail?.dsl?.path, source, target]);
const visible = useMemo(() => {
return (
data?.isHovered &&
sourceHandleId !== NodeHandleId.Tool &&
sourceHandleId !== NodeHandleId.AgentBottom && // The connection between the agent node and the tool node does not need to display the delete button
!target.startsWith(Operator.Tool) &&
!isTargetPlaceholder
);
}, [data?.isHovered, isTargetPlaceholder, sourceHandleId, target]);
return (
<>
<BaseEdge
path={edgePath}
markerEnd={markerEnd}
style={{
...style,
...selectedStyle,
...showHighlight,
...placeholderHighlightStyle,
}}
className={cn('text-text-disabled')}
/>
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
fontSize: 12,
// everything inside EdgeLabelRenderer has no pointer events by default
// if you have an interactive element, set pointer-events: all
pointerEvents: 'all',
zIndex: 1001, // https://github.com/xyflow/xyflow/discussions/3498
}}
className="nodrag nopan"
>
<button
className={cn(
'size-3.5 border border-state-error text-state-error rounded-full leading-none bg-bg-canvas outline outline-bg-canvas',
'invisible',
{ visible },
)}
type="button"
onClick={onEdgeClick}
>
×
</button>
</div>
</EdgeLabelRenderer>
</>
);
}
export const ButtonEdge = memo(InnerButtonEdge);

View File

@@ -0,0 +1,11 @@
.canvasWrapper {
position: relative;
height: calc(100% - 64px);
:global(.react-flow__node-group) {
.commonNode();
border-radius: 0 0 10px 10px;
padding: 0;
border: 0;
background-color: transparent;
}
}

View File

@@ -0,0 +1,391 @@
import { useTheme } from '@/components/theme-provider';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useSetModalState } from '@/hooks/common-hooks';
import { cn } from '@/lib/utils';
import {
ConnectionMode,
ControlButton,
Controls,
NodeTypes,
Position,
ReactFlow,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { NotebookPen } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChatSheet } from '../chat/chat-sheet';
import {
AgentChatContext,
AgentChatLogContext,
AgentInstanceContext,
HandleContext,
} from '../context';
import FormSheet from '../form-sheet/next';
import {
useHandleDrop,
useSelectCanvasData,
useValidateConnection,
} from '../hooks';
import { useAddNode } from '../hooks/use-add-node';
import { useBeforeDelete } from '../hooks/use-before-delete';
import { useCacheChatLog } from '../hooks/use-cache-chat-log';
import { useConnectionDrag } from '../hooks/use-connection-drag';
import { useDropdownPosition } from '../hooks/use-dropdown-position';
import { useMoveNote } from '../hooks/use-move-note';
import { usePlaceholderManager } from '../hooks/use-placeholder-manager';
import { useDropdownManager } from './context';
import { AgentBackground } from '@/components/canvas/background';
import Spotlight from '@/components/spotlight';
import {
useHideFormSheetOnNodeDeletion,
useShowDrawer,
useShowLogSheet,
} from '../hooks/use-show-drawer';
import { LogSheet } from '../log-sheet';
import RunSheet from '../run-sheet';
import { ButtonEdge } from './edge';
import styles from './index.less';
import { RagNode } from './node';
import { AgentNode } from './node/agent-node';
import { BeginNode } from './node/begin-node';
import { CategorizeNode } from './node/categorize-node';
import { NextStepDropdown } from './node/dropdown/next-step-dropdown';
import { ExtractorNode } from './node/extractor-node';
import { FileNode } from './node/file-node';
import { GenerateNode } from './node/generate-node';
import { InvokeNode } from './node/invoke-node';
import { IterationNode, IterationStartNode } from './node/iteration-node';
import { KeywordNode } from './node/keyword-node';
import { MessageNode } from './node/message-node';
import NoteNode from './node/note-node';
import ParserNode from './node/parser-node';
import { PlaceholderNode } from './node/placeholder-node';
import { RelevantNode } from './node/relevant-node';
import { RetrievalNode } from './node/retrieval-node';
import { RewriteNode } from './node/rewrite-node';
import { SplitterNode } from './node/splitter-node';
import { SwitchNode } from './node/switch-node';
import { TemplateNode } from './node/template-node';
import TokenizerNode from './node/tokenizer-node';
import { ToolNode } from './node/tool-node';
export const nodeTypes: NodeTypes = {
ragNode: RagNode,
categorizeNode: CategorizeNode,
beginNode: BeginNode,
placeholderNode: PlaceholderNode,
relevantNode: RelevantNode,
noteNode: NoteNode,
switchNode: SwitchNode,
generateNode: GenerateNode,
retrievalNode: RetrievalNode,
messageNode: MessageNode,
rewriteNode: RewriteNode,
keywordNode: KeywordNode,
invokeNode: InvokeNode,
templateNode: TemplateNode,
// emailNode: EmailNode,
group: IterationNode,
iterationStartNode: IterationStartNode,
agentNode: AgentNode,
toolNode: ToolNode,
fileNode: FileNode,
parserNode: ParserNode,
tokenizerNode: TokenizerNode,
splitterNode: SplitterNode,
contextNode: ExtractorNode,
};
const edgeTypes = {
buttonEdge: ButtonEdge,
};
interface IProps {
drawerVisible: boolean;
hideDrawer(): void;
}
function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
const { t } = useTranslation();
const {
nodes,
edges,
onConnect: originalOnConnect,
onEdgesChange,
onNodesChange,
onSelectionChange,
onEdgeMouseEnter,
onEdgeMouseLeave,
} = useSelectCanvasData();
const isValidConnection = useValidateConnection();
const { onDrop, onDragOver, setReactFlowInstance, reactFlowInstance } =
useHandleDrop();
const {
onNodeClick,
clickedNode,
formDrawerVisible,
hideFormDrawer,
singleDebugDrawerVisible,
hideSingleDebugDrawer,
showSingleDebugDrawer,
chatVisible,
runVisible,
hideRunOrChatDrawer,
showChatModal,
showFormDrawer,
} = useShowDrawer({
drawerVisible,
hideDrawer,
});
const {
addEventList,
setCurrentMessageId,
currentEventListWithoutMessageById,
clearEventList,
currentMessageId,
} = useCacheChatLog();
const { showLogSheet, logSheetVisible, hideLogSheet } = useShowLogSheet({
setCurrentMessageId,
});
const [lastSendLoading, setLastSendLoading] = useState(false);
const { handleBeforeDelete } = useBeforeDelete();
const { addCanvasNode, addNoteNode } = useAddNode(reactFlowInstance);
const { ref, showImage, hideImage, imgVisible, mouse } = useMoveNote();
const { theme } = useTheme();
useEffect(() => {
if (!chatVisible) {
clearEventList();
}
}, [chatVisible, clearEventList]);
const setLastSendLoadingFunc = (loading: boolean, messageId: string) => {
if (messageId === currentMessageId) {
setLastSendLoading(loading);
} else {
setLastSendLoading(false);
}
};
useHideFormSheetOnNodeDeletion({ hideFormDrawer });
const { visible, hideModal, showModal } = useSetModalState();
const [dropdownPosition, setDropdownPosition] = useState({ x: 0, y: 0 });
const { clearActiveDropdown } = useDropdownManager();
const {
removePlaceholderNode,
onNodeCreated,
setCreatedPlaceholderRef,
checkAndRemoveExistingPlaceholder,
} = usePlaceholderManager(reactFlowInstance);
const { calculateDropdownPosition } = useDropdownPosition(reactFlowInstance);
const {
onConnectStart,
onConnectEnd,
handleConnect,
getConnectionStartContext,
shouldPreventClose,
onMove,
nodeId,
} = useConnectionDrag(
reactFlowInstance,
originalOnConnect,
showModal,
hideModal,
setDropdownPosition,
setCreatedPlaceholderRef,
calculateDropdownPosition,
removePlaceholderNode,
clearActiveDropdown,
checkAndRemoveExistingPlaceholder,
);
const onPaneClick = useCallback(() => {
hideFormDrawer();
if (visible && !shouldPreventClose()) {
removePlaceholderNode();
hideModal();
clearActiveDropdown();
}
if (imgVisible) {
addNoteNode(mouse);
hideImage();
}
}, [
hideFormDrawer,
visible,
shouldPreventClose,
hideModal,
imgVisible,
addNoteNode,
mouse,
hideImage,
clearActiveDropdown,
removePlaceholderNode,
]);
return (
<div className={cn(styles.canvasWrapper, 'px-5 pb-5')}>
<svg
xmlns="http://www.w3.org/2000/svg"
style={{ position: 'absolute', top: 10, left: 0 }}
>
<defs>
<marker
fill="var(--text-disabled)"
id="logo"
viewBox="0 0 40 40"
refX="8"
refY="5"
markerUnits="strokeWidth"
markerWidth="20"
markerHeight="20"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
</defs>
</svg>
<AgentInstanceContext.Provider value={{ addCanvasNode, showFormDrawer }}>
<ReactFlow
connectionMode={ConnectionMode.Loose}
nodes={nodes}
onNodesChange={onNodesChange}
edges={edges}
onEdgesChange={onEdgesChange}
fitView
onConnect={handleConnect}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onDrop={onDrop}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
onMove={onMove}
onDragOver={onDragOver}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
onInit={setReactFlowInstance}
onSelectionChange={onSelectionChange}
nodeOrigin={[0.5, 0]}
isValidConnection={isValidConnection}
onEdgeMouseEnter={onEdgeMouseEnter}
onEdgeMouseLeave={onEdgeMouseLeave}
className="h-full"
colorMode={theme}
defaultEdgeOptions={{
type: 'buttonEdge',
markerEnd: 'logo',
zIndex: 1001, // https://github.com/xyflow/xyflow/discussions/3498
}}
deleteKeyCode={['Delete', 'Backspace']}
onBeforeDelete={handleBeforeDelete}
>
<AgentBackground></AgentBackground>
<Spotlight className="z-0" opcity={0.7} coverage={70} />
<Controls position={'bottom-center'} orientation="horizontal">
<ControlButton>
<Tooltip>
<TooltipTrigger asChild>
<NotebookPen className="!fill-none" onClick={showImage} />
</TooltipTrigger>
<TooltipContent>{t('flow.note')}</TooltipContent>
</Tooltip>
</ControlButton>
</Controls>
</ReactFlow>
{visible && (
<HandleContext.Provider
value={
getConnectionStartContext() || {
nodeId: '',
id: '',
type: 'source',
position: Position.Right,
isFromConnectionDrag: true,
}
}
>
<NextStepDropdown
hideModal={() => {
removePlaceholderNode();
hideModal();
clearActiveDropdown();
}}
position={dropdownPosition}
onNodeCreated={onNodeCreated}
nodeId={nodeId}
>
<span></span>
</NextStepDropdown>
</HandleContext.Provider>
)}
</AgentInstanceContext.Provider>
<NotebookPen
className={cn('hidden absolute size-6', { block: imgVisible })}
ref={ref}
></NotebookPen>
{formDrawerVisible && (
<AgentInstanceContext.Provider
value={{ addCanvasNode, showFormDrawer }}
>
<FormSheet
node={clickedNode}
visible={formDrawerVisible}
hideModal={hideFormDrawer}
chatVisible={chatVisible}
singleDebugDrawerVisible={singleDebugDrawerVisible}
hideSingleDebugDrawer={hideSingleDebugDrawer}
showSingleDebugDrawer={showSingleDebugDrawer}
></FormSheet>
</AgentInstanceContext.Provider>
)}
{chatVisible && (
<AgentChatContext.Provider
value={{ showLogSheet, setLastSendLoadingFunc }}
>
<AgentChatLogContext.Provider
value={{ addEventList, setCurrentMessageId }}
>
<ChatSheet hideModal={hideRunOrChatDrawer}></ChatSheet>
</AgentChatLogContext.Provider>
</AgentChatContext.Provider>
)}
{runVisible && (
<RunSheet
hideModal={hideRunOrChatDrawer}
showModal={showChatModal}
></RunSheet>
)}
{logSheetVisible && (
<LogSheet
hideModal={hideLogSheet}
currentEventListWithoutMessageById={
currentEventListWithoutMessageById
}
currentMessageId={currentMessageId}
sendLoading={lastSendLoading}
></LogSheet>
)}
</div>
);
}
export default AgentCanvas;

View File

@@ -0,0 +1,123 @@
import LLMLabel from '@/components/llm-select/llm-label';
import { IAgentNode } from '@/interfaces/database/flow';
import { cn } from '@/lib/utils';
import { Handle, NodeProps, Position } from '@xyflow/react';
import { get } from 'lodash';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { AgentExceptionMethod, NodeHandleId } from '../../constant';
import { AgentFormSchemaType } from '../../form/agent-form';
import useGraphStore from '../../store';
import { hasSubAgent, isBottomSubAgent } from '../../utils';
import { CommonHandle, LeftEndHandle } from './handle';
import { RightHandleStyle } from './handle-icon';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
function InnerAgentNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IAgentNode<AgentFormSchemaType>>) {
const edges = useGraphStore((state) => state.edges);
const { t } = useTranslation();
const isHeadAgent = useMemo(() => {
return !isBottomSubAgent(edges, id);
}, [edges, id]);
const exceptionMethod = useMemo(() => {
return get(data, 'form.exception_method');
}, [data]);
const hasTools = useMemo(() => {
const tools = get(data, 'form.tools', []);
const mcp = get(data, 'form.mcp', []);
return tools.length > 0 || mcp.length > 0;
}, [data]);
const isGotoMethod = useMemo(() => {
return exceptionMethod === AgentExceptionMethod.Goto;
}, [exceptionMethod]);
return (
<ToolBar selected={selected} id={id} label={data.label}>
<NodeWrapper selected={selected}>
{isHeadAgent && (
<>
<LeftEndHandle></LeftEndHandle>
<CommonHandle
type="source"
position={Position.Right}
isConnectable={isConnectable}
style={RightHandleStyle}
nodeId={id}
id={NodeHandleId.Start}
isConnectableEnd={false}
></CommonHandle>
</>
)}
{isHeadAgent || (
<Handle
type="target"
position={Position.Top}
isConnectable={false}
id={NodeHandleId.AgentTop}
className="!bg-accent-primary !size-2"
></Handle>
)}
<Handle
type="source"
position={Position.Bottom}
isConnectable={false}
id={NodeHandleId.AgentBottom}
style={{ left: 180 }}
className={cn('!bg-accent-primary !size-2 invisible', {
visible: hasSubAgent(edges, id),
})}
></Handle>
<Handle
type="source"
position={Position.Bottom}
isConnectable={false}
id={NodeHandleId.Tool}
style={{ left: 20 }}
className={cn('!bg-accent-primary !size-2 invisible', {
visible: hasTools,
})}
></Handle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
<section className="flex flex-col gap-2">
<div className={'bg-bg-card rounded-sm p-1'}>
<LLMLabel value={get(data, 'form.llm_id')}></LLMLabel>
</div>
{(isGotoMethod ||
exceptionMethod === AgentExceptionMethod.Comment) && (
<div className="bg-bg-card rounded-sm p-1 flex justify-between gap-2">
<span className="text-text-secondary">{t('flow.onFailure')}</span>
<span className="truncate flex-1 text-right">
{t(`flow.${exceptionMethod}`)}
</span>
</div>
)}
</section>
{isGotoMethod && (
<CommonHandle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className="!bg-state-error"
style={{ ...RightHandleStyle, top: 94 }}
nodeId={id}
id={NodeHandleId.AgentException}
isConnectableEnd={false}
></CommonHandle>
)}
</NodeWrapper>
</ToolBar>
);
}
export const AgentNode = memo(InnerAgentNode);

View File

@@ -0,0 +1,62 @@
import { IBeginNode } from '@/interfaces/database/flow';
import { cn } from '@/lib/utils';
import { NodeProps, Position } from '@xyflow/react';
import get from 'lodash/get';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import {
BeginQueryType,
BeginQueryTypeIconMap,
NodeHandleId,
Operator,
} from '../../constant';
import { BeginQuery } from '../../interface';
import OperatorIcon from '../../operator-icon';
import { CommonHandle } from './handle';
import { RightHandleStyle } from './handle-icon';
import styles from './index.less';
import { NodeWrapper } from './node-wrapper';
// TODO: do not allow other nodes to connect to this node
function InnerBeginNode({ data, id, selected }: NodeProps<IBeginNode>) {
const { t } = useTranslation();
const inputs: Record<string, BeginQuery> = get(data, 'form.inputs', {});
return (
<NodeWrapper selected={selected}>
<CommonHandle
type="source"
position={Position.Right}
isConnectable
style={RightHandleStyle}
nodeId={id}
id={NodeHandleId.Start}
></CommonHandle>
<section className="flex items-center gap-2">
<OperatorIcon name={data.label as Operator}></OperatorIcon>
<div className="truncate text-center font-semibold text-sm">
{t(`flow.begin`)}
</div>
</section>
<section className={cn(styles.generateParameters, 'flex gap-2 flex-col')}>
{Object.entries(inputs).map(([key, val], idx) => {
const Icon = BeginQueryTypeIconMap[val.type as BeginQueryType];
return (
<div
key={idx}
className={cn(styles.conditionBlock, 'flex gap-1.5 items-center')}
>
<Icon className="size-4" />
<label htmlFor="">{key}</label>
<span className={styles.parameterValue}>{val.name}</span>
<span className="flex-1">{val.optional ? 'Yes' : 'No'}</span>
</div>
);
})}
</section>
</NodeWrapper>
);
}
export const BeginNode = memo(InnerBeginNode);

View File

@@ -0,0 +1,70 @@
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { PropsWithChildren } from 'react';
export function CardWithForm() {
return (
<Card className="w-[350px]">
<CardHeader>
<CardTitle>Create project</CardTitle>
<CardDescription>Deploy your new project in one-click.</CardDescription>
</CardHeader>
<CardContent>
<form>
<div className="grid w-full items-center gap-4">
<div className="flex flex-col space-y-1.5">
<Label htmlFor="name">Name</Label>
<Input id="name" placeholder="Name of your project" />
</div>
<div className="flex flex-col space-y-1.5">
<Label htmlFor="framework">Framework</Label>
<Select>
<SelectTrigger id="framework">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="next">Next.js</SelectItem>
<SelectItem value="sveltekit">SvelteKit</SelectItem>
<SelectItem value="astro">Astro</SelectItem>
<SelectItem value="nuxt">Nuxt.js</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</form>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline">Cancel</Button>
<Button>Deploy</Button>
</CardFooter>
</Card>
);
}
type LabelCardProps = {
className?: string;
} & PropsWithChildren;
export function LabelCard({ children, className }: LabelCardProps) {
return (
<div className={cn('bg-bg-card rounded-sm p-1', className)}>{children}</div>
);
}

View File

@@ -0,0 +1,54 @@
import LLMLabel from '@/components/llm-select/llm-label';
import { ICategorizeNode } from '@/interfaces/database/flow';
import { NodeProps, Position } from '@xyflow/react';
import { get } from 'lodash';
import { memo } from 'react';
import { CommonHandle, LeftEndHandle } from './handle';
import { RightHandleStyle } from './handle-icon';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
import { useBuildCategorizeHandlePositions } from './use-build-categorize-handle-positions';
export function InnerCategorizeNode({
id,
data,
selected,
}: NodeProps<ICategorizeNode>) {
const { positions } = useBuildCategorizeHandlePositions({ data, id });
return (
<ToolBar selected={selected} id={id} label={data.label}>
<NodeWrapper selected={selected}>
<LeftEndHandle></LeftEndHandle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
<section className="flex flex-col gap-2">
<div className={'bg-bg-card rounded-sm px-1'}>
<LLMLabel value={get(data, 'form.llm_id')}></LLMLabel>
</div>
{positions.map((position) => {
return (
<div key={position.uuid}>
<div className={'bg-bg-card rounded-sm p-1 truncate'}>
{position.name}
</div>
<CommonHandle
id={position.uuid}
type="source"
position={Position.Right}
isConnectable
style={{ ...RightHandleStyle, top: position.top }}
nodeId={id}
isConnectableEnd={false}
></CommonHandle>
</div>
);
})}
</section>
</NodeWrapper>
</ToolBar>
);
}
export const CategorizeNode = memo(InnerCategorizeNode);

View File

@@ -0,0 +1,58 @@
import OperateDropdown from '@/components/operate-dropdown';
import { CopyOutlined } from '@ant-design/icons';
import { Flex, MenuProps } from 'antd';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Operator } from '../../constant';
import { useDuplicateNode } from '../../hooks';
import useGraphStore from '../../store';
interface IProps {
id: string;
iconFontColor?: string;
label: string;
}
const NodeDropdown = ({ id, iconFontColor, label }: IProps) => {
const { t } = useTranslation();
const deleteNodeById = useGraphStore((store) => store.deleteNodeById);
const deleteIterationNodeById = useGraphStore(
(store) => store.deleteIterationNodeById,
);
const deleteNode = useCallback(() => {
if (label === Operator.Iteration) {
deleteIterationNodeById(id);
} else {
deleteNodeById(id);
}
}, [label, deleteIterationNodeById, id, deleteNodeById]);
const duplicateNode = useDuplicateNode();
const items: MenuProps['items'] = [
{
key: '2',
onClick: () => duplicateNode(id, label),
label: (
<Flex justify={'space-between'}>
{t('common.copy')}
<CopyOutlined />
</Flex>
),
},
];
return (
<OperateDropdown
iconFontSize={22}
height={14}
deleteItem={deleteNode}
items={items}
needsDeletionValidation={false}
iconFontColor={iconFontColor}
></OperateDropdown>
);
};
export default NodeDropdown;

View File

@@ -0,0 +1,196 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Operator } from '@/constants/agent';
import useGraphStore from '@/pages/agent/store';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { OperatorItemList } from './operator-item-list';
export function AccordionOperators({
isCustomDropdown = false,
mousePosition,
}: {
isCustomDropdown?: boolean;
mousePosition?: { x: number; y: number };
}) {
const { t } = useTranslation();
return (
<Accordion
type="multiple"
className="px-2 text-text-title max-h-[45vh] overflow-auto scrollbar-none"
defaultValue={['item-1', 'item-2', 'item-3', 'item-4', 'item-5']}
>
<AccordionItem value="item-1">
<AccordionTrigger className="text-xl">
{t('flow.foundation')}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[Operator.Agent, Operator.Retrieval]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger className="text-xl">
{t('flow.dialog')}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[Operator.Message, Operator.UserFillUp]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger className="text-xl">
{t('flow.flow')}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[
Operator.Switch,
Operator.Iteration,
Operator.Categorize,
]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-4">
<AccordionTrigger className="text-xl">
{t('flow.dataManipulation')}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[Operator.Code, Operator.StringTransform]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-5">
<AccordionTrigger className="text-xl">
{t('flow.tools')}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[
Operator.TavilySearch,
Operator.TavilyExtract,
Operator.ExeSQL,
Operator.Google,
Operator.YahooFinance,
Operator.Email,
Operator.DuckDuckGo,
Operator.Wikipedia,
Operator.GoogleScholar,
Operator.ArXiv,
Operator.PubMed,
Operator.GitHub,
Operator.Invoke,
Operator.WenCai,
Operator.SearXNG,
]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
</Accordion>
);
}
// Limit the number of operators of a certain type on the canvas to only one
function useRestrictSingleOperatorOnCanvas() {
const { findNodeByName } = useGraphStore((state) => state);
const restrictSingleOperatorOnCanvas = useCallback(
(singleOperators: Operator[]) => {
const list: Operator[] = [];
singleOperators.forEach((operator) => {
if (!findNodeByName(operator)) {
list.push(operator);
}
});
return list;
},
[findNodeByName],
);
return restrictSingleOperatorOnCanvas;
}
export function PipelineAccordionOperators({
isCustomDropdown = false,
mousePosition,
nodeId,
}: {
isCustomDropdown?: boolean;
mousePosition?: { x: number; y: number };
nodeId?: string;
}) {
const restrictSingleOperatorOnCanvas = useRestrictSingleOperatorOnCanvas();
const { getOperatorTypeFromId } = useGraphStore((state) => state);
const operators = useMemo(() => {
let list = [
...restrictSingleOperatorOnCanvas([Operator.Parser, Operator.Tokenizer]),
];
list.push(Operator.Extractor);
return list;
}, [restrictSingleOperatorOnCanvas]);
const chunkerOperators = useMemo(() => {
return [
...restrictSingleOperatorOnCanvas([
Operator.Splitter,
Operator.HierarchicalMerger,
]),
];
}, [restrictSingleOperatorOnCanvas]);
const showChunker = useMemo(() => {
return (
getOperatorTypeFromId(nodeId) !== Operator.Extractor &&
chunkerOperators.length > 0
);
}, [chunkerOperators.length, getOperatorTypeFromId, nodeId]);
return (
<>
<OperatorItemList
operators={operators}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
{showChunker && (
<Accordion
type="single"
collapsible
className="w-full px-4"
defaultValue="item-1"
>
<AccordionItem value="item-1">
<AccordionTrigger>Chunker</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={chunkerOperators}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</>
);
}

View File

@@ -0,0 +1,112 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { IModalProps } from '@/interfaces/common';
import { useIsPipeline } from '@/pages/agent/hooks/use-is-pipeline';
import { t } from 'i18next';
import { PropsWithChildren, memo, useEffect, useRef } from 'react';
import {
AccordionOperators,
PipelineAccordionOperators,
} from './accordion-operators';
import { HideModalContext, OnNodeCreatedContext } from './operator-item-list';
export function InnerNextStepDropdown({
children,
hideModal,
position,
onNodeCreated,
nodeId,
}: PropsWithChildren &
IModalProps<any> & {
position?: { x: number; y: number };
onNodeCreated?: (newNodeId: string) => void;
nodeId?: string;
}) {
const dropdownRef = useRef<HTMLDivElement>(null);
const isPipeline = useIsPipeline();
useEffect(() => {
if (position && hideModal) {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
hideModal();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}
}, [position, hideModal]);
if (position) {
return (
<div
ref={dropdownRef}
style={{
position: 'fixed',
left: position.x,
top: position.y,
zIndex: 1000,
}}
onClick={(e) => e.stopPropagation()}
>
<div className="w-[300px] font-semibold bg-bg-base border border-border rounded-md shadow-lg">
<div className="px-3 py-2 border-b border-border">
<div className="text-sm font-medium">{t('flow.nextStep')}</div>
</div>
<HideModalContext.Provider value={hideModal}>
<OnNodeCreatedContext.Provider value={onNodeCreated}>
{isPipeline ? (
<PipelineAccordionOperators
isCustomDropdown={true}
mousePosition={position}
nodeId={nodeId}
></PipelineAccordionOperators>
) : (
<AccordionOperators
isCustomDropdown={true}
mousePosition={position}
></AccordionOperators>
)}
</OnNodeCreatedContext.Provider>
</HideModalContext.Provider>
</div>
</div>
);
}
return (
<DropdownMenu
open={true}
onOpenChange={(open) => {
if (!open && hideModal) {
hideModal();
}
}}
>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
onClick={(e) => e.stopPropagation()}
className="w-[300px] font-semibold"
>
<DropdownMenuLabel>{t('flow.nextStep')}</DropdownMenuLabel>
<HideModalContext.Provider value={hideModal}>
{isPipeline ? (
<PipelineAccordionOperators></PipelineAccordionOperators>
) : (
<AccordionOperators></AccordionOperators>
)}
</HideModalContext.Provider>
</DropdownMenuContent>
</DropdownMenu>
);
}
export const NextStepDropdown = memo(InnerNextStepDropdown);

View File

@@ -0,0 +1,100 @@
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Operator } from '@/constants/agent';
import { IModalProps } from '@/interfaces/common';
import { AgentInstanceContext, HandleContext } from '@/pages/agent/context';
import OperatorIcon from '@/pages/agent/operator-icon';
import { Position } from '@xyflow/react';
import { lowerFirst } from 'lodash';
import { createContext, useContext } from 'react';
import { useTranslation } from 'react-i18next';
export type OperatorItemProps = {
operators: Operator[];
isCustomDropdown?: boolean;
mousePosition?: { x: number; y: number };
};
export const HideModalContext = createContext<IModalProps<any>['showModal']>(
() => {},
);
export const OnNodeCreatedContext = createContext<
((newNodeId: string) => void) | undefined
>(undefined);
export function OperatorItemList({
operators,
isCustomDropdown = false,
mousePosition,
}: OperatorItemProps) {
const { addCanvasNode } = useContext(AgentInstanceContext);
const handleContext = useContext(HandleContext);
const hideModal = useContext(HideModalContext);
const onNodeCreated = useContext(OnNodeCreatedContext);
const { t } = useTranslation();
const handleClick =
(operator: Operator): React.MouseEventHandler<HTMLElement> =>
(e) => {
const contextData = handleContext || {
nodeId: '',
id: '',
type: 'source' as const,
position: Position.Right,
isFromConnectionDrag: true,
};
const mockEvent = mousePosition
? {
clientX: mousePosition.x,
clientY: mousePosition.y,
}
: e;
const newNodeId = addCanvasNode(operator, contextData)(mockEvent);
if (onNodeCreated && newNodeId) {
onNodeCreated(newNodeId);
}
hideModal?.();
};
const renderOperatorItem = (operator: Operator) => {
const commonContent = (
<div className="hover:bg-background-card py-1 px-3 cursor-pointer rounded-sm flex gap-2 items-center justify-start">
<OperatorIcon name={operator} />
{t(`flow.${lowerFirst(operator)}`)}
</div>
);
return (
<Tooltip key={operator}>
<TooltipTrigger asChild>
{isCustomDropdown ? (
<li onClick={handleClick(operator)}>{commonContent}</li>
) : (
<DropdownMenuItem
key={operator}
className="hover:bg-background-card py-1 px-3 cursor-pointer rounded-sm flex gap-2 items-center justify-start"
onClick={handleClick(operator)}
onSelect={() => hideModal?.()}
>
<OperatorIcon name={operator} />
{t(`flow.${lowerFirst(operator)}`)}
</DropdownMenuItem>
)}
</TooltipTrigger>
<TooltipContent side="right" sideOffset={24}>
<p>{t(`flow.${lowerFirst(operator)}Description`)}</p>
</TooltipContent>
</Tooltip>
);
};
return <ul className="space-y-2">{operators.map(renderOperatorItem)}</ul>;
}

View File

@@ -0,0 +1,80 @@
import { IEmailNode } from '@/interfaces/database/flow';
import { Handle, NodeProps, Position } from '@xyflow/react';
import { Flex } from 'antd';
import classNames from 'classnames';
import { memo, useState } from 'react';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
export function InnerEmailNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IEmailNode>) {
const [showDetails, setShowDetails] = useState(false);
return (
<section
className={classNames(styles.ragNode, {
[styles.selectedNode]: selected,
})}
>
<Handle
id="c"
type="source"
position={Position.Left}
isConnectable={isConnectable}
className={styles.handle}
style={LeftHandleStyle}
></Handle>
<Handle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
style={RightHandleStyle}
id="b"
></Handle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
<Flex vertical gap={8} className={styles.emailNodeContainer}>
<div
className={styles.emailConfig}
onClick={() => setShowDetails(!showDetails)}
>
<div className={styles.configItem}>
<span className={styles.configLabel}>SMTP:</span>
<span className={styles.configValue}>{data.form?.smtp_server}</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>Port:</span>
<span className={styles.configValue}>{data.form?.smtp_port}</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>From:</span>
<span className={styles.configValue}>{data.form?.email}</span>
</div>
<div className={styles.expandIcon}>{showDetails ? '▼' : '▶'}</div>
</div>
{showDetails && (
<div className={styles.jsonExample}>
<div className={styles.jsonTitle}>Expected Input JSON:</div>
<pre className={styles.jsonContent}>
{`{
"to_email": "...",
"cc_email": "...",
"subject": "...",
"content": "..."
}`}
</pre>
</div>
)}
</Flex>
</section>
);
}
export const EmailNode = memo(InnerEmailNode);

View File

@@ -0,0 +1,18 @@
import LLMLabel from '@/components/llm-select/llm-label';
import { IRagNode } from '@/interfaces/database/agent';
import { NodeProps } from '@xyflow/react';
import { get } from 'lodash';
import { LabelCard } from './card';
import { RagNode } from './index';
export function ExtractorNode({ ...props }: NodeProps<IRagNode>) {
const { data } = props;
return (
<RagNode {...props}>
<LabelCard>
<LLMLabel value={get(data, 'form.llm_id')}></LLMLabel>
</LabelCard>
</RagNode>
);
}

View File

@@ -0,0 +1,62 @@
import { IBeginNode } from '@/interfaces/database/flow';
import { cn } from '@/lib/utils';
import { NodeProps, Position } from '@xyflow/react';
import get from 'lodash/get';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import {
BeginQueryType,
BeginQueryTypeIconMap,
NodeHandleId,
Operator,
} from '../../constant';
import { BeginQuery } from '../../interface';
import OperatorIcon from '../../operator-icon';
import { CommonHandle } from './handle';
import { RightHandleStyle } from './handle-icon';
import styles from './index.less';
import { NodeWrapper } from './node-wrapper';
// TODO: do not allow other nodes to connect to this node
function InnerFileNode({ data, id, selected }: NodeProps<IBeginNode>) {
const { t } = useTranslation();
const inputs: Record<string, BeginQuery> = get(data, 'form.inputs', {});
return (
<NodeWrapper selected={selected}>
<CommonHandle
type="source"
position={Position.Right}
isConnectable
style={RightHandleStyle}
nodeId={id}
id={NodeHandleId.Start}
></CommonHandle>
<section className="flex items-center gap-2">
<OperatorIcon name={data.label as Operator}></OperatorIcon>
<div className="truncate text-center font-semibold text-sm">
{t(`flow.begin`)}
</div>
</section>
<section className={cn(styles.generateParameters, 'flex gap-2 flex-col')}>
{Object.entries(inputs).map(([key, val], idx) => {
const Icon = BeginQueryTypeIconMap[val.type as BeginQueryType];
return (
<div
key={idx}
className={cn(styles.conditionBlock, 'flex gap-1.5 items-center')}
>
<Icon className="size-4" />
<label htmlFor="">{key}</label>
<span className={styles.parameterValue}>{val.name}</span>
<span className="flex-1">{val.optional ? 'Yes' : 'No'}</span>
</div>
);
})}
</section>
</NodeWrapper>
);
}
export const FileNode = memo(InnerFileNode);

View File

@@ -0,0 +1,60 @@
import LLMLabel from '@/components/llm-select/llm-label';
import { useTheme } from '@/components/theme-provider';
import { IGenerateNode } from '@/interfaces/database/flow';
import { Handle, NodeProps, Position } from '@xyflow/react';
import classNames from 'classnames';
import { get } from 'lodash';
import { memo } from 'react';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
export function InnerGenerateNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IGenerateNode>) {
const { theme } = useTheme();
return (
<section
className={classNames(
styles.logicNode,
theme === 'dark' ? styles.dark : '',
{
[styles.selectedNode]: selected,
},
)}
>
<Handle
id="c"
type="source"
position={Position.Left}
isConnectable={isConnectable}
className={styles.handle}
style={LeftHandleStyle}
></Handle>
<Handle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
style={RightHandleStyle}
id="b"
></Handle>
<NodeHeader
id={id}
name={data.name}
label={data.label}
className={styles.nodeHeader}
></NodeHeader>
<div className={styles.nodeText}>
<LLMLabel value={get(data, 'form.llm_id')}></LLMLabel>
</div>
</section>
);
}
export const GenerateNode = memo(InnerGenerateNode);

View File

@@ -0,0 +1,20 @@
import { PlusOutlined } from '@ant-design/icons';
import { CSSProperties } from 'react';
export const HandleIcon = () => {
return (
<PlusOutlined
style={{ fontSize: 6, color: 'white', position: 'absolute', zIndex: 10 }}
/>
);
};
export const RightHandleStyle: CSSProperties = {
right: 0,
};
export const LeftHandleStyle: CSSProperties = {
left: 0,
};
export default HandleIcon;

View File

@@ -0,0 +1,92 @@
import { useSetModalState } from '@/hooks/common-hooks';
import { cn } from '@/lib/utils';
import { Handle, HandleProps, Position } from '@xyflow/react';
import { Plus } from 'lucide-react';
import { useMemo } from 'react';
import { NodeHandleId } from '../../constant';
import { HandleContext } from '../../context';
import { useIsPipeline } from '../../hooks/use-is-pipeline';
import useGraphStore from '../../store';
import { useDropdownManager } from '../context';
import { NextStepDropdown } from './dropdown/next-step-dropdown';
export function CommonHandle({
className,
nodeId,
...props
}: HandleProps & { nodeId: string }) {
const { visible, hideModal, showModal } = useSetModalState();
const { canShowDropdown, setActiveDropdown, clearActiveDropdown } =
useDropdownManager();
const { hasChildNode } = useGraphStore((state) => state);
const isPipeline = useIsPipeline();
const isConnectable = !(isPipeline && hasChildNode(nodeId)); // Using useMemo will cause isConnectable to not be updated when the subsequent connection line is deleted
const value = useMemo(
() => ({
nodeId,
id: props.id || undefined,
type: props.type,
position: props.position,
isFromConnectionDrag: false,
}),
[nodeId, props.id, props.position, props.type],
);
return (
<HandleContext.Provider value={value}>
<Handle
{...props}
isConnectable={isConnectable}
className={cn(
'inline-flex justify-center items-center !bg-accent-primary !border-none group-hover:!size-4 group-hover:!rounded-sm',
className,
)}
onClick={(e) => {
e.stopPropagation();
if (!isConnectable) {
return;
}
if (!canShowDropdown()) {
return;
}
setActiveDropdown('handle');
showModal();
}}
>
<Plus className="size-3 pointer-events-none text-white hidden group-hover:inline-block" />
{visible && (
<NextStepDropdown
nodeId={nodeId}
hideModal={() => {
hideModal();
clearActiveDropdown();
}}
>
<span></span>
</NextStepDropdown>
)}
</Handle>
</HandleContext.Provider>
);
}
export function LeftEndHandle({
isConnectable,
...props
}: Omit<HandleProps, 'type' | 'position'>) {
return (
<Handle
isConnectable={isConnectable}
className="!bg-accent-primary !size-2"
id={NodeHandleId.End}
type="target"
position={Position.Left}
{...props}
></Handle>
);
}

View File

@@ -0,0 +1,298 @@
.dark {
background: rgb(63, 63, 63) !important;
}
.ragNode {
.commonNode();
.nodeName {
font-size: 10px;
color: black;
}
label {
display: block;
color: #777;
font-size: 12px;
}
.description {
font-size: 10px;
}
.categorizeAnchorPointText {
position: absolute;
top: -4px;
left: 8px;
white-space: nowrap;
}
}
@lightBackgroundColor: rgba(150, 150, 150, 0.1);
@darkBackgroundColor: rgba(150, 150, 150, 0.2);
.selectedNode {
border: 1.5px solid rgb(59, 118, 244);
}
.selectedIterationNode {
border-bottom: 1.5px solid rgb(59, 118, 244);
border-left: 1.5px solid rgb(59, 118, 244);
border-right: 1.5px solid rgb(59, 118, 244);
}
.iterationHeader {
.commonNodeShadow();
}
.selectedHeader {
border-top: 1.9px solid rgb(59, 118, 244);
border-left: 1.9px solid rgb(59, 118, 244);
border-right: 1.9px solid rgb(59, 118, 244);
}
.handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
background: rgb(59, 88, 253);
border: 1px solid white;
z-index: 1;
background-image: url('@/assets/svg/plus.svg');
background-size: cover;
background-position: center;
}
.jsonView {
word-wrap: break-word;
overflow: auto;
max-width: 300px;
max-height: 500px;
}
.logicNode {
.commonNode();
.nodeName {
font-size: 10px;
color: black;
}
label {
display: block;
color: #777;
font-size: 12px;
}
.description {
font-size: 10px;
}
.categorizeAnchorPointText {
position: absolute;
top: -4px;
left: 8px;
white-space: nowrap;
}
.relevantSourceLabel {
font-size: 10px;
}
}
.noteNode {
.commonNode();
min-width: 140px;
width: auto;
height: 100%;
padding: 8px;
border-radius: 10px;
min-height: 128px;
.noteTitle {
background-color: #edfcff;
font-size: 12px;
padding: 6px 6px 4px;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
.noteTitleDark {
background-color: #edfcff;
font-size: 12px;
padding: 6px 6px 4px;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
.noteForm {
margin-top: 4px;
height: calc(100% - 50px);
}
.noteName {
padding: 0px 4px;
}
.noteTextarea {
resize: none;
border: 0;
border-radius: 0;
height: 100%;
&:focus {
border: none;
box-shadow: none;
}
}
}
.iterationNode {
.commonNodeShadow();
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
}
.nodeText {
padding-inline: 0.4em;
padding-block: 0.2em 0.1em;
background: @lightBackgroundColor;
border-radius: 3px;
min-height: 22px;
.textEllipsis();
}
.nodeHeader {
padding-bottom: 12px;
}
.zeroDivider {
margin: 0 !important;
}
.conditionBlock {
border-radius: 4px;
padding: 6px;
background: @lightBackgroundColor;
}
.conditionLine {
border-radius: 4px;
padding: 0 4px;
background: @darkBackgroundColor;
.textEllipsis();
}
.conditionKey {
flex: 1;
}
.conditionOperator {
padding: 0 2px;
text-align: center;
}
.relevantLabel {
text-align: right;
}
.knowledgeNodeName {
.textEllipsis();
}
.messageNodeContainer {
overflow-y: auto;
max-height: 300px;
}
.generateParameters {
padding-top: 8px;
label {
flex: 2;
.textEllipsis();
}
.parameterValue {
flex: 3;
.conditionLine;
}
}
.emailNodeContainer {
padding: 8px;
font-size: 12px;
.emailConfig {
background: rgba(0, 0, 0, 0.02);
border-radius: 4px;
padding: 8px;
position: relative;
cursor: pointer;
&:hover {
background: rgba(0, 0, 0, 0.04);
}
.configItem {
display: flex;
align-items: center;
margin-bottom: 4px;
&:last-child {
margin-bottom: 0;
}
.configLabel {
color: #666;
width: 45px;
flex-shrink: 0;
}
.configValue {
color: #333;
word-break: break-all;
}
}
.expandIcon {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
color: #666;
font-size: 12px;
}
}
.jsonExample {
background: #f5f5f5;
border-radius: 4px;
padding: 8px;
margin-top: 4px;
animation: slideDown 0.2s ease-out;
.jsonTitle {
color: #666;
margin-bottom: 4px;
}
.jsonContent {
margin: 0;
color: #333;
font-family: monospace;
}
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hideScrollbar {
/* Webkit browsers (Chrome, Safari, Edge) */
&::-webkit-scrollbar {
display: none;
}
/* Firefox */
scrollbar-width: none;
/* IE和Edge */
-ms-overflow-style: none;
}

View File

@@ -0,0 +1,43 @@
import { IRagNode } from '@/interfaces/database/flow';
import { NodeProps, Position } from '@xyflow/react';
import { memo } from 'react';
import { NodeHandleId } from '../../constant';
import { needsSingleStepDebugging, showCopyIcon } from '../../utils';
import { CommonHandle, LeftEndHandle } from './handle';
import { RightHandleStyle } from './handle-icon';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
function InnerRagNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IRagNode>) {
return (
<ToolBar
selected={selected}
id={id}
label={data.label}
showRun={needsSingleStepDebugging(data.label)}
showCopy={showCopyIcon(data.label)}
>
<NodeWrapper selected={selected}>
<LeftEndHandle></LeftEndHandle>
<CommonHandle
type="source"
position={Position.Right}
isConnectable={isConnectable}
id={NodeHandleId.Start}
style={RightHandleStyle}
nodeId={id}
isConnectableEnd={false}
></CommonHandle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
</NodeWrapper>
</ToolBar>
);
}
export const RagNode = memo(InnerRagNode);

View File

@@ -0,0 +1,62 @@
import { useTheme } from '@/components/theme-provider';
import { IInvokeNode } from '@/interfaces/database/flow';
import { Handle, NodeProps, Position } from '@xyflow/react';
import { Flex } from 'antd';
import classNames from 'classnames';
import { get } from 'lodash';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
function InnerInvokeNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IInvokeNode>) {
const { t } = useTranslation();
const { theme } = useTheme();
const url = get(data, 'form.url');
return (
<section
className={classNames(
styles.ragNode,
theme === 'dark' ? styles.dark : '',
{
[styles.selectedNode]: selected,
},
)}
>
<Handle
id="c"
type="source"
position={Position.Left}
isConnectable={isConnectable}
className={styles.handle}
style={LeftHandleStyle}
></Handle>
<Handle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
id="b"
style={RightHandleStyle}
></Handle>
<NodeHeader
id={id}
name={data.name}
label={data.label}
className={styles.nodeHeader}
></NodeHeader>
<Flex vertical>
<div>{t('flow.url')}</div>
<div className={styles.nodeText}>{url}</div>
</Flex>
</section>
);
}
export const InvokeNode = memo(InnerInvokeNode);

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