feat: Added UI functions related to data-flow knowledge base #3221 (#10038)

### What problem does this PR solve?

feat: Added UI functions related to data-flow knowledge base #3221

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
chanx
2025-09-11 09:51:18 +08:00
committed by GitHub
parent df8d31451b
commit 8a09f07186
64 changed files with 5079 additions and 81 deletions

View File

@ -0,0 +1,234 @@
import message from '@/components/ui/message';
import {
RAGFlowPagination,
RAGFlowPaginationType,
} from '@/components/ui/ragflow-pagination';
import { Spin } from '@/components/ui/spin';
import {
useFetchNextChunkList,
useSwitchChunk,
} from '@/hooks/use-chunk-request';
import classNames from 'classnames';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ChunkCard from './components/chunk-card';
import CreatingModal from './components/chunk-creating-modal';
import ChunkResultBar from './components/chunk-result-bar';
import CheckboxSets from './components/chunk-result-bar/checkbox-sets';
import RerunButton from './components/rerun-button';
import {
useChangeChunkTextMode,
useDeleteChunkByIds,
useHandleChunkCardClick,
useUpdateChunk,
} from './hooks';
import styles from './index.less';
const ChunkerContainer = () => {
const [selectedChunkIds, setSelectedChunkIds] = useState<string[]>([]);
const [isChange, setIsChange] = useState(false);
const { t } = useTranslation();
const {
data: { documentInfo, data = [], total },
pagination,
loading,
searchString,
handleInputChange,
available,
handleSetAvailable,
} = useFetchNextChunkList();
const { handleChunkCardClick, selectedChunkId } = useHandleChunkCardClick();
const isPdf = documentInfo?.type === 'pdf';
const {
chunkUpdatingLoading,
onChunkUpdatingOk,
showChunkUpdatingModal,
hideChunkUpdatingModal,
chunkId,
chunkUpdatingVisible,
documentId,
} = useUpdateChunk();
const { removeChunk } = useDeleteChunkByIds();
const { changeChunkTextMode, textMode } = useChangeChunkTextMode();
const selectAllChunk = useCallback(
(checked: boolean) => {
setSelectedChunkIds(checked ? data.map((x) => x.chunk_id) : []);
},
[data],
);
const showSelectedChunkWarning = useCallback(() => {
message.warning(t('message.pleaseSelectChunk'));
}, [t]);
const { switchChunk } = useSwitchChunk();
const [chunkList, setChunkList] = useState(data);
useEffect(() => {
setChunkList(data);
}, [data]);
const onPaginationChange: RAGFlowPaginationType['onChange'] = (
page,
size,
) => {
setSelectedChunkIds([]);
pagination.onChange?.(page, size);
};
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 (ids?.length && resCode === 0) {
chunkList.forEach((x: any) => {
if (ids.indexOf(x['chunk_id']) > -1) {
x['available_int'] = available;
}
});
setChunkList(chunkList);
}
},
[
switchChunk,
documentId,
selectedChunkIds,
showSelectedChunkWarning,
chunkList,
],
);
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 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 handleChunkEditSave = (e: any) => {
setIsChange(true);
onChunkUpdatingOk(e);
};
return (
<>
{isChange && (
<div className=" absolute top-2 right-6">
<RerunButton />
</div>
)}
<div
className={classNames(
{ [styles.pagePdfWrapper]: isPdf },
'flex flex-col w-3/5',
)}
>
<Spin spinning={loading} className={styles.spin} size="large">
<div className="h-[50px] flex flex-row justify-between items-end pb-[5px]">
<div>
<h2 className="text-[16px]">{t('chunk.chunkResult')}</h2>
<div className="text-[12px] text-text-secondary italic">
{t('chunk.chunkResultTip')}
</div>
</div>
<ChunkResultBar
handleInputChange={handleInputChange}
searchString={searchString}
changeChunkTextMode={changeChunkTextMode}
createChunk={showChunkUpdatingModal}
available={available}
selectAllChunk={selectAllChunk}
handleSetAvailable={handleSetAvailable}
/>
</div>
<div className=" rounded-[16px] box-border mb-2">
<div className="pt-[5px] pb-[5px]">
<CheckboxSets
selectAllChunk={selectAllChunk}
switchChunk={handleSwitchChunk}
removeChunk={handleRemoveChunk}
checked={selectedChunkIds.length === data.length}
selectedChunkIds={selectedChunkIds}
/>
</div>
<div className="h-[calc(100vh-280px)] overflow-y-auto pr-2 scrollbar-thin">
<div
className={classNames(
styles.chunkContainer,
{
[styles.chunkOtherContainer]: !isPdf,
},
'flex flex-col gap-4',
)}
>
{chunkList.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>
))}
</div>
</div>
<div className={styles.pageFooter}>
<RAGFlowPagination
pageSize={pagination.pageSize}
current={pagination.current}
total={total}
onChange={(page, pageSize) => {
onPaginationChange(page, pageSize);
}}
></RAGFlowPagination>
</div>
</div>
</Spin>
</div>
{chunkUpdatingVisible && (
<CreatingModal
doc_id={documentId}
chunkId={chunkId}
hideModal={hideChunkUpdatingModal}
visible={chunkUpdatingVisible}
loading={chunkUpdatingLoading}
onOk={(e) => {
handleChunkEditSave(e);
}}
parserId={documentInfo.parser_id}
/>
)}
</>
);
};
export { ChunkerContainer };

