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.paginate.Page;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
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.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
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.entity.FaqItem;
|
||||||
|
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||||
import tech.easyflow.ai.service.FaqCategoryService;
|
import tech.easyflow.ai.service.FaqCategoryService;
|
||||||
import tech.easyflow.ai.service.FaqItemService;
|
import tech.easyflow.ai.service.FaqItemService;
|
||||||
import tech.easyflow.common.annotation.UsePermission;
|
import tech.easyflow.common.annotation.UsePermission;
|
||||||
import tech.easyflow.common.domain.Result;
|
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.controller.BaseCurdController;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/faqItem")
|
@RequestMapping("/api/v1/faqItem")
|
||||||
@UsePermission(moduleName = "/api/v1/documentCollection")
|
@UsePermission(moduleName = "/api/v1/documentCollection")
|
||||||
public class FaqItemController extends BaseCurdController<FaqItemService, FaqItem> {
|
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;
|
private final FaqCategoryService faqCategoryService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private DocumentCollectionService documentCollectionService;
|
||||||
|
|
||||||
|
@Resource(name = "default")
|
||||||
|
private FileStorageService fileStorageService;
|
||||||
|
|
||||||
public FaqItemController(FaqItemService service, FaqCategoryService faqCategoryService) {
|
public FaqItemController(FaqItemService service, FaqCategoryService faqCategoryService) {
|
||||||
super(service);
|
super(service);
|
||||||
this.faqCategoryService = faqCategoryService;
|
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))));
|
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
|
@Override
|
||||||
protected String getDefaultOrderBy() {
|
protected String getDefaultOrderBy() {
|
||||||
return "order_no asc";
|
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();
|
StringBuilder sb = new StringBuilder();
|
||||||
if (documents != null) {
|
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());
|
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 {
|
public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(BotServiceImpl.class);
|
private static final Logger log = LoggerFactory.getLogger(BotServiceImpl.class);
|
||||||
|
private static final String FAQ_IMAGE_SYSTEM_RULE = "当知识工具返回 Markdown 图片(格式:)时,你必须在最终回答中保留并输出对应的图片 Markdown,禁止改写、替换或省略图片 URL。";
|
||||||
|
|
||||||
public static class ChatCheckResult {
|
public static class ChatCheckResult {
|
||||||
private Bot aiBot;
|
private Bot aiBot;
|
||||||
@@ -173,7 +174,9 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
|||||||
Map<String, Object> modelOptions = chatCheckResult.getModelOptions();
|
Map<String, Object> modelOptions = chatCheckResult.getModelOptions();
|
||||||
ChatModel chatModel = chatCheckResult.getChatModel();
|
ChatModel chatModel = chatCheckResult.getChatModel();
|
||||||
final MemoryPrompt memoryPrompt = new MemoryPrompt();
|
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);
|
Integer maxMessageCount = MapUtil.getInteger(modelOptions, Bot.KEY_MAX_MESSAGE_COUNT);
|
||||||
if (maxMessageCount != null) {
|
if (maxMessageCount != null) {
|
||||||
memoryPrompt.setMaxAttachedMessageCount(maxMessageCount);
|
memoryPrompt.setMaxAttachedMessageCount(maxMessageCount);
|
||||||
@@ -221,6 +224,9 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
|||||||
Map<String, Object> modelOptions = chatCheckResult.getModelOptions();
|
Map<String, Object> modelOptions = chatCheckResult.getModelOptions();
|
||||||
ChatOptions chatOptions = getChatOptions(modelOptions);
|
ChatOptions chatOptions = getChatOptions(modelOptions);
|
||||||
ChatModel chatModel = chatCheckResult.getChatModel();
|
ChatModel chatModel = chatCheckResult.getChatModel();
|
||||||
|
String systemPrompt = buildSystemPromptWithFaqImageRule(
|
||||||
|
MapUtil.getString(modelOptions, Bot.KEY_SYSTEM_PROMPT)
|
||||||
|
);
|
||||||
UserMessage userMessage = new UserMessage(prompt);
|
UserMessage userMessage = new UserMessage(prompt);
|
||||||
userMessage.addTools(buildFunctionList(Maps.of("botId", botId)
|
userMessage.addTools(buildFunctionList(Maps.of("botId", botId)
|
||||||
.set("needEnglishName", false)
|
.set("needEnglishName", false)
|
||||||
@@ -231,6 +237,9 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
|||||||
ChatMemory defaultChatMemory = new PublicBotMessageMemory(chatSseEmitter, messages);
|
ChatMemory defaultChatMemory = new PublicBotMessageMemory(chatSseEmitter, messages);
|
||||||
final MemoryPrompt memoryPrompt = new MemoryPrompt();
|
final MemoryPrompt memoryPrompt = new MemoryPrompt();
|
||||||
memoryPrompt.setMemory(defaultChatMemory);
|
memoryPrompt.setMemory(defaultChatMemory);
|
||||||
|
if (StringUtils.hasLength(systemPrompt)) {
|
||||||
|
memoryPrompt.setSystemMessage(SystemMessage.of(systemPrompt));
|
||||||
|
}
|
||||||
memoryPrompt.addMessage(userMessage);
|
memoryPrompt.addMessage(userMessage);
|
||||||
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
|
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
|
||||||
ServletRequestAttributes sra = (ServletRequestAttributes) requestAttributes;
|
ServletRequestAttributes sra = (ServletRequestAttributes) requestAttributes;
|
||||||
@@ -397,4 +406,14 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
|||||||
return messageBuilder.toString();
|
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 {
|
public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectionMapper, DocumentCollection> implements DocumentCollectionService {
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(DocumentCollectionServiceImpl.class);
|
private static final Logger LOG = LoggerFactory.getLogger(DocumentCollectionServiceImpl.class);
|
||||||
|
private static final int MAX_FAQ_IMAGES_IN_PROMPT = 3;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private ModelService llmService;
|
private ModelService llmService;
|
||||||
@@ -256,7 +257,18 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
|
|||||||
searchDocuments.forEach(item -> {
|
searchDocuments.forEach(item -> {
|
||||||
FaqItem faqItem = faqItemMap.get(String.valueOf(item.getId()));
|
FaqItem faqItem = faqItemMap.get(String.valueOf(item.getId()));
|
||||||
if (faqItem != null) {
|
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;
|
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.core.query.QueryWrapper;
|
||||||
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
||||||
import org.jsoup.Jsoup;
|
import org.jsoup.Jsoup;
|
||||||
|
import org.jsoup.nodes.Element;
|
||||||
|
import org.jsoup.select.Elements;
|
||||||
import org.jsoup.safety.Safelist;
|
import org.jsoup.safety.Safelist;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
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 Logger LOG = LoggerFactory.getLogger(FaqItemServiceImpl.class);
|
||||||
private static final Safelist ANSWER_SAFE_LIST = Safelist.basic()
|
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
|
@Resource
|
||||||
private DocumentCollectionService documentCollectionService;
|
private DocumentCollectionService documentCollectionService;
|
||||||
@@ -57,7 +65,7 @@ public class FaqItemServiceImpl extends ServiceImpl<FaqItemMapper, FaqItem> impl
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public boolean saveFaqItem(FaqItem entity) {
|
public boolean saveFaqItem(FaqItem entity) {
|
||||||
checkAndNormalize(entity, true);
|
checkAndNormalize(entity, true, null);
|
||||||
DocumentCollection collection = getFaqCollection(entity.getCollectionId());
|
DocumentCollection collection = getFaqCollection(entity.getCollectionId());
|
||||||
entity.setCategoryId(faqCategoryService.ensureFaqItemCategory(entity.getCollectionId(), entity.getCategoryId()));
|
entity.setCategoryId(faqCategoryService.ensureFaqItemCategory(entity.getCollectionId(), entity.getCategoryId()));
|
||||||
|
|
||||||
@@ -93,7 +101,7 @@ public class FaqItemServiceImpl extends ServiceImpl<FaqItemMapper, FaqItem> impl
|
|||||||
if (entity.getCollectionId() == null) {
|
if (entity.getCollectionId() == null) {
|
||||||
entity.setCollectionId(old.getCollectionId());
|
entity.setCollectionId(old.getCollectionId());
|
||||||
}
|
}
|
||||||
checkAndNormalize(entity, false);
|
checkAndNormalize(entity, false, old.getOptions());
|
||||||
|
|
||||||
DocumentCollection collection = getFaqCollection(old.getCollectionId());
|
DocumentCollection collection = getFaqCollection(old.getCollectionId());
|
||||||
old.setCategoryId(faqCategoryService.ensureFaqItemCategory(old.getCollectionId(), entity.getCategoryId()));
|
old.setCategoryId(faqCategoryService.ensureFaqItemCategory(old.getCollectionId(), entity.getCategoryId()));
|
||||||
@@ -128,7 +136,7 @@ public class FaqItemServiceImpl extends ServiceImpl<FaqItemMapper, FaqItem> impl
|
|||||||
return removeById(id);
|
return removeById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkAndNormalize(FaqItem entity, boolean isSave) {
|
private void checkAndNormalize(FaqItem entity, boolean isSave, Map<String, Object> baseOptions) {
|
||||||
if (entity == null) {
|
if (entity == null) {
|
||||||
throw new BusinessException("FAQ条目不能为空");
|
throw new BusinessException("FAQ条目不能为空");
|
||||||
}
|
}
|
||||||
@@ -148,11 +156,24 @@ public class FaqItemServiceImpl extends ServiceImpl<FaqItemMapper, FaqItem> impl
|
|||||||
entity.setQuestion(entity.getQuestion().trim());
|
entity.setQuestion(entity.getQuestion().trim());
|
||||||
String cleanHtml = Jsoup.clean(entity.getAnswerHtml(), ANSWER_SAFE_LIST);
|
String cleanHtml = Jsoup.clean(entity.getAnswerHtml(), ANSWER_SAFE_LIST);
|
||||||
String answerText = Jsoup.parse(cleanHtml).text();
|
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("答案不能为空");
|
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.setAnswerHtml(cleanHtml);
|
||||||
entity.setAnswerText(answerText);
|
entity.setAnswerText(answerText);
|
||||||
|
entity.setOptions(normalizedOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
private DocumentCollection getFaqCollection(BigInteger collectionId) {
|
private DocumentCollection getFaqCollection(BigInteger collectionId) {
|
||||||
@@ -262,12 +283,22 @@ public class FaqItemServiceImpl extends ServiceImpl<FaqItemMapper, FaqItem> impl
|
|||||||
metadata.put("question", entity.getQuestion());
|
metadata.put("question", entity.getQuestion());
|
||||||
metadata.put("answerText", entity.getAnswerText());
|
metadata.put("answerText", entity.getAnswerText());
|
||||||
metadata.put("categoryId", entity.getCategoryId());
|
metadata.put("categoryId", entity.getCategoryId());
|
||||||
|
metadata.put("imageUrls", readImageUrls(entity.getOptions()));
|
||||||
doc.setMetadataMap(metadata);
|
doc.setMetadataMap(metadata);
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildSearchContent(FaqItem entity) {
|
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) {
|
private void markCollectionEmbedded(DocumentCollection collection, EmbeddingModel embeddingModel) {
|
||||||
@@ -307,6 +338,122 @@ public class FaqItemServiceImpl extends ServiceImpl<FaqItemMapper, FaqItem> impl
|
|||||||
return BigInteger.valueOf(StpUtil.getLoginIdAsLong());
|
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 static class PreparedStore {
|
||||||
private final DocumentStore documentStore;
|
private final DocumentStore documentStore;
|
||||||
private final StoreOptions storeOptions;
|
private final StoreOptions storeOptions;
|
||||||
|
|||||||
@@ -108,7 +108,11 @@
|
|||||||
"questionPlaceholder": "Please input question",
|
"questionPlaceholder": "Please input question",
|
||||||
"answerPlaceholder": "Please input answer",
|
"answerPlaceholder": "Please input answer",
|
||||||
"questionRequired": "Question is required",
|
"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",
|
"searchResults": "SearchResults",
|
||||||
"documentPreview": "DocumentPreview",
|
"documentPreview": "DocumentPreview",
|
||||||
|
|||||||
@@ -108,7 +108,11 @@
|
|||||||
"questionPlaceholder": "请输入问题",
|
"questionPlaceholder": "请输入问题",
|
||||||
"answerPlaceholder": "请输入答案",
|
"answerPlaceholder": "请输入答案",
|
||||||
"questionRequired": "问题不能为空",
|
"questionRequired": "问题不能为空",
|
||||||
"answerRequired": "答案不能为空"
|
"answerRequired": "答案不能为空",
|
||||||
|
"collectionRequired": "知识库ID不能为空",
|
||||||
|
"imageTypeInvalid": "仅支持 JPG/PNG/WEBP/GIF 图片",
|
||||||
|
"imageSizeExceeded": "图片大小不能超过5MB",
|
||||||
|
"imageUploadFailed": "图片上传失败"
|
||||||
},
|
},
|
||||||
"searchResults": "检索结果",
|
"searchResults": "检索结果",
|
||||||
"documentPreview": "文档预览",
|
"documentPreview": "文档预览",
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
ElTreeSelect,
|
ElTreeSelect,
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import { api } from '#/api/request';
|
||||||
|
|
||||||
import '@wangeditor/editor/dist/css/style.css';
|
import '@wangeditor/editor/dist/css/style.css';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -35,6 +37,13 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['submit', 'update:modelValue']);
|
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 editorRef = shallowRef<IDomEditor | null>(null);
|
||||||
const form = ref<any>({
|
const form = ref<any>({
|
||||||
@@ -65,19 +74,48 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const toolbarConfig = {
|
const toolbarConfig = {
|
||||||
excludeKeys: [
|
excludeKeys: ['insertVideo', 'group-video', 'uploadVideo', 'todo', 'emotion'],
|
||||||
'uploadImage',
|
|
||||||
'insertImage',
|
|
||||||
'group-image',
|
|
||||||
'insertVideo',
|
|
||||||
'group-video',
|
|
||||||
'uploadVideo',
|
|
||||||
'todo',
|
|
||||||
'emotion',
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
const editorConfig = {
|
const editorConfig: any = {
|
||||||
placeholder: $t('documentCollection.faq.answerPlaceholder'),
|
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) => {
|
const handleEditorCreated = (editor: IDomEditor) => {
|
||||||
@@ -121,9 +159,15 @@ const handleSubmit = () => {
|
|||||||
ElMessage.error($t('documentCollection.faq.questionRequired'));
|
ElMessage.error($t('documentCollection.faq.questionRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sanitizedHtml = DOMPurify.sanitize(form.value.answerHtml || '');
|
const sanitizedHtml = DOMPurify.sanitize(form.value.answerHtml || '', {
|
||||||
const pureText = sanitizedHtml.replaceAll(/<[^>]*>/g, '').trim();
|
ADD_TAGS: ['img'],
|
||||||
if (!pureText) {
|
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'));
|
ElMessage.error($t('documentCollection.faq.answerRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -238,7 +282,9 @@ onBeforeUnmount(() => {
|
|||||||
background: var(--el-fill-color-blank);
|
background: var(--el-fill-color-blank);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 8px 24px rgb(15 23 42 / 4%);
|
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 {
|
.editor-wrapper:focus-within {
|
||||||
|
|||||||
Reference in New Issue
Block a user