diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/FaqItemController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/FaqItemController.java index f95a925..651cd69 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/FaqItemController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/FaqItemController.java @@ -4,31 +4,56 @@ import cn.dev33.satoken.annotation.SaCheckPermission; import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.query.QueryWrapper; import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.MediaType; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.FaqItem; +import tech.easyflow.ai.service.DocumentCollectionService; import tech.easyflow.ai.service.FaqCategoryService; import tech.easyflow.ai.service.FaqItemService; import tech.easyflow.common.annotation.UsePermission; import tech.easyflow.common.domain.Result; +import tech.easyflow.common.filestorage.FileStorageService; +import tech.easyflow.common.vo.UploadResVo; import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.jsonbody.JsonBody; +import javax.annotation.Resource; import java.io.Serializable; import java.math.BigInteger; +import java.util.Arrays; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; @RestController @RequestMapping("/api/v1/faqItem") @UsePermission(moduleName = "/api/v1/documentCollection") public class FaqItemController extends BaseCurdController { + private static final long MAX_IMAGE_SIZE_BYTES = 5L * 1024L * 1024L; + private static final Set ALLOWED_IMAGE_TYPES = new HashSet<>(Arrays.asList( + "image/jpeg", + "image/png", + "image/webp", + "image/gif" + )); + private final FaqCategoryService faqCategoryService; + @Resource + private DocumentCollectionService documentCollectionService; + + @Resource(name = "default") + private FileStorageService fileStorageService; + public FaqItemController(FaqItemService service, FaqCategoryService faqCategoryService) { super(service); this.faqCategoryService = faqCategoryService; @@ -118,8 +143,43 @@ public class FaqItemController extends BaseCurdController uploadImage(MultipartFile file, BigInteger collectionId) { + if (collectionId == null) { + throw new BusinessException("知识库ID不能为空"); + } + if (file == null || file.isEmpty()) { + throw new BusinessException("图片不能为空"); + } + if (file.getSize() > MAX_IMAGE_SIZE_BYTES) { + throw new BusinessException("图片大小不能超过5MB"); + } + if (!isAllowedImageType(file)) { + throw new BusinessException("仅支持 JPG/PNG/WEBP/GIF 图片"); + } + + DocumentCollection collection = documentCollectionService.getById(collectionId); + if (collection == null) { + throw new BusinessException("知识库不存在"); + } + if (!collection.isFaqCollection()) { + throw new BusinessException("当前知识库不是FAQ类型"); + } + + String path = fileStorageService.save(file, "faq/" + collectionId); + UploadResVo resVo = new UploadResVo(); + resVo.setPath(path); + return Result.ok(resVo); + } + @Override protected String getDefaultOrderBy() { return "order_no asc"; } + + private boolean isAllowedImageType(MultipartFile file) { + String contentType = file.getContentType(); + return StringUtils.hasText(contentType) && ALLOWED_IMAGE_TYPES.contains(contentType.toLowerCase()); + } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/DocumentCollectionTool.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/DocumentCollectionTool.java index 868ebbc..c499366 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/DocumentCollectionTool.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/DocumentCollectionTool.java @@ -55,7 +55,14 @@ public class DocumentCollectionTool extends BaseTool { StringBuilder sb = new StringBuilder(); if (documents != null) { - for (Document document : documents) { + for (int i = 0; i < documents.size(); i++) { + Document document = documents.get(i); + if (document == null || document.getContent() == null) { + continue; + } + if (sb.length() > 0) { + sb.append("\n\n---\n\n"); + } sb.append(document.getContent()); } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java index 76e945a..ba1c560 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java @@ -61,6 +61,7 @@ import static tech.easyflow.ai.entity.table.PluginItemTableDef.PLUGIN_ITEM; public class BotServiceImpl extends ServiceImpl implements BotService { private static final Logger log = LoggerFactory.getLogger(BotServiceImpl.class); + private static final String FAQ_IMAGE_SYSTEM_RULE = "当知识工具返回 Markdown 图片(格式:![描述](URL))时,你必须在最终回答中保留并输出对应的图片 Markdown,禁止改写、替换或省略图片 URL。"; public static class ChatCheckResult { private Bot aiBot; @@ -173,7 +174,9 @@ public class BotServiceImpl extends ServiceImpl implements BotSe Map modelOptions = chatCheckResult.getModelOptions(); ChatModel chatModel = chatCheckResult.getChatModel(); final MemoryPrompt memoryPrompt = new MemoryPrompt(); - String systemPrompt = MapUtil.getString(modelOptions, Bot.KEY_SYSTEM_PROMPT); + String systemPrompt = buildSystemPromptWithFaqImageRule( + MapUtil.getString(modelOptions, Bot.KEY_SYSTEM_PROMPT) + ); Integer maxMessageCount = MapUtil.getInteger(modelOptions, Bot.KEY_MAX_MESSAGE_COUNT); if (maxMessageCount != null) { memoryPrompt.setMaxAttachedMessageCount(maxMessageCount); @@ -221,6 +224,9 @@ public class BotServiceImpl extends ServiceImpl implements BotSe Map modelOptions = chatCheckResult.getModelOptions(); ChatOptions chatOptions = getChatOptions(modelOptions); ChatModel chatModel = chatCheckResult.getChatModel(); + String systemPrompt = buildSystemPromptWithFaqImageRule( + MapUtil.getString(modelOptions, Bot.KEY_SYSTEM_PROMPT) + ); UserMessage userMessage = new UserMessage(prompt); userMessage.addTools(buildFunctionList(Maps.of("botId", botId) .set("needEnglishName", false) @@ -231,6 +237,9 @@ public class BotServiceImpl extends ServiceImpl implements BotSe ChatMemory defaultChatMemory = new PublicBotMessageMemory(chatSseEmitter, messages); final MemoryPrompt memoryPrompt = new MemoryPrompt(); memoryPrompt.setMemory(defaultChatMemory); + if (StringUtils.hasLength(systemPrompt)) { + memoryPrompt.setSystemMessage(SystemMessage.of(systemPrompt)); + } memoryPrompt.addMessage(userMessage); RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); ServletRequestAttributes sra = (ServletRequestAttributes) requestAttributes; @@ -397,4 +406,14 @@ public class BotServiceImpl extends ServiceImpl implements BotSe return messageBuilder.toString(); } + private String buildSystemPromptWithFaqImageRule(String systemPrompt) { + if (!StringUtils.hasLength(systemPrompt)) { + return FAQ_IMAGE_SYSTEM_RULE; + } + if (systemPrompt.contains(FAQ_IMAGE_SYSTEM_RULE)) { + return systemPrompt; + } + return systemPrompt + "\n\n" + FAQ_IMAGE_SYSTEM_RULE; + } + } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java index 84bc8d2..a8a25cf 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java @@ -52,6 +52,7 @@ import static tech.easyflow.ai.entity.DocumentCollection.*; public class DocumentCollectionServiceImpl extends ServiceImpl implements DocumentCollectionService { private static final Logger LOG = LoggerFactory.getLogger(DocumentCollectionServiceImpl.class); + private static final int MAX_FAQ_IMAGES_IN_PROMPT = 3; @Resource private ModelService llmService; @@ -256,7 +257,18 @@ public class DocumentCollectionServiceImpl extends ServiceImpl { FaqItem faqItem = faqItemMap.get(String.valueOf(item.getId())); if (faqItem != null) { - item.setContent("问题:" + faqItem.getQuestion() + "\n答案:" + faqItem.getAnswerText()); + List> faqImages = readFaqImages(faqItem); + item.setContent(buildFaqPromptContent(faqItem, faqImages)); + + Map metadataMap = item.getMetadataMap() == null + ? new HashMap<>() + : new HashMap<>(item.getMetadataMap()); + List imageUrls = faqImages.stream() + .map(image -> image.get("url")) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + metadataMap.put("imageUrls", imageUrls); + item.setMetadataMap(metadataMap); } }); return; @@ -271,4 +283,75 @@ public class DocumentCollectionServiceImpl extends ServiceImpl> images) { + StringBuilder contentBuilder = new StringBuilder(); + contentBuilder.append("问题:").append(faqItem.getQuestion()); + if (StringUtil.hasText(faqItem.getAnswerText())) { + contentBuilder.append("\n答案:").append(faqItem.getAnswerText()); + } + if (images == null || images.isEmpty()) { + return contentBuilder.toString(); + } + contentBuilder.append("\n相关图片:"); + int limit = Math.min(images.size(), MAX_FAQ_IMAGES_IN_PROMPT); + for (int i = 0; i < limit; i++) { + Map image = images.get(i); + String url = image.get("url"); + if (!StringUtil.hasText(url)) { + continue; + } + String alt = image.get("alt"); + if (!StringUtil.hasText(alt)) { + alt = image.get("title"); + } + if (!StringUtil.hasText(alt)) { + alt = "faq-image-" + (i + 1); + } + contentBuilder.append("\n").append("![").append(alt).append("](").append(url).append(")"); + } + return contentBuilder.toString(); + } + + @SuppressWarnings("unchecked") + private List> readFaqImages(FaqItem faqItem) { + if (faqItem == null || faqItem.getOptions() == null) { + return Collections.emptyList(); + } + Object imagesValue = faqItem.getOptions().get("images"); + if (!(imagesValue instanceof List)) { + return Collections.emptyList(); + } + List> images = new ArrayList<>(); + for (Object imageObj : (List) imagesValue) { + if (!(imageObj instanceof Map)) { + continue; + } + Map imageMap = (Map) imageObj; + String url = trimToNull(imageMap.get("url")); + if (url == null) { + continue; + } + Map normalized = new HashMap<>(); + normalized.put("url", url); + String alt = trimToNull(imageMap.get("alt")); + if (alt != null) { + normalized.put("alt", alt); + } + String title = trimToNull(imageMap.get("title")); + if (title != null) { + normalized.put("title", title); + } + images.add(normalized); + } + return images; + } + + private String trimToNull(Object value) { + if (value == null) { + return null; + } + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : text; + } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/FaqItemServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/FaqItemServiceImpl.java index bc424e5..8c5b3d6 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/FaqItemServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/FaqItemServiceImpl.java @@ -10,6 +10,8 @@ import com.easyagents.search.engine.service.DocumentSearcher; import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.spring.service.impl.ServiceImpl; import org.jsoup.Jsoup; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; import org.jsoup.safety.Safelist; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -40,7 +42,13 @@ public class FaqItemServiceImpl extends ServiceImpl impl private static final Logger LOG = LoggerFactory.getLogger(FaqItemServiceImpl.class); private static final Safelist ANSWER_SAFE_LIST = Safelist.basic() - .addTags("h1", "h2", "h3", "h4", "h5", "h6"); + .addTags("h1", "h2", "h3", "h4", "h5", "h6", "img") + .addAttributes("img", "src", "alt", "title", "width", "height") + .addProtocols("img", "src", "http", "https"); + private static final String OPTION_KEY_IMAGES = "images"; + private static final String IMAGE_FIELD_URL = "url"; + private static final String IMAGE_FIELD_ALT = "alt"; + private static final String IMAGE_FIELD_TITLE = "title"; @Resource private DocumentCollectionService documentCollectionService; @@ -57,7 +65,7 @@ public class FaqItemServiceImpl extends ServiceImpl impl @Override @Transactional public boolean saveFaqItem(FaqItem entity) { - checkAndNormalize(entity, true); + checkAndNormalize(entity, true, null); DocumentCollection collection = getFaqCollection(entity.getCollectionId()); entity.setCategoryId(faqCategoryService.ensureFaqItemCategory(entity.getCollectionId(), entity.getCategoryId())); @@ -93,7 +101,7 @@ public class FaqItemServiceImpl extends ServiceImpl impl if (entity.getCollectionId() == null) { entity.setCollectionId(old.getCollectionId()); } - checkAndNormalize(entity, false); + checkAndNormalize(entity, false, old.getOptions()); DocumentCollection collection = getFaqCollection(old.getCollectionId()); old.setCategoryId(faqCategoryService.ensureFaqItemCategory(old.getCollectionId(), entity.getCategoryId())); @@ -128,7 +136,7 @@ public class FaqItemServiceImpl extends ServiceImpl impl return removeById(id); } - private void checkAndNormalize(FaqItem entity, boolean isSave) { + private void checkAndNormalize(FaqItem entity, boolean isSave, Map baseOptions) { if (entity == null) { throw new BusinessException("FAQ条目不能为空"); } @@ -148,11 +156,24 @@ public class FaqItemServiceImpl extends ServiceImpl impl entity.setQuestion(entity.getQuestion().trim()); String cleanHtml = Jsoup.clean(entity.getAnswerHtml(), ANSWER_SAFE_LIST); String answerText = Jsoup.parse(cleanHtml).text(); - if (StringUtil.noText(answerText)) { + List> images = extractImages(cleanHtml); + if (StringUtil.noText(answerText) && images.isEmpty()) { throw new BusinessException("答案不能为空"); } + Map normalizedOptions = baseOptions == null + ? new HashMap<>() + : new HashMap<>(baseOptions); + if (entity.getOptions() != null) { + normalizedOptions.putAll(entity.getOptions()); + } + if (images.isEmpty()) { + normalizedOptions.remove(OPTION_KEY_IMAGES); + } else { + normalizedOptions.put(OPTION_KEY_IMAGES, images); + } entity.setAnswerHtml(cleanHtml); entity.setAnswerText(answerText); + entity.setOptions(normalizedOptions); } private DocumentCollection getFaqCollection(BigInteger collectionId) { @@ -262,12 +283,22 @@ public class FaqItemServiceImpl extends ServiceImpl impl metadata.put("question", entity.getQuestion()); metadata.put("answerText", entity.getAnswerText()); metadata.put("categoryId", entity.getCategoryId()); + metadata.put("imageUrls", readImageUrls(entity.getOptions())); doc.setMetadataMap(metadata); return doc; } private String buildSearchContent(FaqItem entity) { - return "问题:" + entity.getQuestion() + "\n答案:" + entity.getAnswerText(); + StringBuilder contentBuilder = new StringBuilder(); + contentBuilder.append("问题:").append(entity.getQuestion()); + if (StringUtil.hasText(entity.getAnswerText())) { + contentBuilder.append("\n答案:").append(entity.getAnswerText()); + } + String imageDescription = buildImageDescriptionText(entity.getOptions()); + if (StringUtil.hasText(imageDescription)) { + contentBuilder.append("\n图片说明:").append(imageDescription); + } + return contentBuilder.toString(); } private void markCollectionEmbedded(DocumentCollection collection, EmbeddingModel embeddingModel) { @@ -307,6 +338,122 @@ public class FaqItemServiceImpl extends ServiceImpl impl return BigInteger.valueOf(StpUtil.getLoginIdAsLong()); } + private List> extractImages(String answerHtml) { + if (StringUtil.noText(answerHtml)) { + return Collections.emptyList(); + } + Elements imageElements = Jsoup.parseBodyFragment(answerHtml).select("img[src]"); + if (imageElements.isEmpty()) { + return Collections.emptyList(); + } + List> images = new ArrayList<>(); + Set visited = new HashSet<>(); + for (Element imageElement : imageElements) { + String url = trimToNull(imageElement.attr("src")); + if (url == null || !url.startsWith("http")) { + continue; + } + if (!visited.add(url)) { + continue; + } + Map image = new LinkedHashMap<>(); + image.put(IMAGE_FIELD_URL, url); + String alt = trimToNull(imageElement.attr("alt")); + if (alt != null) { + image.put(IMAGE_FIELD_ALT, alt); + } + String title = trimToNull(imageElement.attr("title")); + if (title != null) { + image.put(IMAGE_FIELD_TITLE, title); + } + images.add(image); + } + return images; + } + + private String buildImageDescriptionText(Map options) { + List> images = normalizeImageField(options); + if (images.isEmpty()) { + return null; + } + List descriptions = new ArrayList<>(); + for (Map image : images) { + String alt = trimToNull(image.get(IMAGE_FIELD_ALT)); + if (alt != null) { + descriptions.add(alt); + continue; + } + String title = trimToNull(image.get(IMAGE_FIELD_TITLE)); + if (title != null) { + descriptions.add(title); + } + } + if (descriptions.isEmpty()) { + return null; + } + return String.join(";", descriptions); + } + + private List readImageUrls(Map options) { + List> images = normalizeImageField(options); + if (images.isEmpty()) { + return Collections.emptyList(); + } + List imageUrls = new ArrayList<>(); + for (Map image : images) { + String url = trimToNull(image.get(IMAGE_FIELD_URL)); + if (url != null) { + imageUrls.add(url); + } + } + return imageUrls; + } + + @SuppressWarnings("unchecked") + private List> normalizeImageField(Map options) { + if (options == null) { + return Collections.emptyList(); + } + Object imagesValue = options.get(OPTION_KEY_IMAGES); + if (!(imagesValue instanceof List)) { + return Collections.emptyList(); + } + List> normalized = new ArrayList<>(); + for (Object imageObj : (List) imagesValue) { + if (!(imageObj instanceof Map)) { + continue; + } + Map imageMap = (Map) imageObj; + String url = trimToNull(toStringValue(imageMap.get(IMAGE_FIELD_URL))); + if (url == null) { + continue; + } + Map image = new LinkedHashMap<>(); + image.put(IMAGE_FIELD_URL, url); + String alt = trimToNull(toStringValue(imageMap.get(IMAGE_FIELD_ALT))); + if (alt != null) { + image.put(IMAGE_FIELD_ALT, alt); + } + String title = trimToNull(toStringValue(imageMap.get(IMAGE_FIELD_TITLE))); + if (title != null) { + image.put(IMAGE_FIELD_TITLE, title); + } + normalized.add(image); + } + return normalized; + } + + private String toStringValue(Object value) { + return value == null ? null : String.valueOf(value); + } + + private String trimToNull(String value) { + if (!StringUtil.hasText(value)) { + return null; + } + return value.trim(); + } + private static class PreparedStore { private final DocumentStore documentStore; private final StoreOptions storeOptions; diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json b/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json index 2bed025..962c47c 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json @@ -108,7 +108,11 @@ "questionPlaceholder": "Please input question", "answerPlaceholder": "Please input answer", "questionRequired": "Question is required", - "answerRequired": "Answer is required" + "answerRequired": "Answer is required", + "collectionRequired": "Collection id is required", + "imageTypeInvalid": "Only JPG/PNG/WEBP/GIF images are supported", + "imageSizeExceeded": "Image size must be less than 5MB", + "imageUploadFailed": "Image upload failed" }, "searchResults": "SearchResults", "documentPreview": "DocumentPreview", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json index 924f6b9..1793b04 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json @@ -108,7 +108,11 @@ "questionPlaceholder": "请输入问题", "answerPlaceholder": "请输入答案", "questionRequired": "问题不能为空", - "answerRequired": "答案不能为空" + "answerRequired": "答案不能为空", + "collectionRequired": "知识库ID不能为空", + "imageTypeInvalid": "仅支持 JPG/PNG/WEBP/GIF 图片", + "imageSizeExceeded": "图片大小不能超过5MB", + "imageUploadFailed": "图片上传失败" }, "searchResults": "检索结果", "documentPreview": "文档预览", diff --git a/easyflow-ui-admin/app/src/views/ai/documentCollection/FaqEditDialog.vue b/easyflow-ui-admin/app/src/views/ai/documentCollection/FaqEditDialog.vue index a1de721..711909c 100644 --- a/easyflow-ui-admin/app/src/views/ai/documentCollection/FaqEditDialog.vue +++ b/easyflow-ui-admin/app/src/views/ai/documentCollection/FaqEditDialog.vue @@ -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(null); const form = ref({ @@ -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 {