View File

@ -0,0 +1,36 @@
.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%;
padding: 18px 10px;
}
.cardSelected {
background-color: @selectedBackgroundColor;
}
.cardSelectedDark {
background-color: #ffffff2f;
}

View File

@ -0,0 +1,127 @@
import Image from '@/components/image';
import { useTheme } from '@/components/theme-provider';
import { Card } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Switch } from '@/components/ui/switch';
import { IChunk } from '@/interfaces/database/knowledge';
import { CheckedState } from '@radix-ui/react-checkbox';
import classNames from 'classnames';
import DOMPurify from 'dompurify';
import { useEffect, useState } from 'react';
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 = (e: CheckedState) => {
handleCheckboxClick(item.chunk_id, e === 'indeterminate' ? false : e);
};
const handleContentDoubleClick = () => {
editChunk(item.chunk_id);
};
const handleContentClick = () => {
clickChunkCard(item.chunk_id);
};
useEffect(() => {
setEnabled(available === 1);
}, [available]);
const [open, setOpen] = useState<boolean>(false);
return (
<Card
className={classNames('rounded-lg w-full py-3 px-3', {
'bg-bg-title': selected,
'bg-bg-input': !selected,
})}
>
<div className="flex items-start justify-between gap-2">
<Checkbox onCheckedChange={handleCheck} checked={checked}></Checkbox>
{item.image_id && (
<Popover open={open}>
<PopoverTrigger
asChild
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<div>
<Image id={item.image_id} className={styles.image}></Image>
</div>
</PopoverTrigger>
<PopoverContent
className="p-0"
align={'start'}
side={'right'}
sideOffset={-20}
>
<div>
<Image
id={item.image_id}
className={styles.imagePreview}
></Image>
</div>
</PopoverContent>
</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}
onCheckedChange={onChange}
aria-readonly
className="!m-0"
/>
</div>
</div>
</Card>
);
};
export default ChunkCard;

View File

