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:
@@ -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<FaqItemService, FaqItem> {
|
||||
|
||||
private static final long MAX_IMAGE_SIZE_BYTES = 5L * 1024L * 1024L;
|
||||
private static final Set<String> 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<FaqItemService, FaqIte
|
||||
return Result.ok(service.removeFaqItem(new java.math.BigInteger(String.valueOf(id))));
|
||||
}
|
||||
|
||||
@PostMapping(value = "uploadImage", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
@SaCheckPermission("/api/v1/documentCollection/save")
|
||||
public Result<UploadResVo> 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ import static tech.easyflow.ai.entity.table.PluginItemTableDef.PLUGIN_ITEM;
|
||||
public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BotServiceImpl.class);
|
||||
private static final String FAQ_IMAGE_SYSTEM_RULE = "当知识工具返回 Markdown 图片(格式:)时,你必须在最终回答中保留并输出对应的图片 Markdown,禁止改写、替换或省略图片 URL。";
|
||||
|
||||
public static class ChatCheckResult {
|
||||
private Bot aiBot;
|
||||
@@ -173,7 +174,9 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
||||
Map<String, Object> 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<BotMapper, Bot> implements BotSe
|
||||
Map<String, Object> 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<BotMapper, Bot> 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<BotMapper, Bot> 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ import static tech.easyflow.ai.entity.DocumentCollection.*;
|
||||
public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectionMapper, DocumentCollection> 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<DocumentCollectio
|
||||
searchDocuments.forEach(item -> {
|
||||
FaqItem faqItem = faqItemMap.get(String.valueOf(item.getId()));
|
||||
if (faqItem != null) {
|
||||
item.setContent("问题:" + faqItem.getQuestion() + "\n答案:" + faqItem.getAnswerText());
|
||||
List<Map<String, String>> faqImages = readFaqImages(faqItem);
|
||||
item.setContent(buildFaqPromptContent(faqItem, faqImages));
|
||||
|
||||
Map<String, Object> metadataMap = item.getMetadataMap() == null
|
||||
? new HashMap<>()
|
||||
: new HashMap<>(item.getMetadataMap());
|
||||
List<String> 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<DocumentCollectio
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private String buildFaqPromptContent(FaqItem faqItem, List<Map<String, String>> 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<String, String> 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(url).append(")");
|
||||
}
|
||||
return contentBuilder.toString();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Map<String, String>> 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<Map<String, String>> images = new ArrayList<>();
|
||||
for (Object imageObj : (List<Object>) imagesValue) {
|
||||
if (!(imageObj instanceof Map<?, ?>)) {
|
||||
continue;
|
||||
}
|
||||
Map<?, ?> imageMap = (Map<?, ?>) imageObj;
|
||||
String url = trimToNull(imageMap.get("url"));
|
||||
if (url == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<FaqItemMapper, FaqItem> 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<FaqItemMapper, FaqItem> 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<FaqItemMapper, FaqItem> 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<FaqItemMapper, FaqItem> impl
|
||||
return removeById(id);
|
||||
}
|
||||
|
||||
private void checkAndNormalize(FaqItem entity, boolean isSave) {
|
||||
private void checkAndNormalize(FaqItem entity, boolean isSave, Map<String, Object> baseOptions) {
|
||||
if (entity == null) {
|
||||
throw new BusinessException("FAQ条目不能为空");
|
||||
}
|
||||
@@ -148,11 +156,24 @@ public class FaqItemServiceImpl extends ServiceImpl<FaqItemMapper, FaqItem> 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<Map<String, String>> images = extractImages(cleanHtml);
|
||||
if (StringUtil.noText(answerText) && images.isEmpty()) {
|
||||
throw new BusinessException("答案不能为空");
|
||||
}
|
||||
Map<String, Object> 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<FaqItemMapper, FaqItem> 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<FaqItemMapper, FaqItem> impl
|
||||
return BigInteger.valueOf(StpUtil.getLoginIdAsLong());
|
||||
}
|
||||
|
||||
private List<Map<String, String>> 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<Map<String, String>> images = new ArrayList<>();
|
||||
Set<String> 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<String, String> 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<String, Object> options) {
|
||||
List<Map<String, String>> images = normalizeImageField(options);
|
||||
if (images.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
List<String> descriptions = new ArrayList<>();
|
||||
for (Map<String, String> 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<String> readImageUrls(Map<String, Object> options) {
|
||||
List<Map<String, String>> images = normalizeImageField(options);
|
||||
if (images.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<String> imageUrls = new ArrayList<>();
|
||||
for (Map<String, String> image : images) {
|
||||
String url = trimToNull(image.get(IMAGE_FIELD_URL));
|
||||
if (url != null) {
|
||||
imageUrls.add(url);
|
||||
}
|
||||
}
|
||||
return imageUrls;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Map<String, String>> normalizeImageField(Map<String, Object> options) {
|
||||
if (options == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Object imagesValue = options.get(OPTION_KEY_IMAGES);
|
||||
if (!(imagesValue instanceof List<?>)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<Map<String, String>> normalized = new ArrayList<>();
|
||||
for (Object imageObj : (List<Object>) imagesValue) {
|
||||
if (!(imageObj instanceof Map<?, ?>)) {
|
||||
continue;
|
||||
}
|
||||
Map<?, ?> imageMap = (Map<?, ?>) imageObj;
|
||||
String url = trimToNull(toStringValue(imageMap.get(IMAGE_FIELD_URL)));
|
||||
if (url == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> 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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -108,7 +108,11 @@
|
||||
"questionPlaceholder": "请输入问题",
|
||||
"answerPlaceholder": "请输入答案",
|
||||
"questionRequired": "问题不能为空",
|
||||
"answerRequired": "答案不能为空"
|
||||
"answerRequired": "答案不能为空",
|
||||
"collectionRequired": "知识库ID不能为空",
|
||||
"imageTypeInvalid": "仅支持 JPG/PNG/WEBP/GIF 图片",
|
||||
"imageSizeExceeded": "图片大小不能超过5MB",
|
||||
"imageUploadFailed": "图片上传失败"
|
||||
},
|
||||
"searchResults": "检索结果",
|
||||
"documentPreview": "文档预览",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user