feat: add image uploader in edit chunk dialog (#12003)

### What problem does this PR solve?

Add image uploader in edit chunk dialog for replacing image chunk

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
Jimmy Ben Klieve
2025-12-18 09:33:52 +08:00
committed by GitHub
parent 672958a192
commit ce161f09cc
5 changed files with 87 additions and 14 deletions

View File

@ -39,7 +39,7 @@ function FilePreview({ file }: FilePreviewProps) {
width={48} width={48}
height={48} height={48}
loading="lazy" loading="lazy"
className="aspect-square shrink-0 rounded-md object-cover" className="size-full aspect-square shrink-0 rounded-md object-cover"
/> />
); );
} }
@ -84,7 +84,8 @@ function FileCard({ file, progress, onRemove }: FileCardProps) {
); );
} }
interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> { interface FileUploaderProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
/** /**
* Value of the uploader. * Value of the uploader.
* @type File[] * @type File[]
@ -160,7 +161,8 @@ interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
*/ */
disabled?: boolean; disabled?: boolean;
description?: string; title?: React.ReactNode;
description?: React.ReactNode;
} }
export function FileUploader(props: FileUploaderProps) { export function FileUploader(props: FileUploaderProps) {
@ -177,6 +179,7 @@ export function FileUploader(props: FileUploaderProps) {
multiple = false, multiple = false,
disabled = false, disabled = false,
className, className,
title,
description, description,
...dropzoneProps ...dropzoneProps
} = props; } = props;
@ -285,7 +288,7 @@ export function FileUploader(props: FileUploaderProps) {
<div className="flex flex-col items-center justify-center gap-4 sm:px-5"> <div className="flex flex-col items-center justify-center gap-4 sm:px-5">
<div className="rounded-full border border-dashed p-3"> <div className="rounded-full border border-dashed p-3">
<Upload <Upload
className="size-7 text-text-secondary hover:text-text-primary" className="size-7 text-text-secondary transition-colors group-hover:text-text-primary"
aria-hidden="true" aria-hidden="true"
/> />
</div> </div>
@ -297,13 +300,13 @@ export function FileUploader(props: FileUploaderProps) {
<div className="flex flex-col items-center justify-center gap-4 sm:px-5"> <div className="flex flex-col items-center justify-center gap-4 sm:px-5">
<div className="rounded-full border border-dashed p-3"> <div className="rounded-full border border-dashed p-3">
<Upload <Upload
className="size-7 text-text-secondary hover:text-text-primary" className="size-7 text-text-secondary transition-colors group-hover:text-text-primary"
aria-hidden="true" aria-hidden="true"
/> />
</div> </div>
<div className="flex flex-col gap-px"> <div className="flex flex-col gap-px">
<p className="font-medium text-text-secondary"> <p className="font-medium text-text-secondary">
{t('knowledgeDetails.uploadTitle')} {title || t('knowledgeDetails.uploadTitle')}
</p> </p>
<p className="text-sm text-text-disabled"> <p className="text-sm text-text-disabled">
{description || t('knowledgeDetails.uploadDescription')} {description || t('knowledgeDetails.uploadDescription')}

View File

@ -4,7 +4,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
interface IImage { interface IImage {
id: string; id: string;
className: string; className?: string;
onClick?(): void; onClick?(): void;
} }

View File

@ -598,6 +598,8 @@ This auto-tagging feature enhances retrieval by adding another layer of domain-s
enabled: 'Enabled', enabled: 'Enabled',
disabled: 'Disabled', disabled: 'Disabled',
keyword: 'Keyword', keyword: 'Keyword',
image: 'Image',
imageUploaderTitle: 'Upload a new image to update this image chunk',
function: 'Function', function: 'Function',
chunkMessage: 'Please input value!', chunkMessage: 'Please input value!',
full: 'Full text', full: 'Full text',

View File

@ -1,4 +1,6 @@
import EditTag from '@/components/edit-tag'; import EditTag from '@/components/edit-tag';
import { FileUploader } from '@/components/file-uploader';
import Image from '@/components/image';
import Divider from '@/components/ui/divider'; import Divider from '@/components/ui/divider';
import { import {
Form, Form,
@ -35,6 +37,21 @@ interface kFProps {
parserId: string; parserId: string;
} }
async function fileToBase64(file: File) {
if (!file) {
return;
}
return new Promise<string>((resolve, reject) => {
const fr = new FileReader();
fr.addEventListener('load', () => {
resolve((fr.result?.toString() ?? '').replace(/^.*,/, ''));
});
fr.onerror = reject;
fr.readAsDataURL(file);
});
}
const ChunkCreatingModal: React.FC<IModalProps<any> & kFProps> = ({ const ChunkCreatingModal: React.FC<IModalProps<any> & kFProps> = ({
doc_id, doc_id,
chunkId, chunkId,
@ -52,6 +69,7 @@ const ChunkCreatingModal: React.FC<IModalProps<any> & kFProps> = ({
question_kwd: [], question_kwd: [],
important_kwd: [], important_kwd: [],
tag_feas: [], tag_feas: [],
image: [],
}, },
}); });
const [checked, setChecked] = useState(false); const [checked, setChecked] = useState(false);
@ -61,12 +79,17 @@ const ChunkCreatingModal: React.FC<IModalProps<any> & kFProps> = ({
const isTagParser = parserId === 'tag'; const isTagParser = parserId === 'tag';
const onSubmit = useCallback( const onSubmit = useCallback(
(values: FieldValues) => { async (values: FieldValues) => {
onOk?.({ const prunedValues = {
...values, ...values,
image_base64: await fileToBase64(values.image?.[0] as File),
tag_feas: transformTagFeaturesArrayToObject(values.tag_feas), tag_feas: transformTagFeaturesArrayToObject(values.tag_feas),
available_int: checked ? 1 : 0, available_int: checked ? 1 : 0,
}); } as FieldValues;
Reflect.deleteProperty(prunedValues, 'image');
onOk?.(prunedValues);
}, },
[checked, onOk], [checked, onOk],
); );
@ -86,6 +109,7 @@ const ChunkCreatingModal: React.FC<IModalProps<any> & kFProps> = ({
useEffect(() => { useEffect(() => {
if (data?.code === 0) { if (data?.code === 0) {
const { available_int, tag_feas } = data.data; const { available_int, tag_feas } = data.data;
form.reset({ form.reset({
...data.data, ...data.data,
tag_feas: transformTagFeaturesObjectToArray(tag_feas), tag_feas: transformTagFeaturesObjectToArray(tag_feas),
@ -119,6 +143,44 @@ const ChunkCreatingModal: React.FC<IModalProps<any> & kFProps> = ({
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="image"
render={({ field }) => (
<FormItem>
<FormLabel className="gap-1">{t('chunk.image')}</FormLabel>
<div className="grid grid-cols-2 gap-4 items-start">
{data?.data?.img_id && (
<Image
id={data?.data?.img_id}
className="w-full object-contain"
/>
)}
<div className="col-start-2 col-end-3 only:col-span-2">
<FormControl>
<FileUploader
className="h-48"
value={field.value}
onValueChange={field.onChange}
accept={{
'image/png': [],
'image/jpeg': [],
'image/webp': [],
}}
maxFileCount={1}
title={t('chunk.imageUploaderTitle')}
description={<></>}
/>
</FormControl>
</div>
</div>
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="important_kwd" name="important_kwd"

View File

@ -7,10 +7,16 @@ export const isFormData = (data: unknown): data is FormData => {
return data instanceof FormData; return data instanceof FormData;
}; };
const excludedFields = ['img2txt_id', 'mcpServers']; const excludedFields: Array<string | RegExp> = [
'img2txt_id',
'mcpServers',
'image_base64',
];
const isExcludedField = (key: string) => { const isExcludedField = (key: string) => {
return excludedFields.includes(key); return excludedFields.some((excl) =>
excl instanceof RegExp ? excl.test(key) : excl === key,
);
}; };
export const convertTheKeysOfTheObjectToSnake = (data: unknown) => { export const convertTheKeysOfTheObjectToSnake = (data: unknown) => {