Feat: Enhanced metadata functionality (#12560)

### What problem does this PR solve?

Feat: Enhanced metadata functionality
- Metadata filtering supports searching.
- Values ​​can be directly modified.

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
chanx
2026-01-12 19:05:33 +08:00
committed by GitHub
parent 653001b14f
commit fd0a1fde6b
9 changed files with 322 additions and 123 deletions

View File

@ -80,7 +80,7 @@ const FilterItem = memo(
} }
// className="hidden group-hover:block" // className="hidden group-hover:block"
/> />
<FormLabel <div
onClick={() => onClick={() =>
handleCheckChange({ handleCheckChange({
checked: !field.value?.includes(item.id.toString()), checked: !field.value?.includes(item.id.toString()),
@ -88,9 +88,10 @@ const FilterItem = memo(
item, item,
}) })
} }
className="truncate w-[200px] text-sm font-normal leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-text-secondary"
> >
{item.label} {item.label}
</FormLabel> </div>
</div> </div>
</FormControl> </FormControl>
</FormItem> </FormItem>
@ -101,7 +102,7 @@ const FilterItem = memo(
); );
}, },
); );
FilterItem.displayName = 'FilterItem';
export const FilterField = memo( export const FilterField = memo(
({ ({
item, item,

View File

@ -15,11 +15,17 @@ import { useForm } from 'react-hook-form';
import { z, ZodArray, ZodString } from 'zod'; import { z, ZodArray, ZodString } from 'zod';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Form, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { t } from 'i18next'; import { t } from 'i18next';
import { FilterField } from './filter-field'; import { FilterField } from './filter-field';
import { FilterChange, FilterCollection, FilterValue } from './interface'; import {
FilterChange,
FilterCollection,
FilterType,
FilterValue,
} from './interface';
export type CheckboxFormMultipleProps = { export type CheckboxFormMultipleProps = {
filters?: FilterCollection[]; filters?: FilterCollection[];
@ -30,6 +36,41 @@ export type CheckboxFormMultipleProps = {
filterGroup?: Record<string, string[]>; filterGroup?: Record<string, string[]>;
}; };
const filterNestedList = (
list: FilterType[],
searchTerm: string,
): FilterType[] => {
if (!searchTerm) return list;
const term = searchTerm.toLowerCase();
return list
.filter((item) => {
if (
item.label.toString().toLowerCase().includes(term) ||
item.id.toLowerCase().includes(term)
) {
return true;
}
if (item.list && item.list.length > 0) {
const filteredSubList = filterNestedList(item.list, searchTerm);
return filteredSubList.length > 0;
}
return false;
})
.map((item) => {
if (item.list && item.list.length > 0) {
return {
...item,
list: filterNestedList(item.list, searchTerm),
};
}
return item;
});
};
function CheckboxFormMultiple({ function CheckboxFormMultiple({
filters = [], filters = [],
value, value,
@ -37,21 +78,22 @@ function CheckboxFormMultiple({
setOpen, setOpen,
filterGroup, filterGroup,
}: CheckboxFormMultipleProps) { }: CheckboxFormMultipleProps) {
const [resolvedFilters, setResolvedFilters] = // const [resolvedFilters, setResolvedFilters] =
useState<FilterCollection[]>(filters); // useState<FilterCollection[]>(filters);
const [searchTerms, setSearchTerms] = useState<Record<string, string>>({});
useEffect(() => { // useEffect(() => {
if (filters && filters.length > 0) { // if (filters && filters.length > 0) {
setResolvedFilters(filters); // setResolvedFilters(filters);
} // }
}, [filters]); // }, [filters]);
const fieldsDict = useMemo(() => { const fieldsDict = useMemo(() => {
if (resolvedFilters.length === 0) { if (filters.length === 0) {
return {}; return {};
} }
return resolvedFilters.reduce<Record<string, any>>((pre, cur) => { return filters.reduce<Record<string, any>>((pre, cur) => {
const hasNested = cur.list?.some( const hasNested = cur.list?.some(
(item) => item.list && item.list.length > 0, (item) => item.list && item.list.length > 0,
); );
@ -63,14 +105,14 @@ function CheckboxFormMultiple({
} }
return pre; return pre;
}, {}); }, {});
}, [resolvedFilters]); }, [filters]);
const FormSchema = useMemo(() => { const FormSchema = useMemo(() => {
if (resolvedFilters.length === 0) { if (filters.length === 0) {
return z.object({}); return z.object({});
} }
return z.object( return z.object(
resolvedFilters.reduce< filters.reduce<
Record< Record<
string, string,
ZodArray<ZodString, 'many'> | z.ZodObject<any> | z.ZodOptional<any> ZodArray<ZodString, 'many'> | z.ZodObject<any> | z.ZodOptional<any>
@ -90,13 +132,10 @@ function CheckboxFormMultiple({
return pre; return pre;
}, {}), }, {}),
); );
}, [resolvedFilters]); }, [filters]);
// const FormSchema = useMemo(() => {
// return z.object({});
// }, []);
const form = useForm<z.infer<typeof FormSchema>>({ const form = useForm<z.infer<typeof FormSchema>>({
resolver: resolvedFilters.length > 0 ? zodResolver(FormSchema) : undefined, resolver: filters.length > 0 ? zodResolver(FormSchema) : undefined,
defaultValues: fieldsDict, defaultValues: fieldsDict,
}); });
@ -112,10 +151,10 @@ function CheckboxFormMultiple({
}, [fieldsDict, onChange, setOpen]); }, [fieldsDict, onChange, setOpen]);
useEffect(() => { useEffect(() => {
if (resolvedFilters.length > 0) { if (filters.length > 0) {
form.reset(value || fieldsDict); form.reset(value || fieldsDict);
} }
}, [form, value, resolvedFilters, fieldsDict]); }, [form, value, filters, fieldsDict]);
const filterList = useMemo(() => { const filterList = useMemo(() => {
const filterSet = filterGroup const filterSet = filterGroup
@ -131,6 +170,26 @@ function CheckboxFormMultiple({
return filters.filter((x) => !filterList.includes(x.field)); return filters.filter((x) => !filterList.includes(x.field));
}, [filterList, filters]); }, [filterList, filters]);
const handleSearchChange = (field: string, value: string) => {
setSearchTerms((prev) => ({
...prev,
[field]: value,
}));
};
const getFilteredFilters = (originalFilters: FilterCollection[]) => {
return originalFilters.map((filter) => {
if (filter.canSearch && searchTerms[filter.field]) {
const filteredList = filterNestedList(
filter.list,
searchTerms[filter.field],
);
return { ...filter, list: filteredList };
}
return filter;
});
};
return ( return (
<Form {...form}> <Form {...form}>
<form <form
@ -142,9 +201,11 @@ function CheckboxFormMultiple({
{filterGroup && {filterGroup &&
Object.keys(filterGroup).map((key) => { Object.keys(filterGroup).map((key) => {
const filterKeys = filterGroup[key]; const filterKeys = filterGroup[key];
const thisFilters = filters.filter((x) => const originalFilters = filters.filter((x) =>
filterKeys.includes(x.field), filterKeys.includes(x.field),
); );
const thisFilters = getFilteredFilters(originalFilters);
return ( return (
<div <div
key={key} key={key}
@ -153,15 +214,29 @@ function CheckboxFormMultiple({
<div className="text-text-primary text-sm">{key}</div> <div className="text-text-primary text-sm">{key}</div>
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
{thisFilters.map((x) => ( {thisFilters.map((x) => (
<FilterField <div key={x.field}>
key={x.field} {x.canSearch && (
item={{ ...x, id: x.field }} <div className="mb-2">
parent={{ <Input
...x, placeholder={t('common.search') + '...'}
id: x.field, value={searchTerms[x.field] || ''}
field: ``, onChange={(e) =>
}} handleSearchChange(x.field, e.target.value)
/> }
className="h-8"
/>
</div>
)}
<FilterField
key={x.field}
item={{ ...x, id: x.field }}
parent={{
...x,
id: x.field,
field: ``,
}}
/>
</div>
))} ))}
</div> </div>
</div> </div>
@ -169,15 +244,29 @@ function CheckboxFormMultiple({
})} })}
{notInfilterGroup && {notInfilterGroup &&
notInfilterGroup.map((x) => { notInfilterGroup.map((x) => {
const filteredItem = getFilteredFilters([x])[0];
return ( return (
<FormItem className="space-y-4" key={x.field}> <FormItem className="space-y-4" key={x.field}>
<div> <div>
<FormLabel className="text-text-primary text-sm"> <div className="flex justify-between items-center mb-2">
{x.label} <FormLabel className="text-text-primary text-sm">
</FormLabel> {x.label}
</FormLabel>
{x.canSearch && (
<Input
placeholder={t('common.search') + '...'}
value={searchTerms[x.field] || ''}
onChange={(e) =>
handleSearchChange(x.field, e.target.value)
}
className="h-8 w-32 ml-2"
/>
)}
</div>
</div> </div>
{x.list?.length && {!!filteredItem.list?.length &&
x.list.map((item) => { filteredItem.list.map((item) => {
return ( return (
<FilterField <FilterField
key={item.id} key={item.id}

View File

@ -44,6 +44,7 @@ export const FilterButton = React.forwardRef<
); );
}); });
FilterButton.displayName = 'FilterButton';
export default function ListFilterBar({ export default function ListFilterBar({
title, title,
children, children,

View File

@ -5,17 +5,16 @@ export type FilterType = {
list?: FilterType[]; list?: FilterType[];
value?: string | string[]; value?: string | string[];
count?: number; count?: number;
canSearch?: boolean;
}; };
export type FilterCollection = { export type FilterCollection = {
field: string; field: string;
label: string; label: string;
list: FilterType[]; list: FilterType[];
canSearch?: boolean;
}; };
export type FilterValue = Record< export type FilterValue = Record<
string, string,
Array<string> | Record<string, Array<string>> Array<string> | Record<string, Array<string>>
>; >;
export type FilterChange = (value: FilterValue) => void; export type FilterChange = (value: FilterValue) => void;

View File

@ -5,6 +5,7 @@ import {
import { EmptyType } from '@/components/empty/constant'; import { EmptyType } from '@/components/empty/constant';
import Empty from '@/components/empty/empty'; import Empty from '@/components/empty/empty';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Modal } from '@/components/ui/modal/modal'; import { Modal } from '@/components/ui/modal/modal';
import { import {
Table, Table,
@ -25,7 +26,13 @@ import {
getSortedRowModel, getSortedRowModel,
useReactTable, useReactTable,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { Plus, Settings, Trash2 } from 'lucide-react'; import {
ListChevronsDownUp,
ListChevronsUpDown,
Plus,
Settings,
Trash2,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useHandleMenuClick } from '../../sidebar/hooks'; import { useHandleMenuClick } from '../../sidebar/hooks';
@ -61,6 +68,7 @@ export const ManageMetadataModal = (props: IManageModalProps) => {
values: [], values: [],
}); });
const [expanded, setExpanded] = useState(true);
const [currentValueIndex, setCurrentValueIndex] = useState<number>(0); const [currentValueIndex, setCurrentValueIndex] = useState<number>(0);
const [deleteDialogContent, setDeleteDialogContent] = useState({ const [deleteDialogContent, setDeleteDialogContent] = useState({
visible: false, visible: false,
@ -70,6 +78,11 @@ export const ManageMetadataModal = (props: IManageModalProps) => {
onOk: () => {}, onOk: () => {},
onCancel: () => {}, onCancel: () => {},
}); });
const [editingValue, setEditingValue] = useState<{
field: string;
value: string;
newValue: string;
} | null>(null);
const { const {
tableData, tableData,
@ -81,6 +94,7 @@ export const ManageMetadataModal = (props: IManageModalProps) => {
addDeleteValue, addDeleteValue,
} = useManageMetaDataModal(originalTableData, metadataType, otherData); } = useManageMetaDataModal(originalTableData, metadataType, otherData);
const { handleMenuClick } = useHandleMenuClick(); const { handleMenuClick } = useHandleMenuClick();
const [shouldSave, setShouldSave] = useState(false);
const { const {
visible: manageValuesVisible, visible: manageValuesVisible,
showModal: showManageValuesModal, showModal: showManageValuesModal,
@ -96,6 +110,32 @@ export const ManageMetadataModal = (props: IManageModalProps) => {
onCancel: () => {}, onCancel: () => {},
}); });
}; };
const handleEditValue = (field: string, value: string) => {
setEditingValue({ field, value, newValue: value });
};
const saveEditedValue = useCallback(() => {
if (editingValue) {
setTableData((prev) => {
return prev.map((row) => {
if (row.field === editingValue.field) {
const updatedValues = row.values.map((v) =>
v === editingValue.value ? editingValue.newValue : v,
);
return { ...row, values: updatedValues };
}
return row;
});
});
setEditingValue(null);
setShouldSave(true);
}
}, [editingValue, setTableData]);
const cancelEditValue = () => {
setEditingValue(null);
};
const handAddValueRow = () => { const handAddValueRow = () => {
setValueData({ setValueData({
field: '', field: '',
@ -136,66 +176,119 @@ export const ManageMetadataModal = (props: IManageModalProps) => {
}, },
{ {
accessorKey: 'values', accessorKey: 'values',
header: () => <span>{t('knowledgeDetails.metadata.values')}</span>, header: () => (
<div className="flex items-center">
<span>{t('knowledgeDetails.metadata.values')}</span>
<div
className="ml-2 p-1 cursor-pointer"
onClick={() => {
setExpanded(!expanded);
}}
>
{expanded ? (
<ListChevronsDownUp size={14} />
) : (
<ListChevronsUpDown size={14} />
)}
{expanded}
</div>
</div>
),
cell: ({ row }) => { cell: ({ row }) => {
const values = row.getValue('values') as Array<string>; const values = row.getValue('values') as Array<string>;
if (!Array.isArray(values) || values.length === 0) {
return <div></div>;
}
const displayedValues = expanded ? values : values.slice(0, 2);
const hasMore = Array.isArray(values) && values.length > 2;
return ( return (
<div className="flex items-center gap-1"> <div className="flex flex-col gap-1">
{Array.isArray(values) && <div className="flex flex-wrap gap-1">
values.length > 0 && {displayedValues?.map((value: string) => {
values const isEditing =
.filter((value: string, index: number) => index < 2) editingValue &&
?.map((value: string) => { editingValue.field === row.getValue('field') &&
return ( editingValue.value === value;
<Button
key={value} return isEditing ? (
variant={'ghost'} <div key={value}>
className="border border-border-button" <Input
aria-label="Edit" type="text"
> value={editingValue.newValue}
<div className="flex gap-1 items-center"> onChange={(e) =>
<div className="text-sm truncate max-w-24"> setEditingValue({
{value} ...editingValue,
</div> newValue: e.target.value,
{isDeleteSingleValue && ( })
<Button }
variant={'delete'} onBlur={saveEditedValue}
className="p-0 bg-transparent" onKeyDown={(e) => {
onClick={() => { if (e.key === 'Enter') {
setDeleteDialogContent({ saveEditedValue();
visible: true, } else if (e.key === 'Escape') {
title: cancelEditValue();
t('common.delete') + }
' ' + }}
t('knowledgeDetails.metadata.value'), autoFocus
name: value, // className="text-sm min-w-20 max-w-32 outline-none bg-transparent px-1 py-0.5"
warnText: />
MetadataDeleteMap(t)[ </div>
metadataType as MetadataType ) : (
].warnValueText, <Button
onOk: () => { key={value}
hideDeleteModal(); variant={'ghost'}
handleDeleteSingleValue( className="border border-border-button"
row.getValue('field'), onClick={() =>
value, handleEditValue(row.getValue('field'), value)
); }
}, aria-label="Edit"
onCancel: () => { >
hideDeleteModal(); <div className="flex gap-1 items-center">
}, <div className="text-sm truncate max-w-24">{value}</div>
}); {isDeleteSingleValue && (
}} <Button
> variant={'delete'}
<Trash2 /> className="p-0 bg-transparent"
</Button> onClick={(e) => {
)} e.stopPropagation();
</div> setDeleteDialogContent({
</Button> visible: true,
); title:
})} t('common.delete') +
{Array.isArray(values) && values.length > 2 && ( ' ' +
<div className="text-text-secondary self-end">...</div> t('knowledgeDetails.metadata.value'),
)} name: value,
warnText:
MetadataDeleteMap(t)[
metadataType as MetadataType
].warnValueText,
onOk: () => {
hideDeleteModal();
handleDeleteSingleValue(
row.getValue('field'),
value,
);
},
onCancel: () => {
hideDeleteModal();
},
});
}}
>
<Trash2 />
</Button>
)}
</div>
</Button>
);
})}
{hasMore && !expanded && (
<div className="text-text-secondary self-end">...</div>
)}
</div>
</div> </div>
); );
}, },
@ -260,6 +353,9 @@ export const ManageMetadataModal = (props: IManageModalProps) => {
isDeleteSingleValue, isDeleteSingleValue,
handleEditValueRow, handleEditValueRow,
metadataType, metadataType,
expanded,
editingValue,
saveEditedValue,
]); ]);
const table = useReactTable({ const table = useReactTable({
@ -271,7 +367,7 @@ export const ManageMetadataModal = (props: IManageModalProps) => {
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
manualPagination: true, manualPagination: true,
}); });
const [shouldSave, setShouldSave] = useState(false);
const handleSaveValues = (data: IMetaDataTableData) => { const handleSaveValues = (data: IMetaDataTableData) => {
setTableData((prev) => { setTableData((prev) => {
let newData; let newData;

View File

@ -127,6 +127,7 @@ export default function Dataset() {
type: MetadataType.Manage, type: MetadataType.Manage,
isCanAdd: false, isCanAdd: false,
isEditField: true, isEditField: true,
isDeleteSingleValue: true,
title: ( title: (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="text-base font-normal"> <div className="text-base font-normal">

View File

@ -21,7 +21,6 @@ import { ParsingCard } from './parsing-card';
import { ReparseDialog } from './reparse-dialog'; import { ReparseDialog } from './reparse-dialog';
import { UseChangeDocumentParserShowType } from './use-change-document-parser'; import { UseChangeDocumentParserShowType } from './use-change-document-parser';
import { useHandleRunDocumentByIds } from './use-run-document'; import { useHandleRunDocumentByIds } from './use-run-document';
import { UseSaveMetaShowType } from './use-save-meta';
import { isParserRunning } from './utils'; import { isParserRunning } from './utils';
const IconMap = { const IconMap = {
[RunningStatus.UNSTART]: ( [RunningStatus.UNSTART]: (
@ -44,13 +43,12 @@ const IconMap = {
export function ParsingStatusCell({ export function ParsingStatusCell({
record, record,
showChangeParserModal, showChangeParserModal,
showSetMetaModal, // showSetMetaModal,
showLog, showLog,
}: { }: {
record: IDocumentInfo; record: IDocumentInfo;
showLog: (record: IDocumentInfo) => void; showLog: (record: IDocumentInfo) => void;
} & UseChangeDocumentParserShowType & } & UseChangeDocumentParserShowType) {
UseSaveMetaShowType) {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
run, run,
@ -83,10 +81,6 @@ export function ParsingStatusCell({
showChangeParserModal(record); showChangeParserModal(record);
}, [record, showChangeParserModal]); }, [record, showChangeParserModal]);
const handleShowSetMetaModal = useCallback(() => {
showSetMetaModal(record);
}, [record, showSetMetaModal]);
const showParse = useMemo(() => { const showParse = useMemo(() => {
return record.type !== DocumentType.Virtual; return record.type !== DocumentType.Virtual;
}, [record]); }, [record]);
@ -124,9 +118,6 @@ export function ParsingStatusCell({
<DropdownMenuItem onClick={handleShowChangeParserModal}> <DropdownMenuItem onClick={handleShowChangeParserModal}>
{t('knowledgeDetails.dataPipeline')} {t('knowledgeDetails.dataPipeline')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={handleShowSetMetaModal}>
{t('knowledgeDetails.setMetaData')}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>

View File

@ -172,17 +172,18 @@ export function useDatasetTableColumns({
), ),
}, },
{ {
accessorKey: 'run', accessorKey: 'meta_fields',
header: t('Parse'), header: t('metadata.metadata'),
// meta: { cellClassName: 'min-w-[20vw]' },
cell: ({ row }) => { cell: ({ row }) => {
const length = Object.keys(row.getValue('meta_fields') || {}).length;
return ( return (
<ParsingStatusCell <div
record={row.original} className="capitalize cursor-pointer"
showChangeParserModal={showChangeParserModal} onClick={() => {
showSetMetaModal={(row) =>
showManageMetadataModal({ showManageMetadataModal({
metadata: util.JSONToMetaDataTableData(row.meta_fields || {}), metadata: util.JSONToMetaDataTableData(
row.original.meta_fields || {},
),
isCanAdd: true, isCanAdd: true,
type: MetadataType.UpdateSingle, type: MetadataType.UpdateSingle,
record: row, record: row,
@ -193,13 +194,28 @@ export function useDatasetTableColumns({
</div> </div>
<div className="text-sm text-text-secondary w-full truncate"> <div className="text-sm text-text-secondary w-full truncate">
{t('metadata.editMetadataForDataset')} {t('metadata.editMetadataForDataset')}
{row.name} {row.original.name}
</div> </div>
</div> </div>
), ),
isDeleteSingleValue: true, isDeleteSingleValue: true,
}) });
} }}
>
{length + ' fields'}
</div>
);
},
},
{
accessorKey: 'run',
header: t('Parse'),
// meta: { cellClassName: 'min-w-[20vw]' },
cell: ({ row }) => {
return (
<ParsingStatusCell
record={row.original}
showChangeParserModal={showChangeParserModal}
showLog={showLog} showLog={showLog}
></ParsingStatusCell> ></ParsingStatusCell>
); );

View File

@ -72,7 +72,12 @@ export function useSelectDatasetFilters() {
return [ return [
{ field: 'type', label: 'File Type', list: fileTypes }, { field: 'type', label: 'File Type', list: fileTypes },
{ field: 'run', label: 'Status', list: fileStatus }, { field: 'run', label: 'Status', list: fileStatus },
{ field: 'metadata', label: 'Metadata field', list: metaDataList }, {
field: 'metadata',
label: 'Metadata field',
canSearch: true,
list: metaDataList,
},
] as FilterCollection[]; ] as FilterCollection[];
}, [fileStatus, fileTypes, metaDataList]); }, [fileStatus, fileTypes, metaDataList]);