|
|
@@ -0,0 +1,599 @@
|
|
|
+package cn.iocoder.byzs.module.web.service.ai;
|
|
|
+
|
|
|
+import cn.hutool.core.util.ObjUtil;
|
|
|
+import cn.hutool.core.util.StrUtil;
|
|
|
+import cn.iocoder.byzs.framework.common.pojo.CommonResult;
|
|
|
+import cn.iocoder.byzs.framework.common.util.object.BeanUtils;
|
|
|
+import cn.iocoder.byzs.framework.tenant.core.util.TenantUtils;
|
|
|
+import cn.iocoder.byzs.module.ai.controller.admin.chat.vo.message.AiChatMessageSendRespVO;
|
|
|
+import cn.iocoder.byzs.module.ai.dal.dataobject.chat.AiChatConversationDO;
|
|
|
+import cn.iocoder.byzs.module.ai.dal.dataobject.chat.AiChatMessageDO;
|
|
|
+import cn.iocoder.byzs.module.ai.dal.dataobject.model.AiApiKeyDO;
|
|
|
+import cn.iocoder.byzs.module.ai.dal.dataobject.model.AiModelDO;
|
|
|
+import cn.iocoder.byzs.module.ai.dal.mysql.chat.AiChatMessageMapper;
|
|
|
+import cn.iocoder.byzs.module.ai.enums.ErrorCodeConstants;
|
|
|
+import cn.iocoder.byzs.module.ai.enums.model.AiPlatformEnum;
|
|
|
+import cn.iocoder.byzs.module.ai.service.chat.AiChatConversationService;
|
|
|
+import cn.iocoder.byzs.module.ai.service.model.AiApiKeyService;
|
|
|
+import cn.iocoder.byzs.module.ai.service.model.AiModelService;
|
|
|
+import cn.iocoder.byzs.module.web.controller.admin.ai.vo.WebQSAiChatMessageSendReqVO;
|
|
|
+import com.volcengine.ark.runtime.model.responses.constant.ResponsesConstants;
|
|
|
+import com.volcengine.ark.runtime.model.responses.content.InputContentItemFile;
|
|
|
+import com.volcengine.ark.runtime.model.responses.content.InputContentItemImage;
|
|
|
+import com.volcengine.ark.runtime.model.responses.content.InputContentItemText;
|
|
|
+import com.volcengine.ark.runtime.model.responses.content.OutputContentItem;
|
|
|
+import com.volcengine.ark.runtime.model.responses.content.OutputContentItemText;
|
|
|
+import com.volcengine.ark.runtime.model.responses.event.ErrorEvent;
|
|
|
+import com.volcengine.ark.runtime.model.responses.event.outputtext.OutputTextDeltaEvent;
|
|
|
+import com.volcengine.ark.runtime.model.responses.event.outputtext.OutputTextDoneEvent;
|
|
|
+import com.volcengine.ark.runtime.model.responses.event.response.ResponseCompletedEvent;
|
|
|
+import com.volcengine.ark.runtime.model.responses.event.response.ResponseFailedEvent;
|
|
|
+import com.volcengine.ark.runtime.model.responses.event.response.ResponseInCompleteEvent;
|
|
|
+import com.volcengine.ark.runtime.model.responses.item.BaseItem;
|
|
|
+import com.volcengine.ark.runtime.model.responses.item.ItemEasyMessage;
|
|
|
+import com.volcengine.ark.runtime.model.responses.item.ItemOutputMessage;
|
|
|
+import com.volcengine.ark.runtime.model.responses.item.MessageContent;
|
|
|
+import com.volcengine.ark.runtime.model.responses.request.CreateResponsesRequest;
|
|
|
+import com.volcengine.ark.runtime.model.responses.request.ResponsesInput;
|
|
|
+import com.volcengine.ark.runtime.model.responses.response.ResponseObject;
|
|
|
+import com.volcengine.ark.runtime.service.ArkService;
|
|
|
+import io.reactivex.disposables.Disposable;
|
|
|
+import jakarta.annotation.Resource;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.apache.poi.hwpf.extractor.WordExtractor;
|
|
|
+import org.apache.poi.ss.usermodel.Cell;
|
|
|
+import org.apache.poi.ss.usermodel.DataFormatter;
|
|
|
+import org.apache.poi.ss.usermodel.Row;
|
|
|
+import org.apache.poi.ss.usermodel.Sheet;
|
|
|
+import org.apache.poi.ss.usermodel.Workbook;
|
|
|
+import org.apache.poi.ss.usermodel.WorkbookFactory;
|
|
|
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
|
|
|
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
|
|
|
+import org.apache.poi.xwpf.usermodel.XWPFTable;
|
|
|
+import org.apache.poi.xwpf.usermodel.XWPFTableCell;
|
|
|
+import org.apache.poi.xwpf.usermodel.XWPFTableRow;
|
|
|
+import org.springframework.ai.chat.messages.MessageType;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.validation.annotation.Validated;
|
|
|
+import org.springframework.web.multipart.MultipartFile;
|
|
|
+import reactor.core.publisher.Flux;
|
|
|
+import reactor.core.scheduler.Schedulers;
|
|
|
+
|
|
|
+import java.io.IOException;
|
|
|
+import java.io.InputStream;
|
|
|
+import java.net.HttpURLConnection;
|
|
|
+import java.net.InetAddress;
|
|
|
+import java.net.URI;
|
|
|
+import java.net.URL;
|
|
|
+import java.net.URLConnection;
|
|
|
+import java.time.LocalDateTime;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Base64;
|
|
|
+import java.util.List;
|
|
|
+import java.util.concurrent.atomic.AtomicBoolean;
|
|
|
+
|
|
|
+import static cn.iocoder.byzs.framework.common.exception.util.ServiceExceptionUtil.exception;
|
|
|
+import static cn.iocoder.byzs.framework.common.pojo.CommonResult.error;
|
|
|
+import static cn.iocoder.byzs.framework.common.pojo.CommonResult.success;
|
|
|
+import static cn.iocoder.byzs.module.ai.enums.ErrorCodeConstants.CHAT_CONVERSATION_NOT_EXISTS;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Web 端支持附件的豆包 AI 问答服务。
|
|
|
+ */
|
|
|
+@Service
|
|
|
+@Validated
|
|
|
+@Slf4j
|
|
|
+public class WebQSAiServiceImpl {
|
|
|
+
|
|
|
+ private static final String DEFAULT_ARK_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
|
|
|
+ private static final long MAX_DOCUMENT_SIZE_BYTES = 10L * 1024 * 1024;
|
|
|
+ private static final long MAX_TOTAL_DOCUMENT_SIZE_BYTES = 15L * 1024 * 1024;
|
|
|
+ private static final int MAX_ATTACHMENTS = 3;
|
|
|
+ private static final int MAX_EXTRACTED_TEXT_CHARS = 20_000;
|
|
|
+ private static final int MAX_USER_CONTENT_CHARS = 1_000;
|
|
|
+ private static final int MAX_CONTEXT_MESSAGES = 6;
|
|
|
+ private static final int MAX_CONTEXT_CHARS = 12_000;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private AiChatConversationService chatConversationService;
|
|
|
+ @Resource
|
|
|
+ private AiChatMessageMapper chatMessageMapper;
|
|
|
+ @Resource
|
|
|
+ private AiModelService modelService;
|
|
|
+ @Resource
|
|
|
+ private AiApiKeyService apiKeyService;
|
|
|
+
|
|
|
+ public Flux<CommonResult<AiChatMessageSendRespVO>> sendChatMessageStream(
|
|
|
+ WebQSAiChatMessageSendReqVO sendReqVO, Long userId) {
|
|
|
+ return sendChatMessageStream(sendReqVO, userId, List.of());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 处理 multipart/form-data 提交的本地文件。文件只会写入临时目录并上传至 Ark,不会保存到业务文件存储。
|
|
|
+ */
|
|
|
+ public Flux<CommonResult<AiChatMessageSendRespVO>> sendChatMessageStream(
|
|
|
+ WebQSAiChatMessageSendReqVO sendReqVO, Long userId, List<MultipartFile> multipartFiles) {
|
|
|
+ List<MultipartFile> files = multipartFiles == null ? List.of() : multipartFiles;
|
|
|
+ int urlAttachmentCount = sendReqVO.getAttachments() == null ? 0 : sendReqVO.getAttachments().size();
|
|
|
+ if (urlAttachmentCount + files.size() > MAX_ATTACHMENTS) {
|
|
|
+ throw new IllegalArgumentException("单次最多上传 " + MAX_ATTACHMENTS + " 个附件");
|
|
|
+ }
|
|
|
+ AiChatConversationDO conversation = chatConversationService
|
|
|
+ .validateChatConversationExists(sendReqVO.getConversationId());
|
|
|
+ if (ObjUtil.notEqual(conversation.getUserId(), userId)) {
|
|
|
+ throw exception(CHAT_CONVERSATION_NOT_EXISTS);
|
|
|
+ }
|
|
|
+
|
|
|
+ AiModelDO model = modelService.validateModel(conversation.getModelId());
|
|
|
+ AiApiKeyDO apiKey = apiKeyService.validateApiKey(model.getKeyId());
|
|
|
+ if (!AiPlatformEnum.DOU_BAO.getPlatform().equals(apiKey.getPlatform())) {
|
|
|
+ throw new IllegalArgumentException("支持附件的问答目前仅支持豆包模型");
|
|
|
+ }
|
|
|
+
|
|
|
+ String userContent = StrUtil.blankToDefault(sendReqVO.getContent(), "请分析附件内容。");
|
|
|
+ if (userContent.length() > MAX_USER_CONTENT_CHARS) {
|
|
|
+ throw new IllegalArgumentException("提问内容不能超过 " + MAX_USER_CONTENT_CHARS + " 个字符");
|
|
|
+ }
|
|
|
+ validateMultipartFilesSize(files);
|
|
|
+ List<AiChatMessageDO> historyMessages = chatMessageMapper.selectListByConversationId(conversation.getId());
|
|
|
+ ArkService arkService = ArkService.builder()
|
|
|
+ .apiKey(apiKey.getApiKey())
|
|
|
+ .baseUrl(StrUtil.blankToDefault(apiKey.getUrl(), DEFAULT_ARK_BASE_URL))
|
|
|
+ .build();
|
|
|
+ CreateResponsesRequest request;
|
|
|
+ try {
|
|
|
+ request = buildRequest(model, historyMessages, sendReqVO, userContent, files);
|
|
|
+ } catch (RuntimeException e) {
|
|
|
+ arkService.shutdownExecutor();
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+ AiChatMessageDO userMessage = createChatMessage(conversation, model, userId, null,
|
|
|
+ MessageType.USER, userContent, sendReqVO.getUseContext());
|
|
|
+ AiChatMessageDO assistantMessage = createChatMessage(conversation, model, userId, userMessage.getId(),
|
|
|
+ MessageType.ASSISTANT, "", sendReqVO.getUseContext());
|
|
|
+
|
|
|
+ StringBuilder answer = new StringBuilder();
|
|
|
+ AtomicBoolean serviceClosed = new AtomicBoolean(false);
|
|
|
+
|
|
|
+ return Flux.<CommonResult<AiChatMessageSendRespVO>>create(sink -> {
|
|
|
+ try {
|
|
|
+ Disposable disposable = arkService.streamResponse(request).subscribe(event -> {
|
|
|
+ if (event instanceof OutputTextDeltaEvent textDeltaEvent) {
|
|
|
+ String delta = textDeltaEvent.getDelta();
|
|
|
+ if (StrUtil.isNotEmpty(delta)) {
|
|
|
+ answer.append(delta);
|
|
|
+ sink.next(success(createTextResponse(userMessage, assistantMessage, delta)));
|
|
|
+ }
|
|
|
+ } else if (event instanceof OutputTextDoneEvent textDoneEvent) {
|
|
|
+ // 部分模型只发送完成事件,不发送 delta;此处兜底输出完整文本。
|
|
|
+ emitCompletedTextIfNecessary(answer, textDoneEvent.getText(), sink,
|
|
|
+ userMessage, assistantMessage);
|
|
|
+ } else if (event instanceof ResponseCompletedEvent completedEvent) {
|
|
|
+ // 再以 response.completed 中的完整 output 兜底,避免事件类型差异导致空回复。
|
|
|
+ emitCompletedTextIfNecessary(answer, extractResponseText(completedEvent.getResponse()), sink,
|
|
|
+ userMessage, assistantMessage);
|
|
|
+ } else if (event instanceof ResponseFailedEvent failedEvent) {
|
|
|
+ sink.error(new IllegalStateException("豆包模型调用失败:" + failedEvent.getResponse()));
|
|
|
+ } else if (event instanceof ResponseInCompleteEvent incompleteEvent) {
|
|
|
+ // SDK 会将部分模型或参数错误以 response.incomplete 事件返回,而不是回调 onError。
|
|
|
+ sink.error(new IllegalStateException("豆包模型调用未完成:" + incompleteEvent.getResponse()));
|
|
|
+ } else if (event instanceof ErrorEvent errorEvent) {
|
|
|
+ // Responses API 的 SSE error 事件同样不会触发 RxJava 的 onError 回调,必须显式转为异常。
|
|
|
+ sink.error(new IllegalStateException("豆包模型调用失败[" + errorEvent.getCode() + "]:"
|
|
|
+ + errorEvent.getMessage()));
|
|
|
+ } else {
|
|
|
+ log.debug("[sendChatMessageStream][忽略 Ark 流事件: {}]", event);
|
|
|
+ }
|
|
|
+ }, sink::error, sink::complete);
|
|
|
+ sink.onCancel(disposable::dispose);
|
|
|
+ } catch (Exception e) {
|
|
|
+ sink.error(e);
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .subscribeOn(Schedulers.boundedElastic())
|
|
|
+ .doFinally(signalType -> {
|
|
|
+ TenantUtils.executeIgnore(() -> chatMessageMapper.updateById(
|
|
|
+ new AiChatMessageDO().setId(assistantMessage.getId()).setContent(answer.toString())));
|
|
|
+ if (serviceClosed.compareAndSet(false, true)) {
|
|
|
+ arkService.shutdownExecutor();
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .onErrorResume(throwable -> {
|
|
|
+ log.error("[sendChatMessageStream][userId({}) 会话({}) 调用豆包附件问答失败]", userId,
|
|
|
+ conversation.getId(), throwable);
|
|
|
+ return Flux.just(error(ErrorCodeConstants.CHAT_STREAM_ERROR));
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private void emitCompletedTextIfNecessary(StringBuilder answer, String completedText,
|
|
|
+ reactor.core.publisher.FluxSink<CommonResult<AiChatMessageSendRespVO>> sink,
|
|
|
+ AiChatMessageDO userMessage, AiChatMessageDO assistantMessage) {
|
|
|
+ if (answer.isEmpty() && StrUtil.isNotBlank(completedText)) {
|
|
|
+ answer.append(completedText);
|
|
|
+ sink.next(success(createTextResponse(userMessage, assistantMessage, completedText)));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String extractResponseText(ResponseObject response) {
|
|
|
+ if (response == null || response.getOutput() == null) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ StringBuilder text = new StringBuilder();
|
|
|
+ for (BaseItem item : response.getOutput()) {
|
|
|
+ if (item instanceof ItemOutputMessage message && message.getContent() != null) {
|
|
|
+ for (OutputContentItem content : message.getContent()) {
|
|
|
+ if (content instanceof OutputContentItemText textContent && StrUtil.isNotBlank(textContent.getText())) {
|
|
|
+ text.append(textContent.getText());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return text.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ private CreateResponsesRequest buildRequest(AiModelDO model, List<AiChatMessageDO> historyMessages,
|
|
|
+ WebQSAiChatMessageSendReqVO sendReqVO, String userContent,
|
|
|
+ List<MultipartFile> multipartFiles) {
|
|
|
+ ResponsesInput.Builder inputBuilder = ResponsesInput.builder();
|
|
|
+ if (Boolean.TRUE.equals(sendReqVO.getUseContext())) {
|
|
|
+ int configuredMaxContexts = model.getMaxContexts() == null ? historyMessages.size() : model.getMaxContexts();
|
|
|
+ int maxContexts = Math.min(configuredMaxContexts, MAX_CONTEXT_MESSAGES);
|
|
|
+ int startIndex = Math.max(0, historyMessages.size() - maxContexts);
|
|
|
+ List<ItemEasyMessage> contextMessages = new ArrayList<>();
|
|
|
+ int contextChars = 0;
|
|
|
+ // 从最新消息开始保留,达到总字符上限后停止,最后恢复为时间正序。
|
|
|
+ for (int i = historyMessages.size() - 1; i >= startIndex && contextChars < MAX_CONTEXT_CHARS; i--) {
|
|
|
+ AiChatMessageDO historyMessage = historyMessages.get(i);
|
|
|
+ if (MessageType.USER.getValue().equals(historyMessage.getType())
|
|
|
+ || MessageType.ASSISTANT.getValue().equals(historyMessage.getType())) {
|
|
|
+ String content = StrUtil.nullToEmpty(historyMessage.getContent());
|
|
|
+ int remainingChars = MAX_CONTEXT_CHARS - contextChars;
|
|
|
+ if (content.length() > remainingChars) {
|
|
|
+ // 保留较新的文本尾部,避免单条历史消息耗尽全部上下文预算。
|
|
|
+ content = content.substring(content.length() - remainingChars);
|
|
|
+ }
|
|
|
+ contextMessages.add(0, ItemEasyMessage.builder()
|
|
|
+ .role(historyMessage.getType())
|
|
|
+ .content(MessageContent.builder().stringValue(content).build())
|
|
|
+ .build());
|
|
|
+ contextChars += content.length();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ contextMessages.forEach(inputBuilder::addListItem);
|
|
|
+ }
|
|
|
+
|
|
|
+ MessageContent.Builder contentBuilder = MessageContent.builder();
|
|
|
+ AttachmentSizeCounter attachmentSizeCounter = new AttachmentSizeCounter();
|
|
|
+ if (sendReqVO.getAttachments() != null) {
|
|
|
+ for (WebQSAiChatMessageSendReqVO.Attachment attachment : sendReqVO.getAttachments()) {
|
|
|
+ contentBuilder.addListItem(buildAttachmentContent(attachment, attachmentSizeCounter));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for (MultipartFile multipartFile : multipartFiles) {
|
|
|
+ contentBuilder.addListItem(buildMultipartFileContent(multipartFile, attachmentSizeCounter));
|
|
|
+ }
|
|
|
+ contentBuilder.addListItem(InputContentItemText.builder().text(userContent).build());
|
|
|
+ inputBuilder.addListItem(ItemEasyMessage.builder()
|
|
|
+ .role(ResponsesConstants.MESSAGE_ROLE_USER)
|
|
|
+ .content(contentBuilder.build())
|
|
|
+ .build());
|
|
|
+
|
|
|
+ return CreateResponsesRequest.builder()
|
|
|
+ .model(model.getModel())
|
|
|
+ .stream(true)
|
|
|
+ .temperature(model.getTemperature())
|
|
|
+ .maxOutputTokens(model.getMaxTokens() == null ? null : model.getMaxTokens().longValue())
|
|
|
+ .input(inputBuilder.build())
|
|
|
+ .build();
|
|
|
+ }
|
|
|
+
|
|
|
+ private com.volcengine.ark.runtime.model.responses.content.InputContentItem buildAttachmentContent(
|
|
|
+ WebQSAiChatMessageSendReqVO.Attachment attachment, AttachmentSizeCounter attachmentSizeCounter) {
|
|
|
+ validateAttachmentUrl(attachment.getUrl());
|
|
|
+ String fileName = normalizeOfficeFileName(
|
|
|
+ StrUtil.blankToDefault(attachment.getName(), getFileName(attachment.getUrl())), attachment.getContentType());
|
|
|
+ String extension = StrUtil.subAfter(fileName, '.', true).toLowerCase();
|
|
|
+ if (isImage(extension, attachment.getContentType())) {
|
|
|
+ return InputContentItemImage.builder().imageUrl(attachment.getUrl()).build();
|
|
|
+ }
|
|
|
+ if (!isDocument(extension, attachment.getContentType())) {
|
|
|
+ throw new IllegalArgumentException("暂只支持图片、PDF、Word 和 Excel 文件:" + fileName);
|
|
|
+ }
|
|
|
+ if (isOfficeDocument(extension, attachment.getContentType())) {
|
|
|
+ byte[] content = downloadAttachment(attachment.getUrl());
|
|
|
+ attachmentSizeCounter.add(content.length);
|
|
|
+ return buildOfficeTextContent(content, fileName);
|
|
|
+ }
|
|
|
+ // PDF URL 必须能由 Ark 服务端访问(公网 URL 或未过期的签名 URL)。
|
|
|
+ // file_url 不能同时携带 filename。
|
|
|
+ InputContentItemFile file = new InputContentItemFile();
|
|
|
+ file.setFileUrl(attachment.getUrl());
|
|
|
+ return file;
|
|
|
+ }
|
|
|
+
|
|
|
+ private com.volcengine.ark.runtime.model.responses.content.InputContentItem buildMultipartFileContent(
|
|
|
+ MultipartFile multipartFile, AttachmentSizeCounter attachmentSizeCounter) {
|
|
|
+ if (multipartFile == null || multipartFile.isEmpty()) {
|
|
|
+ throw new IllegalArgumentException("不能上传空文件");
|
|
|
+ }
|
|
|
+ if (multipartFile.getSize() > MAX_DOCUMENT_SIZE_BYTES) {
|
|
|
+ throw new IllegalArgumentException("单个附件大小不能超过 10 MB");
|
|
|
+ }
|
|
|
+ attachmentSizeCounter.add(multipartFile.getSize());
|
|
|
+ String fileName = normalizeOfficeFileName(
|
|
|
+ StrUtil.blankToDefault(multipartFile.getOriginalFilename(), "attachment"), multipartFile.getContentType());
|
|
|
+ String extension = StrUtil.subAfter(fileName, '.', true).toLowerCase();
|
|
|
+ if (!isImage(extension, multipartFile.getContentType())
|
|
|
+ && !isDocument(extension, multipartFile.getContentType())) {
|
|
|
+ throw new IllegalArgumentException("暂只支持图片、PDF、Word 和 Excel 文件:" + fileName);
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ byte[] source = multipartFile.getBytes();
|
|
|
+ if (isOfficeDocument(extension, multipartFile.getContentType())) {
|
|
|
+ return buildOfficeTextContent(source, fileName);
|
|
|
+ }
|
|
|
+ return buildFileDataContent(source, fileName);
|
|
|
+ } catch (IOException e) {
|
|
|
+ throw new IllegalArgumentException("读取上传附件失败: " + fileName, e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private InputContentItemFile buildFileDataContent(byte[] content, String fileName) {
|
|
|
+ InputContentItemFile inputFile = new InputContentItemFile();
|
|
|
+ inputFile.setFileData(Base64.getEncoder().encodeToString(content));
|
|
|
+ inputFile.setFileName(fileName);
|
|
|
+ return inputFile;
|
|
|
+ }
|
|
|
+
|
|
|
+ private InputContentItemText buildOfficeTextContent(byte[] content, String fileName) {
|
|
|
+ String text = extractOfficeText(content, fileName);
|
|
|
+ return InputContentItemText.builder()
|
|
|
+ .text("以下是附件《" + fileName + "》解析出的文本内容:\n" + text)
|
|
|
+ .build();
|
|
|
+ }
|
|
|
+
|
|
|
+ private String extractOfficeText(byte[] content, String fileName) {
|
|
|
+ String extension = StrUtil.subAfter(fileName, '.', true).toLowerCase();
|
|
|
+ try {
|
|
|
+ String text = switch (extension) {
|
|
|
+ case "docx" -> extractDocxText(content);
|
|
|
+ case "doc" -> extractDocText(content);
|
|
|
+ case "xlsx", "xls" -> extractExcelText(content);
|
|
|
+ default -> throw new IllegalArgumentException("不支持解析的 Office 文件类型:" + fileName);
|
|
|
+ };
|
|
|
+ if (StrUtil.isBlank(text)) {
|
|
|
+ throw new IllegalArgumentException("未从附件中提取到可供 AI 分析的文本:" + fileName);
|
|
|
+ }
|
|
|
+ return limitExtractedText(text);
|
|
|
+ } catch (IOException e) {
|
|
|
+ throw new IllegalArgumentException("解析 Office 附件失败:" + fileName, e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String extractDocxText(byte[] content) throws IOException {
|
|
|
+ StringBuilder text = new StringBuilder();
|
|
|
+ try (XWPFDocument document = new XWPFDocument(new java.io.ByteArrayInputStream(content))) {
|
|
|
+ for (XWPFParagraph paragraph : document.getParagraphs()) {
|
|
|
+ appendLine(text, paragraph.getText());
|
|
|
+ }
|
|
|
+ for (XWPFTable table : document.getTables()) {
|
|
|
+ for (XWPFTableRow row : table.getRows()) {
|
|
|
+ StringBuilder rowText = new StringBuilder();
|
|
|
+ for (XWPFTableCell cell : row.getTableCells()) {
|
|
|
+ if (!rowText.isEmpty()) {
|
|
|
+ rowText.append(" | ");
|
|
|
+ }
|
|
|
+ rowText.append(cell.getText().replace('\n', ' ').trim());
|
|
|
+ }
|
|
|
+ appendLine(text, rowText.toString());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return text.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ private String extractDocText(byte[] content) throws IOException {
|
|
|
+ try (WordExtractor extractor = new WordExtractor(new java.io.ByteArrayInputStream(content))) {
|
|
|
+ return extractor.getText();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String extractExcelText(byte[] content) throws IOException {
|
|
|
+ StringBuilder text = new StringBuilder();
|
|
|
+ try (Workbook workbook = WorkbookFactory.create(new java.io.ByteArrayInputStream(content))) {
|
|
|
+ DataFormatter formatter = new DataFormatter();
|
|
|
+ for (Sheet sheet : workbook) {
|
|
|
+ appendLine(text, "工作表:" + sheet.getSheetName());
|
|
|
+ for (Row row : sheet) {
|
|
|
+ StringBuilder rowText = new StringBuilder();
|
|
|
+ for (Cell cell : row) {
|
|
|
+ if (!rowText.isEmpty()) {
|
|
|
+ rowText.append(" | ");
|
|
|
+ }
|
|
|
+ rowText.append(formatter.formatCellValue(cell));
|
|
|
+ }
|
|
|
+ if (!rowText.isEmpty()) {
|
|
|
+ appendLine(text, rowText.toString());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return text.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void appendLine(StringBuilder text, String line) {
|
|
|
+ if (StrUtil.isNotBlank(line)) {
|
|
|
+ text.append(line.trim()).append('\n');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String limitExtractedText(String text) {
|
|
|
+ if (text.length() <= MAX_EXTRACTED_TEXT_CHARS) {
|
|
|
+ return text;
|
|
|
+ }
|
|
|
+ return text.substring(0, MAX_EXTRACTED_TEXT_CHARS)
|
|
|
+ + "\n[附件文本过长,已截取前 " + MAX_EXTRACTED_TEXT_CHARS + " 个字符]";
|
|
|
+ }
|
|
|
+
|
|
|
+ private void validateMultipartFilesSize(List<MultipartFile> files) {
|
|
|
+ long totalSize = files.stream()
|
|
|
+ .filter(file -> file != null)
|
|
|
+ .mapToLong(MultipartFile::getSize)
|
|
|
+ .sum();
|
|
|
+ if (totalSize > MAX_TOTAL_DOCUMENT_SIZE_BYTES) {
|
|
|
+ throw new IllegalArgumentException("附件总大小不能超过 15 MB");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static class AttachmentSizeCounter {
|
|
|
+
|
|
|
+ private long totalSize;
|
|
|
+
|
|
|
+ private void add(long size) {
|
|
|
+ totalSize += size;
|
|
|
+ if (totalSize > MAX_TOTAL_DOCUMENT_SIZE_BYTES) {
|
|
|
+ throw new IllegalArgumentException("附件总大小不能超过 15 MB");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private byte[] downloadAttachment(String fileUrl) {
|
|
|
+ try {
|
|
|
+ URI uri = URI.create(fileUrl);
|
|
|
+ validatePublicHost(uri);
|
|
|
+ URLConnection connection = new URL(fileUrl).openConnection();
|
|
|
+ connection.setConnectTimeout(10_000);
|
|
|
+ connection.setReadTimeout(30_000);
|
|
|
+ if (connection instanceof HttpURLConnection httpConnection) {
|
|
|
+ httpConnection.setInstanceFollowRedirects(false);
|
|
|
+ if (httpConnection.getResponseCode() != HttpURLConnection.HTTP_OK) {
|
|
|
+ throw new IOException("附件下载失败,HTTP 状态码: " + httpConnection.getResponseCode());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (connection.getContentLengthLong() > MAX_DOCUMENT_SIZE_BYTES) {
|
|
|
+ throw new IllegalArgumentException("单个附件大小不能超过 10 MB");
|
|
|
+ }
|
|
|
+ try (InputStream inputStream = connection.getInputStream()) {
|
|
|
+ return readWithSizeLimit(inputStream);
|
|
|
+ }
|
|
|
+ } catch (IOException e) {
|
|
|
+ throw new IllegalArgumentException("下载 Office 附件失败", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private byte[] readWithSizeLimit(InputStream inputStream) throws IOException {
|
|
|
+ try (java.io.ByteArrayOutputStream outputStream = new java.io.ByteArrayOutputStream()) {
|
|
|
+ byte[] buffer = new byte[8192];
|
|
|
+ long total = 0;
|
|
|
+ int read;
|
|
|
+ while ((read = inputStream.read(buffer)) != -1) {
|
|
|
+ total += read;
|
|
|
+ if (total > MAX_DOCUMENT_SIZE_BYTES) {
|
|
|
+ throw new IllegalArgumentException("单个附件大小不能超过 10 MB");
|
|
|
+ }
|
|
|
+ outputStream.write(buffer, 0, read);
|
|
|
+ }
|
|
|
+ return outputStream.toByteArray();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void validatePublicHost(URI uri) {
|
|
|
+ String host = uri.getHost();
|
|
|
+ if (StrUtil.isBlank(host)) {
|
|
|
+ throw new IllegalArgumentException("附件 URL 缺少主机名");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ for (InetAddress address : InetAddress.getAllByName(host)) {
|
|
|
+ if (address.isAnyLocalAddress() || address.isLoopbackAddress() || address.isSiteLocalAddress()
|
|
|
+ || address.isLinkLocalAddress() || address.isMulticastAddress()) {
|
|
|
+ throw new IllegalArgumentException("附件 URL 不允许使用内网地址");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (IOException e) {
|
|
|
+ throw new IllegalArgumentException("附件 URL 域名无法解析", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean isImage(String extension, String contentType) {
|
|
|
+ return StrUtil.startWithIgnoreCase(contentType, "image/")
|
|
|
+ || "jpg".equals(extension) || "jpeg".equals(extension) || "png".equals(extension)
|
|
|
+ || "webp".equals(extension) || "gif".equals(extension) || "bmp".equals(extension);
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean isDocument(String extension, String contentType) {
|
|
|
+ return "pdf".equals(extension) || "doc".equals(extension) || "docx".equals(extension)
|
|
|
+ || "xls".equals(extension) || "xlsx".equals(extension)
|
|
|
+ || "application/pdf".equalsIgnoreCase(contentType)
|
|
|
+ || "application/msword".equalsIgnoreCase(contentType)
|
|
|
+ || "application/vnd.openxmlformats-officedocument.wordprocessingml.document".equalsIgnoreCase(contentType)
|
|
|
+ || "application/vnd.ms-excel".equalsIgnoreCase(contentType)
|
|
|
+ || "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".equalsIgnoreCase(contentType);
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean isOfficeDocument(String extension, String contentType) {
|
|
|
+ return "doc".equals(extension) || "docx".equals(extension) || "xls".equals(extension) || "xlsx".equals(extension)
|
|
|
+ || "application/msword".equalsIgnoreCase(contentType)
|
|
|
+ || "application/vnd.openxmlformats-officedocument.wordprocessingml.document".equalsIgnoreCase(contentType)
|
|
|
+ || "application/vnd.ms-excel".equalsIgnoreCase(contentType)
|
|
|
+ || "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".equalsIgnoreCase(contentType);
|
|
|
+ }
|
|
|
+
|
|
|
+ private String normalizeOfficeFileName(String fileName, String contentType) {
|
|
|
+ if (StrUtil.isNotBlank(StrUtil.subAfter(fileName, '.', true))) {
|
|
|
+ return fileName;
|
|
|
+ }
|
|
|
+ if ("application/msword".equalsIgnoreCase(contentType)) {
|
|
|
+ return fileName + ".doc";
|
|
|
+ }
|
|
|
+ if ("application/vnd.openxmlformats-officedocument.wordprocessingml.document".equalsIgnoreCase(contentType)) {
|
|
|
+ return fileName + ".docx";
|
|
|
+ }
|
|
|
+ if ("application/vnd.ms-excel".equalsIgnoreCase(contentType)) {
|
|
|
+ return fileName + ".xls";
|
|
|
+ }
|
|
|
+ if ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".equalsIgnoreCase(contentType)) {
|
|
|
+ return fileName + ".xlsx";
|
|
|
+ }
|
|
|
+ return fileName;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String getFileName(String url) {
|
|
|
+ try {
|
|
|
+ URI uri = URI.create(url);
|
|
|
+ String path = uri.getPath();
|
|
|
+ String fileName = path == null ? null : StrUtil.subAfter(path, '/', true);
|
|
|
+ return StrUtil.blankToDefault(fileName, "attachment");
|
|
|
+ } catch (IllegalArgumentException e) {
|
|
|
+ throw new IllegalArgumentException("附件 URL 不合法", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void validateAttachmentUrl(String url) {
|
|
|
+ try {
|
|
|
+ URI uri = URI.create(url);
|
|
|
+ if (!"http".equalsIgnoreCase(uri.getScheme()) && !"https".equalsIgnoreCase(uri.getScheme())) {
|
|
|
+ throw new IllegalArgumentException("附件 URL 必须使用 HTTP 或 HTTPS 协议");
|
|
|
+ }
|
|
|
+ } catch (IllegalArgumentException e) {
|
|
|
+ throw new IllegalArgumentException("附件 URL 不合法", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private AiChatMessageDO createChatMessage(AiChatConversationDO conversation, AiModelDO model, Long userId,
|
|
|
+ Long replyId, MessageType messageType, String content, Boolean useContext) {
|
|
|
+ AiChatMessageDO message = new AiChatMessageDO().setConversationId(conversation.getId()).setReplyId(replyId)
|
|
|
+ .setModel(model.getModel()).setModelId(model.getId()).setUserId(userId).setRoleId(conversation.getRoleId())
|
|
|
+ .setType(messageType.getValue()).setContent(content).setUseContext(useContext);
|
|
|
+ message.setCreateTime(LocalDateTime.now());
|
|
|
+ chatMessageMapper.insert(message);
|
|
|
+ return message;
|
|
|
+ }
|
|
|
+
|
|
|
+ private AiChatMessageSendRespVO createTextResponse(AiChatMessageDO userMessage,
|
|
|
+ AiChatMessageDO assistantMessage, String delta) {
|
|
|
+ return new AiChatMessageSendRespVO()
|
|
|
+ .setEventType("TEXT")
|
|
|
+ .setSend(BeanUtils.toBean(userMessage, AiChatMessageSendRespVO.Message.class))
|
|
|
+ .setReceive(BeanUtils.toBean(assistantMessage, AiChatMessageSendRespVO.Message.class).setContent(delta));
|
|
|
+ }
|
|
|
+}
|