mirror of
https://github.com/infiniflow/ragflow.git
synced 2025-12-25 16:21:41 +08:00
### 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:
234
web/src/pages/dataflow-result/chunker.tsx
Normal file
234
web/src/pages/dataflow-result/chunker.tsx
Normal 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 };
|
||||
@ -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;
|
||||
}
|
||||
127
web/src/pages/dataflow-result/components/chunk-card/index.tsx
Normal file
127
web/src/pages/dataflow-result/components/chunk-card/index.tsx
Normal 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;
|
||||
@ -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;
|
||||
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
221
web/src/pages/dataflow-result/components/chunk-toolbar/index.tsx
Normal file
221
web/src/pages/dataflow-result/components/chunk-toolbar/index.tsx
Normal 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;
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
@ -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);
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
@ -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;
|
||||
82
web/src/pages/dataflow-result/components/time-line/index.tsx
Normal file
82
web/src/pages/dataflow-result/components/time-line/index.tsx
Normal 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;
|
||||
4
web/src/pages/dataflow-result/constant.ts
Normal file
4
web/src/pages/dataflow-result/constant.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum ChunkTextMode {
|
||||
Full = 'full',
|
||||
Ellipse = 'ellipse',
|
||||
}
|
||||
185
web/src/pages/dataflow-result/hooks.ts
Normal file
185
web/src/pages/dataflow-result/hooks.ts
Normal 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 };
|
||||
};
|
||||
96
web/src/pages/dataflow-result/index.less
Normal file
96
web/src/pages/dataflow-result/index.less
Normal 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;
|
||||
}
|
||||
121
web/src/pages/dataflow-result/index.tsx
Normal file
121
web/src/pages/dataflow-result/index.tsx
Normal 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;
|
||||
58
web/src/pages/dataflow-result/parser.tsx
Normal file
58
web/src/pages/dataflow-result/parser.tsx
Normal 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;
|
||||
24
web/src/pages/dataflow-result/utils.ts
Normal file
24
web/src/pages/dataflow-result/utils.ts
Normal 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;
|
||||
}, []);
|
||||
}
|
||||
Reference in New Issue
Block a user