@ -0,0 +1,206 @@
import EditTag from '@/components/edit-tag';
import Divider from '@/components/ui/divider';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card';
import { Modal } from '@/components/ui/modal/modal';
import Space from '@/components/ui/space';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { useFetchChunk } from '@/hooks/chunk-hooks';
import { IModalProps } from '@/interfaces/common';
import { Trash2 } from 'lucide-react';
import React, { useCallback, useEffect, useState } from 'react';
import { FieldValues, FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useDeleteChunkByIds } from '../../hooks';
import {
transformTagFeaturesArrayToObject,
transformTagFeaturesObjectToArray,
} from '../../utils';
import { TagFeatureItem } from './tag-feature-item';
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 form = useFormContext();
const form = useForm<FieldValues>({
defaultValues: {
content_with_weight: '',
tag_kwd: [],
question_kwd: [],
important_kwd: [],
tag_feas: [],
},
});
const [checked, setChecked] = useState(false);
const { removeChunk } = useDeleteChunkByIds();
const { data } = useFetchChunk(chunkId);
const { t } = useTranslation();
const isTagParser = parserId === 'tag';
const onSubmit = useCallback(
(values: FieldValues) => {
onOk?.({
...values,
tag_feas: transformTagFeaturesArrayToObject(values.tag_feas),
available_int: checked ? 1 : 0,
});
},
[checked, onOk],
);
const handleOk = form.handleSubmit(onSubmit);
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.reset({
...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}
confirmLoading={loading}
destroyOnClose
>
<Form {...form}>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="content_with_weight"
render={({ field }) => (
<FormItem>
<FormLabel>{t('chunk.chunk')}</FormLabel>
<FormControl>
<Textarea {...field} autoSize={{ minRows: 4, maxRows: 10 }} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="important_kwd"
render={({ field }) => (
<FormItem>
<FormLabel>{t('chunk.keyword')}</FormLabel>
<FormControl>
<EditTag {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="question_kwd"
render={({ field }) => (
<FormItem>
<FormLabel className="flex justify-start items-start">
<div className="flex items-center gap-0">
<span>{t('chunk.question')}</span>
<HoverCard>
<HoverCardTrigger asChild>
<span className="text-xs mt-[-3px] text-center scale-[90%] font-thin text-primary cursor-pointer rounded-full w-[16px] h-[16px] border-muted-foreground/50 border">
?
</span>
</HoverCardTrigger>
<HoverCardContent className="w-80" side="top">
{t('chunk.questionTip')}
</HoverCardContent>
</HoverCard>
</div>
</FormLabel>
<FormControl>
<EditTag {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isTagParser && (
<FormField
control={form.control}
name="tag_kwd"
render={({ field }) => (
<FormItem>
<FormLabel>{t('knowledgeConfiguration.tagName')}</FormLabel>
<FormControl>
<EditTag {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{!isTagParser && (
<FormProvider {...form}>
<TagFeatureItem />
</FormProvider>
)}
</div>
</Form>
{chunkId && (
<section>
<Divider />
<Space size={'large'}>
<div className="flex items-center gap-2">
{t('chunk.enabled')}
<Switch checked={checked} onCheckedChange={handleCheck} />
</div>
<div className="flex items-center gap-1" onClick={handleRemove}>
<Trash2 size={16} /> {t('common.delete')}
</div>
</Space>
</section>
)}
</Modal>
);
};
export default ChunkCreatingModal;

View File

@ -0,0 +1,136 @@
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { Button } from '@/components/ui/button';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { NumberInput } from '@/components/ui/input';
import { useFetchTagListByKnowledgeIds } from '@/hooks/knowledge-hooks';
import { useFetchKnowledgeBaseConfiguration } from '@/hooks/use-knowledge-request';
import { CircleMinus, Plus } from 'lucide-react';
import { useCallback, useEffect, useMemo } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { FormListItem } from '../../utils';
const FieldKey = 'tag_feas';
export const TagFeatureItem = () => {
const { t } = useTranslation();
const { setKnowledgeIds, list } = useFetchTagListByKnowledgeIds();
const { data: knowledgeConfiguration } = useFetchKnowledgeBaseConfiguration();
const form = useFormContext();
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.getValues(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.
const resultList = options.filter(
(x) => !list.some((y) => x.value === y),
);
return resultList;
},
[form, options],
);
useEffect(() => {
setKnowledgeIds(tagKnowledgeIds);
}, [setKnowledgeIds, tagKnowledgeIds]);
const { fields, append, remove } = useFieldArray({
control: form.control,
name: FieldKey,
});
return (
<FormField
control={form.control}
name={FieldKey as any}
render={() => (
<FormItem>
<FormLabel>{t('knowledgeConfiguration.tags')}</FormLabel>
<div>
{fields.map((item, name) => {
return (
<div key={item.id} className="flex gap-3 items-center mb-4">
<div className="flex flex-1 gap-8">
<FormField
control={form.control}
name={`${FieldKey}.${name}.tag` as any}
render={({ field }) => (
<FormItem className="w-2/3">
<FormControl className="w-full">
<div>
<SelectWithSearch
options={filterOptions(name)}
placeholder={t(
'knowledgeConfiguration.tagName',
)}
value={field.value}
onChange={field.onChange}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`${FieldKey}.${name}.frequency`}
render={({ field }) => (
<FormItem>
<FormControl>
<NumberInput
value={field.value}
onChange={field.onChange}
placeholder={t(
'knowledgeConfiguration.frequency',
)}
max={10}
min={0}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<CircleMinus
onClick={() => remove(name)}
className="text-red-500"
/>
</div>
);
})}
<Button
variant="dashed"
className="w-full flex items-center justify-center gap-2"
onClick={() => append({ tag: '', frequency: 0 })}
>
<Plus size={16} />
{t('knowledgeConfiguration.addTag')}
</Button>
</div>
</FormItem>
)}
/>
);
};

View File

@ -0,0 +1,85 @@
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Ban, CircleCheck, Trash2 } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type ICheckboxSetProps = {
selectAllChunk: (e: any) => void;
removeChunk: (e?: any) => void;
switchChunk: (available: number) => void;
checked: boolean;
selectedChunkIds: string[];
};
export default (props: ICheckboxSetProps) => {
const {
selectAllChunk,
removeChunk,
switchChunk,
checked,
selectedChunkIds,
} = props;
const { t } = useTranslation();
const handleSelectAllCheck = useCallback(
(e: any) => {
console.log('eee=', e);
selectAllChunk(e);
},
[selectAllChunk],
);
const handleDeleteClick = useCallback(() => {
removeChunk();
}, [removeChunk]);
const handleEnabledClick = useCallback(() => {
switchChunk(1);
}, [switchChunk]);
const handleDisabledClick = useCallback(() => {
switchChunk(0);
}, [switchChunk]);
const isSelected = useMemo(() => {
return selectedChunkIds?.length > 0;
}, [selectedChunkIds]);
return (
<div className="flex gap-[40px] py-4 px-2">
<div className="flex items-center gap-3 cursor-pointer text-muted-foreground hover:text-text-primary">
<Checkbox
id="all_chunks_checkbox"
onCheckedChange={handleSelectAllCheck}
checked={checked}
className=" data-[state=checked]:bg-text-primary data-[state=checked]:border-text-primary data-[state=checked]:text-bg-base border-muted-foreground text-muted-foreground hover:text-bg-base hover:border-text-primary "
/>
<Label htmlFor="all_chunks_checkbox">{t('chunk.selectAll')}</Label>
</div>
{isSelected && (
<>
<div
className="flex items-center cursor-pointer text-muted-foreground hover:text-text-primary"
onClick={handleEnabledClick}
>
<CircleCheck size={16} />
<span className="block ml-1">{t('chunk.enable')}</span>
</div>
<div
className="flex items-center cursor-pointer text-muted-foreground hover:text-text-primary"
onClick={handleDisabledClick}
>
<Ban size={16} />
<span className="block ml-1">{t('chunk.disable')}</span>
</div>
<div
className="flex items-center cursor-pointer text-red-400 hover:text-red-500"
onClick={handleDeleteClick}
>
<Trash2 size={16} />
<span className="block ml-1">{t('chunk.delete')}</span>
</div>
</>
)}
</div>
);
};

View File

@ -0,0 +1,108 @@
import { Input } from '@/components/originui/input';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Radio } from '@/components/ui/radio';
import { useTranslate } from '@/hooks/common-hooks';
import { cn } from '@/lib/utils';
import { SearchOutlined } from '@ant-design/icons';
import { ListFilter, Plus } from 'lucide-react';
import { useState } from 'react';
import { ChunkTextMode } from '../../constant';
interface ChunkResultBarProps {
changeChunkTextMode: React.Dispatch<React.SetStateAction<string | number>>;
available: number | undefined;
selectAllChunk: (value: boolean) => void;
handleSetAvailable: (value: number | undefined) => void;
createChunk: () => void;
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
searchString: string;
}
export default ({
changeChunkTextMode,
available,
selectAllChunk,
handleSetAvailable,
createChunk,
handleInputChange,
searchString,
}: ChunkResultBarProps) => {
const { t } = useTranslate('chunk');
const [textSelectValue, setTextSelectValue] = useState<string | number>(
ChunkTextMode.Full,
);
const handleFilterChange = (e: string | number) => {
const value = e === -1 ? undefined : (e as number);
selectAllChunk(false);
handleSetAvailable(value);
};
const filterContent = (
<div className="w-[200px]">
<Radio.Group onChange={handleFilterChange} value={available}>
<div className="flex flex-col gap-2 p-4">
<Radio value={-1}>{t('all')}</Radio>
<Radio value={1}>{t('enabled')}</Radio>
<Radio value={0}>{t('disabled')}</Radio>
</div>
</Radio.Group>
</div>
);
const textSelectOptions = [
{ label: t(ChunkTextMode.Full), value: ChunkTextMode.Full },
{ label: t(ChunkTextMode.Ellipse), value: ChunkTextMode.Ellipse },
];
const changeTextSelectValue = (value: string | number) => {
setTextSelectValue(value);
changeChunkTextMode(value);
};
return (
<div className="flex gap-2">
<div className="flex items-center gap-1 bg-bg-card text-muted-foreground w-fit h-[35px] rounded-md p-1">
{textSelectOptions.map((option) => (
<div
key={option.value}
className={cn(
'flex items-center cursor-pointer px-4 py-1 rounded-md',
{
'text-primary bg-bg-base': option.value === textSelectValue,
'text-text-primary': option.value !== textSelectValue,
},
)}
onClick={() => changeTextSelectValue(option.value)}
>
{option.label}
</div>
))}
</div>
<Input
className="bg-bg-card text-muted-foreground"
style={{ width: 200 }}
placeholder={t('search')}
icon={<SearchOutlined />}
onChange={handleInputChange}
value={searchString}
/>
<Popover>
<PopoverTrigger asChild>
<Button className="bg-bg-card text-muted-foreground hover:bg-card">
<ListFilter />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[200px]">
{filterContent}
</PopoverContent>
</Popover>
<Button
onClick={() => createChunk()}
variant={'secondary'}
className="bg-bg-card text-muted-foreground hover:bg-card"
>
<Plus size={44} />
</Button>
</div>
);
};

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,114 @@
import message from '@/components/ui/message';
import { Spin } from '@/components/ui/spin';
import request from '@/utils/request';
import classNames from 'classnames';
import React, { useEffect, useRef, useState } from 'react';
interface CSVData {
rows: string[][];
headers: string[];
}
interface FileViewerProps {
className?: string;
url: string;
}
const CSVFileViewer: React.FC<FileViewerProps> = ({ url }) => {
const [data, setData] = useState<CSVData | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const containerRef = useRef<HTMLDivElement>(null);
// const url = useGetDocumentUrl();
const parseCSV = (csvText: string): CSVData => {
console.log('Parsing CSV data:', csvText);
const lines = csvText.split('\n');
const headers = lines[0].split(',').map((header) => header.trim());
const rows = lines
.slice(1)
.map((line) => line.split(',').map((cell) => cell.trim()));
return { headers, rows };
};
useEffect(() => {
const loadCSV = async () => {
try {
const res = await request(url, {
method: 'GET',
responseType: 'blob',
onError: () => {
message.error('file load failed');
setIsLoading(false);
},
});
// parse CSV file
const reader = new FileReader();
reader.readAsText(res.data);
reader.onload = () => {
const parsedData = parseCSV(reader.result as string);
console.log('file loaded successfully', reader.result);
setData(parsedData);
};
} catch (error) {
message.error('CSV file parse failed');
console.error('Error loading CSV file:', error);
} finally {
setIsLoading(false);
}
};
loadCSV();
return () => {
setData(null);
};
}, [url]);
return (
<div
ref={containerRef}
className={classNames(
'relative w-full h-full p-4 bg-background-paper border border-border-normal rounded-md',
'overflow-auto max-h-[80vh] p-2',
)}
>
{isLoading ? (
<div className="absolute inset-0 flex items-center justify-center">
<Spin />
</div>
) : data ? (
<table className="min-w-full divide-y divide-border-normal">
<thead className="bg-background-header-bar">
<tr>
{data.headers.map((header, index) => (
<th
key={`header-${index}`}
className="px-6 py-3 text-left text-sm font-medium text-text-primary"
>
{header}
</th>
))}
</tr>
</thead>
<tbody className="bg-background-paper divide-y divide-border-normal">
{data.rows.map((row, rowIndex) => (
<tr key={`row-${rowIndex}`}>
{row.map((cell, cellIndex) => (
<td
key={`cell-${rowIndex}-${cellIndex}`}
className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary"
>
{cell || '-'}
</td>
))}
</tr>
))}
</tbody>
</table>
) : null}
</div>
);
};
export default CSVFileViewer;

View File

@ -0,0 +1,70 @@
import message from '@/components/ui/message';
import { Spin } from '@/components/ui/spin';
import request from '@/utils/request';
import classNames from 'classnames';
import mammoth from 'mammoth';
import { useEffect, useState } from 'react';
interface DocPreviewerProps {
className?: string;
url: string;
}
export const DocPreviewer: React.FC<DocPreviewerProps> = ({
className,
url,
}) => {
// const url = useGetDocumentUrl();
const [htmlContent, setHtmlContent] = useState<string>('');
const [loading, setLoading] = useState(false);
const fetchDocument = async () => {
setLoading(true);
const res = await request(url, {
method: 'GET',
responseType: 'blob',
onError: () => {
message.error('Document parsing failed');
console.error('Error loading document:', url);
},
});
try {
const arrayBuffer = await res.data.arrayBuffer();
const result = await mammoth.convertToHtml(
{ arrayBuffer },
{ includeDefaultStyleMap: true },
);
const styledContent = result.value
.replace(/<p>/g, '<p class="mb-2">')
.replace(/<h(\d)>/g, '<h$1 class="font-semibold mt-4 mb-2">');
setHtmlContent(styledContent);
} catch (err) {
message.error('Document parsing failed');
console.error('Error parsing document:', err);
}
setLoading(false);
};
useEffect(() => {
if (url) {
fetchDocument();
}
}, [url]);
return (
<div
className={classNames(
'relative w-full h-full p-4 bg-background-paper border border-border-normal rounded-md',
className,
)}
>
{loading && (
<div className="absolute inset-0 flex items-center justify-center">
<Spin />
</div>
)}
{!loading && <div dangerouslySetInnerHTML={{ __html: htmlContent }} />}
</div>
);
};

View File

@ -0,0 +1,21 @@
import { formatDate } from '@/utils/date';
import { formatBytes } from '@/utils/file-util';
type Props = {
size: number;
name: string;
create_date: string;
};
export default ({ size, name, create_date }: Props) => {
const sizeName = formatBytes(size);
const dateStr = formatDate(create_date);
return (
<div>
<h2 className="text-[16px]">{name}</h2>
<div className="text-text-secondary text-[12px] pt-[5px]">
Size{sizeName} Uploaded Time{dateStr}
</div>
</div>
);
};

View File

@ -0,0 +1,25 @@
import { useFetchExcel } from '@/pages/document-viewer/hooks';
import classNames from 'classnames';
interface ExcelCsvPreviewerProps {
className?: string;
url: string;
}
export const ExcelCsvPreviewer: React.FC<ExcelCsvPreviewerProps> = ({
className,
url,
}) => {
// const url = useGetDocumentUrl();
const { containerRef } = useFetchExcel(url);
return (
<div
ref={containerRef}
className={classNames(
'relative w-full h-full p-4 bg-background-paper border border-border-normal rounded-md excel-csv-previewer',
className,
)}
></div>
);
};

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,73 @@
import message from '@/components/ui/message';
import { Spin } from '@/components/ui/spin';
import request from '@/utils/request';
import classNames from 'classnames';
import { useEffect, useState } from 'react';
interface ImagePreviewerProps {
className?: string;
url: string;
}
export const ImagePreviewer: React.FC<ImagePreviewerProps> = ({
className,
url,
}) => {
// const url = useGetDocumentUrl();
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const fetchImage = async () => {
setIsLoading(true);
const res = await request(url, {
method: 'GET',
responseType: 'blob',
onError: () => {
message.error('Failed to load image');
setIsLoading(false);
},
});
const objectUrl = URL.createObjectURL(res.data);
setImageSrc(objectUrl);
setIsLoading(false);
};
useEffect(() => {
if (url) {
fetchImage();
}
}, [url]);
useEffect(() => {
return () => {
if (imageSrc) {
URL.revokeObjectURL(imageSrc);
}
};
}, [imageSrc]);
return (
<div
className={classNames(
'relative w-full h-full p-4 bg-background-paper border border-border-normal rounded-md image-previewer',
className,
)}
>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Spin />
</div>
)}
{!isLoading && imageSrc && (
<div className="max-h-[80vh] overflow-auto p-2">
<img
src={imageSrc}
alt={'image'}
className="w-full h-auto max-w-full object-contain"
onLoad={() => URL.revokeObjectURL(imageSrc!)}
/>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,13 @@
.documentContainer {
width: 100%;
// height: calc(100vh - 284px);
height: calc(100vh - 170px);
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,68 @@
import { memo } from 'react';
import CSVFileViewer from './csv-preview';
import { DocPreviewer } from './doc-preview';
import { ExcelCsvPreviewer } from './excel-preview';
import { ImagePreviewer } from './image-preview';
import styles from './index.less';
import PdfPreviewer, { IProps } from './pdf-preview';
import { PptPreviewer } from './ppt-preview';
import { TxtPreviewer } from './txt-preview';
type PreviewProps = {
fileType: string;
className?: string;
url: string;
};
const Preview = ({
fileType,
className,
highlights,
setWidthAndHeight,
url,
}: PreviewProps & Partial<IProps>) => {
return (
<>
{fileType === 'pdf' && highlights && setWidthAndHeight && (
<section className={styles.documentPreview}>
<PdfPreviewer
highlights={highlights}
setWidthAndHeight={setWidthAndHeight}
url={url}
></PdfPreviewer>
</section>
)}
{['doc', 'docx'].indexOf(fileType) > -1 && (
<section>
<DocPreviewer className={className} url={url} />
</section>
)}
{['txt', 'md'].indexOf(fileType) > -1 && (
<section>
<TxtPreviewer className={className} url={url} />
</section>
)}
{['visual'].indexOf(fileType) > -1 && (
<section>
<ImagePreviewer className={className} url={url} />
</section>
)}
{['pptx'].indexOf(fileType) > -1 && (
<section>
<PptPreviewer className={className} url={url} />
</section>
)}
{['xlsx'].indexOf(fileType) > -1 && (
<section>
<ExcelCsvPreviewer className={className} url={url} />
</section>
)}
{['csv'].indexOf(fileType) > -1 && (
<section>
<CSVFileViewer className={className} url={url} />
</section>
)}
</>
);
};
export default memo(Preview);

View File

@ -0,0 +1,127 @@
import { memo, useEffect, useRef } from 'react';
import {
AreaHighlight,
Highlight,
IHighlight,
PdfHighlighter,
PdfLoader,
Popup,
} from 'react-pdf-highlighter';
import { useCatchDocumentError } from '@/components/pdf-previewer/hooks';
import { Spin } from '@/components/ui/spin';
import FileError from '@/pages/document-viewer/file-error';
import styles from './index.less';
export interface IProps {
highlights: IHighlight[];
setWidthAndHeight: (width: number, height: number) => void;
url: string;
}
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 PdfPreview = ({ highlights: state, setWidthAndHeight, url }: 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} rounded-[10px] overflow-hidden `}
>
<PdfLoader
url={url}
beforeLoad={
<div className="absolute inset-0 flex items-center justify-center">
<Spin />
</div>
}
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(PdfPreview);

View File

@ -0,0 +1,70 @@
import message from '@/components/ui/message';
import request from '@/utils/request';
import classNames from 'classnames';
import { init } from 'pptx-preview';
import { useEffect, useRef } from 'react';
interface PptPreviewerProps {
className?: string;
url: string;
}
export const PptPreviewer: React.FC<PptPreviewerProps> = ({
className,
url,
}) => {
// const url = useGetDocumentUrl();
const wrapper = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const fetchDocument = async () => {
const res = await request(url, {
method: 'GET',
responseType: 'blob',
onError: () => {
message.error('Document parsing failed');
console.error('Error loading document:', url);
},
});
console.log(res);
try {
const arrayBuffer = await res.data.arrayBuffer();
if (containerRef.current) {
let width = 500;
let height = 900;
if (containerRef.current) {
width = containerRef.current.clientWidth - 50;
height = containerRef.current.clientHeight - 50;
}
let pptxPrviewer = init(containerRef.current, {
width: width,
height: height,
});
pptxPrviewer.preview(arrayBuffer);
}
} catch (err) {
message.error('ppt parse failed');
}
};
useEffect(() => {
if (url) {
fetchDocument();
}
}, [url]);
return (
<div
ref={containerRef}
className={classNames(
'relative w-full h-full p-4 bg-background-paper border border-border-normal rounded-md ppt-previewer',
className,
)}
>
<div className="overflow-auto p-2">
<div className="flex flex-col gap-4">
<div ref={wrapper} />
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,56 @@
import message from '@/components/ui/message';
import { Spin } from '@/components/ui/spin';
import request from '@/utils/request';
import classNames from 'classnames';
import { useEffect, useState } from 'react';
type TxtPreviewerProps = { className?: string; url: string };
export const TxtPreviewer = ({ className, url }: TxtPreviewerProps) => {
// const url = useGetDocumentUrl();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<string>('');
const fetchTxt = async () => {
setLoading(true);
const res = await request(url, {
method: 'GET',
responseType: 'blob',
onError: (err: any) => {
message.error('Failed to load file');
console.error('Error loading file:', err);
},
});
// blob to string
const reader = new FileReader();
reader.readAsText(res.data);
reader.onload = () => {
setData(reader.result as string);
setLoading(false);
console.log('file loaded successfully', reader.result);
};
console.log('file data:', res);
};
useEffect(() => {
if (url) {
fetchTxt();
} else {
setLoading(false);
setData('');
}
}, [url]);
return (
<div
className={classNames(
'relative w-full h-full p-4 bg-background-paper border border-border-normal rounded-md',
className,
)}
>
{loading && (
<div className="absolute inset-0 flex items-center justify-center">
<Spin />
</div>
)}
{!loading && <pre className="whitespace-pre-wrap p-2 ">{data}</pre>}
</div>
);
};

View File

@ -0,0 +1,48 @@
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { useState } from 'react';
interface FormatPreserveEditorProps {
initialValue: string;
onSave: (value: string) => void;
className?: string;
}
const FormatPreserveEditor = ({
initialValue,
onSave,
className,
}: FormatPreserveEditorProps) => {
const [content, setContent] = useState(initialValue);
const [isEditing, setIsEditing] = useState(false);
const handleEdit = () => setIsEditing(true);
const handleSave = () => {
onSave(content);
setIsEditing(false);
};
return (
<div className="editor-container">
{isEditing ? (
<Textarea
className={cn(
'w-full h-full bg-transparent text-text-secondary',
className,
)}
value={content}
onChange={(e) => setContent(e.target.value)}
onBlur={handleSave}
autoSize={{ maxRows: 100 }}
autoFocus
/>
) : (
<pre className="text-text-secondary" onClick={handleEdit}>
{content}
</pre>
)}
</div>
);
};
export default FormatPreserveEditor;

View File

@ -0,0 +1,29 @@
import SvgIcon from '@/components/svg-icon';
import { Button } from '@/components/ui/button';
import { CircleAlert } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useRerunDataflow } from '../../hooks';
interface RerunButtonProps {
className?: string;
}
const RerunButton = (props: RerunButtonProps) => {
const { t } = useTranslation();
const { loading } = useRerunDataflow();
const clickFunc = () => {
console.log('click rerun button');
};
return (
<div className="flex flex-col gap-2">
<div className="text-xs text-text-primary flex items-center gap-1">
<CircleAlert color="#d29e2d" strokeWidth={1} size={12} />
{t('dataflowParser.rerunFromCurrentStepTip')}
</div>
<Button onClick={clickFunc} disabled={loading}>
<SvgIcon name="rerun" width={16} />
{t('dataflowParser.rerunFromCurrentStep')}
</Button>
</div>
);
};
export default RerunButton;

View File

@ -0,0 +1,82 @@
import { CustomTimeline, TimelineNode } from '@/components/originui/timeline';
import {
CheckLine,
FilePlayIcon,
Grid3x2,
ListPlus,
PlayIcon,
} from 'lucide-react';
import { useMemo } from 'react';
export const TimelineNodeObj = {
begin: {
id: 1,
title: 'Begin',
icon: <PlayIcon size={13} />,
clickable: false,
},
parser: { id: 2, title: 'Parser', icon: <FilePlayIcon size={13} /> },
chunker: { id: 3, title: 'Chunker', icon: <Grid3x2 size={13} /> },
indexer: {
id: 4,
title: 'Indexer',
icon: <ListPlus size={13} />,
clickable: false,
},
complete: {
id: 5,
title: 'Complete',
icon: <CheckLine size={13} />,
clickable: false,
},
};
export interface TimelineDataFlowProps {
activeId: number | string;
activeFunc: (id: number | string) => void;
}
const TimelineDataFlow = ({ activeFunc, activeId }: TimelineDataFlowProps) => {
// const [activeStep, setActiveStep] = useState(2);
const timelineNodes: TimelineNode[] = useMemo(() => {
const nodes: TimelineNode[] = [];
Object.keys(TimelineNodeObj).forEach((key) => {
nodes.push({
...TimelineNodeObj[key as keyof typeof TimelineNodeObj],
className: 'w-32',
completed: false,
});
});
return nodes;
}, []);
const activeStep = useMemo(() => {
const index = timelineNodes.findIndex((node) => node.id === activeId);
return index > -1 ? index + 1 : 0;
}, [activeId, timelineNodes]);
const handleStepChange = (step: number, id: string | number) => {
// setActiveStep(step);
activeFunc?.(id);
console.log(step, id);
};
return (
<div className="">
<div>
<CustomTimeline
nodes={timelineNodes as TimelineNode[]}
activeStep={activeStep}
onStepChange={handleStepChange}
orientation="horizontal"
lineStyle="solid"
nodeSize={24}
activeStyle={{
nodeSize: 30,
iconColor: 'var(--accent-primary)',
textColor: 'var(--accent-primary)',
}}
/>
</div>
</div>
);
};
export default TimelineDataFlow;

View File

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

View File

@ -0,0 +1,185 @@
import message from '@/components/ui/message';
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 { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
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,
};
};
export const useFetchParserList = () => {
const [loading, setLoading] = useState(false);
return {
loading,
};
};
export const useRerunDataflow = () => {
const [loading, setLoading] = useState(false);
return {
loading,
};
};
export const useFetchPaserText = () => {
const initialText =
'第一行文本\n\t第二行缩进文本\n第三行 多个空格 第一行文本\n\t第二行缩进文本\n第三行 ' +
'多个空格第一行文本\n\t第二行缩进文本\n第三行 多个空格第一行文本\n\t第二行缩进文本\n第三行 ' +
'多个空格第一行文本\n\t第二行缩进文本\n第三行 多个空格第一行文本\n\t第二行缩进文本\n第三行 ' +
'多个空格第一行文本\n\t第二行缩进文本\n第三行 多个空格第一行文本\n\t第二行缩进文本\n第三行 ' +
'多个空格第一行文本\n\t第二行缩进文本\n第三行 多个空格第一行文本\n\t第二行缩进文本\n第三行 ' +
'多个空格第一行文本\n\t第二行缩进文本\n第三行 多个空格第一行文本\n\t第二行缩进文本\n第三行 ' +
'多个空格第一行文本\n\t第二行缩进文本\n第三行 多个空格第一行文本\n\t第二行缩进文本\n第三行 多个空格';
const [loading, setLoading] = useState(false);
const [data, setData] = useState<string>(initialText);
const { t } = useTranslation();
const queryClient = useQueryClient();
const {
// data,
// isPending: loading,
mutateAsync,
} = useMutation({
mutationKey: ['createChunk'],
mutationFn: async (payload: any) => {
// let service = kbService.create_chunk;
// if (payload.chunk_id) {
// service = kbService.set_chunk;
// }
// const { data } = await service(payload);
// if (data.code === 0) {
message.success(t('message.created'));
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['fetchChunkList'] });
}, 1000); // Delay to ensure the list is updated
// }
// return data?.code;
},
});
return { data, loading, rerun: mutateAsync };
};

View File

@ -0,0 +1,96 @@
.chunkPage {
padding: 24px;
padding-top: 2px;
display: flex;
// height: calc(100vh - 112px);
height: 100vh;
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: calc(100vh - 180px);
overflow: auto;
}
.chunkContainer {
display: flex;
height: calc(100vh - 332px);
}
.chunkOtherContainer {
width: 100%;
}
.pageFooter {
padding-top: 10px;
padding-right: 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,121 @@
import { useFetchNextChunkList } from '@/hooks/use-chunk-request';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import DocumentPreview from './components/document-preview';
import { useGetChunkHighlights, useHandleChunkCardClick } from './hooks';
import DocumentHeader from './components/document-preview/document-header';
import { PageHeader } from '@/components/page-header';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import {
QueryStringMap,
useNavigatePage,
} from '@/hooks/logic-hooks/navigate-hooks';
import { useFetchKnowledgeBaseConfiguration } from '@/hooks/use-knowledge-request';
import { ChunkerContainer } from './chunker';
import { useGetDocumentUrl } from './components/document-preview/hooks';
import TimelineDataFlow, { TimelineNodeObj } from './components/time-line';
import styles from './index.less';
import ParserContainer from './parser';
const Chunk = () => {
const {
data: { documentInfo },
} = useFetchNextChunkList();
const { selectedChunkId } = useHandleChunkCardClick();
const [activeStepId, setActiveStepId] = useState<number | string>(0);
const { data: dataset } = useFetchKnowledgeBaseConfiguration();
const { t } = useTranslation();
const { navigateToDataset, getQueryString, navigateToDatasetList } =
useNavigatePage();
const fileUrl = useGetDocumentUrl();
const { highlights, setWidthAndHeight } =
useGetChunkHighlights(selectedChunkId);
const fileType = useMemo(() => {
switch (documentInfo?.type) {
case 'doc':
return documentInfo?.name.split('.').pop() || 'doc';
case 'visual':
case 'docx':
case 'txt':
case 'md':
case 'pdf':
return documentInfo?.type;
}
return 'unknown';
}, [documentInfo]);
const handleStepChange = (id: number | string) => {
setActiveStepId(id);
};
return (
<>
<PageHeader>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink onClick={navigateToDatasetList}>
{t('knowledgeDetails.dataset')}
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink
onClick={navigateToDataset(
getQueryString(QueryStringMap.id) as string,
)}
>
{dataset.name}
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{documentInfo?.name}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</PageHeader>
<div className=" absolute ml-[50%] translate-x-[-50%] top-4 flex justify-center">
<TimelineDataFlow
activeFunc={handleStepChange}
activeId={activeStepId}
/>
</div>
<div className={styles.chunkPage}>
<div className="flex flex-none gap-8 border border-border mt-[26px] p-3 rounded-lg h-[calc(100vh-100px)]">
<div className="w-2/5">
<div className="h-[50px] flex flex-col justify-end pb-[5px]">
<DocumentHeader {...documentInfo} />
</div>
<section className={styles.documentPreview}>
<DocumentPreview
className={styles.documentPreview}
fileType={fileType}
highlights={highlights}
setWidthAndHeight={setWidthAndHeight}
url={fileUrl}
></DocumentPreview>
</section>
</div>
<div className="h-dvh border-r -mt-3"></div>
{activeStepId === TimelineNodeObj.chunker.id && <ChunkerContainer />}
{activeStepId === TimelineNodeObj.parser.id && <ParserContainer />}
</div>
</div>
</>
);
};
export default Chunk;

View File

@ -0,0 +1,58 @@
import Spotlight from '@/components/spotlight';
import { Spin } from '@/components/ui/spin';
import classNames from 'classnames';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import FormatPreserveEditor from './components/parse-editer';
import RerunButton from './components/rerun-button';
import { useFetchParserList, useFetchPaserText } from './hooks';
const ParserContainer = () => {
const { data: initialValue, rerun: onSave } = useFetchPaserText();
const { t } = useTranslation();
const { loading } = useFetchParserList();
const [initialText, setInitialText] = useState(initialValue);
const [isChange, setIsChange] = useState(false);
const handleSave = (newContent: string) => {
console.log('保存内容:', newContent);
if (newContent !== initialText) {
setIsChange(true);
onSave(newContent);
} else {
setIsChange(false);
}
// Here, the API is called to send newContent to the backend
};
return (
<>
{isChange && (
<div className=" absolute top-2 right-6">
<RerunButton />
</div>
)}
<div className={classNames('flex flex-col w-3/5')}>
<Spin spinning={loading} className="" size="large">
<div className="h-[50px] flex flex-col justify-end pb-[5px]">
<div>
<h2 className="text-[16px]">
{t('dataflowParser.parseSummary')}
</h2>
<div className="text-[12px] text-text-secondary italic ">
{t('dataflowParser.parseSummaryTip')}
</div>
</div>
</div>
<div className=" border rounded-lg p-[20px] box-border h-[calc(100vh-180px)] overflow-auto scrollbar-none">
<FormatPreserveEditor
initialValue={initialText}
onSave={handleSave}
className="!h-[calc(100vh-220px)]"
/>
<Spotlight opcity={0.6} coverage={60} />
</div>
</Spin>
</div>
</>
);
};
export default ParserContainer;

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;
}, []);
}