feat(faq): support rich-text image upload and RAG output

- add FAQ image upload API with MinIO path and mime/size validation

- enable FAQ editor image upload and text-or-image validation

- include FAQ image metadata in vector content and retrieval output

- enforce markdown image preservation via bot system prompt rule
This commit is contained in:
2026-02-25 19:53:39 +08:00
parent fcf1100b56
commit 01f354ede5
8 changed files with 396 additions and 26 deletions

View File

@@ -17,6 +17,8 @@ import {
ElTreeSelect,
} from 'element-plus';
import { api } from '#/api/request';
import '@wangeditor/editor/dist/css/style.css';
const props = defineProps({
@@ -35,6 +37,13 @@ const props = defineProps({
});
const emit = defineEmits(['submit', 'update:modelValue']);
const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
const ALLOWED_IMAGE_TYPES = new Set([
'image/gif',
'image/jpeg',
'image/png',
'image/webp',
]);
const editorRef = shallowRef<IDomEditor | null>(null);
const form = ref<any>({
@@ -65,19 +74,48 @@ watch(
);
const toolbarConfig = {
excludeKeys: [
'uploadImage',
'insertImage',
'group-image',
'insertVideo',
'group-video',
'uploadVideo',
'todo',
'emotion',
],
excludeKeys: ['insertVideo', 'group-video', 'uploadVideo', 'todo', 'emotion'],
};
const editorConfig = {
const editorConfig: any = {
placeholder: $t('documentCollection.faq.answerPlaceholder'),
MENU_CONF: {
uploadImage: {
customUpload: async (
file: File,
insertFn: (url: string, alt?: string, href?: string) => void,
) => {
if (!form.value.collectionId) {
ElMessage.error($t('documentCollection.faq.collectionRequired'));
return;
}
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
ElMessage.error($t('documentCollection.faq.imageTypeInvalid'));
return;
}
if (file.size > MAX_IMAGE_SIZE) {
ElMessage.error($t('documentCollection.faq.imageSizeExceeded'));
return;
}
try {
const res = await api.upload(
'/api/v1/faqItem/uploadImage',
{ collectionId: form.value.collectionId, file },
{},
);
const imageUrl = res?.data?.path;
if (!imageUrl) {
ElMessage.error($t('documentCollection.faq.imageUploadFailed'));
return;
}
insertFn(imageUrl, file.name, imageUrl);
} catch (error) {
console.error('FAQ image upload failed:', error);
ElMessage.error($t('documentCollection.faq.imageUploadFailed'));
}
},
},
},
};
const handleEditorCreated = (editor: IDomEditor) => {
@@ -121,9 +159,15 @@ const handleSubmit = () => {
ElMessage.error($t('documentCollection.faq.questionRequired'));
return;
}
const sanitizedHtml = DOMPurify.sanitize(form.value.answerHtml || '');
const pureText = sanitizedHtml.replaceAll(/<[^>]*>/g, '').trim();
if (!pureText) {
const sanitizedHtml = DOMPurify.sanitize(form.value.answerHtml || '', {
ADD_TAGS: ['img'],
ADD_ATTR: ['src', 'alt', 'title', 'width', 'height'],
ALLOWED_URI_REGEXP: /^https?:\/\//i,
});
const doc = new DOMParser().parseFromString(sanitizedHtml, 'text/html');
const pureText = (doc.body?.textContent || '').trim();
const hasImage = doc.querySelector('img[src]') !== null;
if (!pureText && !hasImage) {
ElMessage.error($t('documentCollection.faq.answerRequired'));
return;
}
@@ -238,7 +282,9 @@ onBeforeUnmount(() => {
background: var(--el-fill-color-blank);
overflow: hidden;
box-shadow: 0 8px 24px rgb(15 23 42 / 4%);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.editor-wrapper:focus-within {