|
@@ -1,665 +0,0 @@
|
|
|
-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.AiChatMessageSendReqVO;
|
|
|
|
|
-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.service.chat.AiChatConversationService;
|
|
|
|
|
-import cn.iocoder.byzs.module.ai.service.chat.AiChatMessageService;
|
|
|
|
|
-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.fasterxml.jackson.databind.JsonNode;
|
|
|
|
|
-import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
|
|
-import com.fasterxml.jackson.databind.node.ArrayNode;
|
|
|
|
|
-import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
|
|
|
-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.net.http.HttpClient;
|
|
|
|
|
-import java.net.http.HttpRequest;
|
|
|
|
|
-import java.net.http.HttpResponse;
|
|
|
|
|
-import java.time.Duration;
|
|
|
|
|
-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 端支持附件的 DeepSeek AI 问答服务。
|
|
|
|
|
- */
|
|
|
|
|
-@Service
|
|
|
|
|
-@Validated
|
|
|
|
|
-@Slf4j
|
|
|
|
|
-public class WebQSAiServiceImpl {
|
|
|
|
|
-
|
|
|
|
|
- // DeepSeek API URL 固定
|
|
|
|
|
- private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions";
|
|
|
|
|
-
|
|
|
|
|
- /** 单个 URL 附件允许下载的最大文件大小:10 MB。 */
|
|
|
|
|
- private static final long MAX_DOCUMENT_SIZE_BYTES = 10L * 1024 * 1024;
|
|
|
|
|
- /** 一次请求中,本地上传附件允许的总文件大小:15 MB。 */
|
|
|
|
|
- private static final long MAX_TOTAL_DOCUMENT_SIZE_BYTES = 15L * 1024 * 1024;
|
|
|
|
|
- /** 一次请求允许携带的附件总数,包括 URL 附件和本地上传附件。 */
|
|
|
|
|
- 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 = 2_000;
|
|
|
|
|
- /** 携带历史上下文时,最多选取的最近历史消息数量。 */
|
|
|
|
|
- private static final int MAX_CONTEXT_MESSAGES = 6;
|
|
|
|
|
- /** 筛选历史上下文时允许累计的最大字符数,用于控制发送给模型的上下文长度。 */
|
|
|
|
|
- private static final int MAX_CONTEXT_CHARS = 12_000;
|
|
|
|
|
-
|
|
|
|
|
- @Resource
|
|
|
|
|
- private AiChatConversationService chatConversationService;
|
|
|
|
|
- @Resource
|
|
|
|
|
- private AiChatMessageService chatMessageService;
|
|
|
|
|
- @Resource
|
|
|
|
|
- private AiChatMessageMapper chatMessageMapper;
|
|
|
|
|
- @Resource
|
|
|
|
|
- private AiModelService modelService;
|
|
|
|
|
- @Resource
|
|
|
|
|
- private AiApiKeyService apiKeyService;
|
|
|
|
|
-
|
|
|
|
|
- private final HttpClient httpClient = HttpClient.newBuilder()
|
|
|
|
|
- .connectTimeout(Duration.ofSeconds(30))
|
|
|
|
|
- .build();
|
|
|
|
|
- private final ObjectMapper objectMapper = new ObjectMapper();
|
|
|
|
|
-
|
|
|
|
|
- public Flux<CommonResult<AiChatMessageSendRespVO>> sendChatMessageStream(
|
|
|
|
|
- WebQSAiChatMessageSendReqVO sendReqVO, Long userId) {
|
|
|
|
|
- return sendChatMessageStream(sendReqVO, userId, List.of());
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * 处理 multipart/form-data 提交的本地文件。
|
|
|
|
|
- */
|
|
|
|
|
- public Flux<CommonResult<AiChatMessageSendRespVO>> sendChatMessageStream(
|
|
|
|
|
- WebQSAiChatMessageSendReqVO sendReqVO, Long userId, List<MultipartFile> multipartFiles) {
|
|
|
|
|
- List<MultipartFile> files = multipartFiles == null ? List.of() : multipartFiles;
|
|
|
|
|
- if (sendReqVO.getAttachments() == null || sendReqVO.getAttachments().isEmpty()) {
|
|
|
|
|
- if (files.isEmpty()) {
|
|
|
|
|
- return sendTextChatMessageStream(sendReqVO, userId);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- 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());
|
|
|
|
|
-
|
|
|
|
|
- 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());
|
|
|
|
|
-
|
|
|
|
|
- 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 {
|
|
|
|
|
- // 构建 DeepSeek 请求
|
|
|
|
|
- String requestBody = buildDeepSeekRequest(conversation, model, apiKey, historyMessages,
|
|
|
|
|
- sendReqVO, userContent, files);
|
|
|
|
|
-
|
|
|
|
|
- // 构建 HTTP 请求(URL 固定,API Key 从数据库获取)
|
|
|
|
|
- HttpRequest httpRequest = HttpRequest.newBuilder()
|
|
|
|
|
- .uri(URI.create(DEEPSEEK_API_URL))
|
|
|
|
|
- .header("Content-Type", "application/json")
|
|
|
|
|
- .header("Authorization", "Bearer " + apiKey.getApiKey())
|
|
|
|
|
- .timeout(Duration.ofSeconds(300))
|
|
|
|
|
- .POST(HttpRequest.BodyPublishers.ofString(requestBody))
|
|
|
|
|
- .build();
|
|
|
|
|
-
|
|
|
|
|
- // 发送流式请求
|
|
|
|
|
- httpClient.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines())
|
|
|
|
|
- .thenAccept(response -> {
|
|
|
|
|
- if (response.statusCode() != 200) {
|
|
|
|
|
- String errorBody = response.body() != null ? response.body().toString() : "";
|
|
|
|
|
- log.error("DeepSeek API 错误: HTTP {}, body: {}", response.statusCode(), errorBody);
|
|
|
|
|
- sink.error(new RuntimeException("DeepSeek API 错误: HTTP " + response.statusCode()));
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- response.body().forEach(line -> {
|
|
|
|
|
- if (!line.startsWith("data: ")) {
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- String data = line.substring(6).trim();
|
|
|
|
|
-
|
|
|
|
|
- if ("[DONE]".equals(data)) {
|
|
|
|
|
- TenantUtils.executeIgnore(() ->
|
|
|
|
|
- chatMessageMapper.updateById(
|
|
|
|
|
- new AiChatMessageDO().setId(assistantMessage.getId())
|
|
|
|
|
- .setContent(answer.toString())));
|
|
|
|
|
- sink.complete();
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- JsonNode chunk = objectMapper.readTree(data);
|
|
|
|
|
- JsonNode choices = chunk.path("choices");
|
|
|
|
|
- if (choices.isEmpty()) {
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- JsonNode delta = choices.path(0).path("delta");
|
|
|
|
|
- // 跳过空对象
|
|
|
|
|
- if (delta == null || delta.isNull() || delta.size() == 0) {
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 处理普通文本内容
|
|
|
|
|
- if (delta.has("content")) {
|
|
|
|
|
- JsonNode contentNode = delta.path("content");
|
|
|
|
|
- if (!contentNode.isNull()) {
|
|
|
|
|
- String deltaText = contentNode.asText();
|
|
|
|
|
- if (StrUtil.isNotEmpty(deltaText) && !"null".equals(deltaText)) {
|
|
|
|
|
- answer.append(deltaText);
|
|
|
|
|
- sink.next(success(createTextResponse(userMessage, assistantMessage, deltaText)));
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 记录思考过程(仅日志,不输出)
|
|
|
|
|
- if (delta.has("reasoning_content")) {
|
|
|
|
|
- JsonNode reasoningNode = delta.path("reasoning_content");
|
|
|
|
|
- if (!reasoningNode.isNull()) {
|
|
|
|
|
- String reasoning = reasoningNode.asText();
|
|
|
|
|
- if (StrUtil.isNotEmpty(reasoning) && !"null".equals(reasoning)) {
|
|
|
|
|
- log.debug("DeepSeek 思考过程: {}", reasoning);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- } catch (Exception e) {
|
|
|
|
|
- log.warn("解析 DeepSeek 响应失败: {}, line: {}", e.getMessage(), line);
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
- })
|
|
|
|
|
- .exceptionally(e -> {
|
|
|
|
|
- log.error("DeepSeek API 调用异常", e);
|
|
|
|
|
- sink.error(e);
|
|
|
|
|
- return null;
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- } catch (Exception e) {
|
|
|
|
|
- log.error("构建 DeepSeek 请求失败", e);
|
|
|
|
|
- sink.error(e);
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
- .subscribeOn(Schedulers.boundedElastic())
|
|
|
|
|
- .doFinally(signalType -> {
|
|
|
|
|
- if (serviceClosed.compareAndSet(false, true)) {
|
|
|
|
|
- if (answer.length() > 0) {
|
|
|
|
|
- TenantUtils.executeIgnore(() ->
|
|
|
|
|
- chatMessageMapper.updateById(
|
|
|
|
|
- new AiChatMessageDO().setId(assistantMessage.getId())
|
|
|
|
|
- .setContent(answer.toString())));
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
- .onErrorResume(throwable -> {
|
|
|
|
|
- log.error("[sendChatMessageStream][userId({}) 会话({}) 调用 DeepSeek 附件问答失败]",
|
|
|
|
|
- userId, conversation.getId(), throwable);
|
|
|
|
|
- return Flux.just(error(ErrorCodeConstants.CHAT_STREAM_ERROR));
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * 纯文本请求复用通用聊天链路
|
|
|
|
|
- */
|
|
|
|
|
- private Flux<CommonResult<AiChatMessageSendRespVO>> sendTextChatMessageStream(
|
|
|
|
|
- WebQSAiChatMessageSendReqVO sendReqVO, Long userId) {
|
|
|
|
|
- if (StrUtil.isBlank(sendReqVO.getContent())) {
|
|
|
|
|
- throw new IllegalArgumentException("文本问答内容不能为空");
|
|
|
|
|
- }
|
|
|
|
|
- AiChatMessageSendReqVO textSendReqVO = new AiChatMessageSendReqVO();
|
|
|
|
|
- textSendReqVO.setConversationId(sendReqVO.getConversationId());
|
|
|
|
|
- textSendReqVO.setContent(sendReqVO.getContent());
|
|
|
|
|
- textSendReqVO.setUseContext(sendReqVO.getUseContext());
|
|
|
|
|
- textSendReqVO.setPlayAudio(sendReqVO.getPlayAudio());
|
|
|
|
|
- // WebQSAi 专用问答显式开启知识库引用;通用聊天接口仍使用原有两参数方法,行为不变。
|
|
|
|
|
- return chatMessageService.sendChatMessageStream(textSendReqVO, userId, true);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * 构建 DeepSeek API 请求体
|
|
|
|
|
- */
|
|
|
|
|
- private String buildDeepSeekRequest(AiChatConversationDO conversation, AiModelDO model, AiApiKeyDO apiKey,
|
|
|
|
|
- List<AiChatMessageDO> historyMessages,
|
|
|
|
|
- WebQSAiChatMessageSendReqVO sendReqVO,
|
|
|
|
|
- String userContent,
|
|
|
|
|
- List<MultipartFile> multipartFiles) throws Exception {
|
|
|
|
|
-
|
|
|
|
|
- ObjectNode root = objectMapper.createObjectNode();
|
|
|
|
|
- root.put("model", model.getModel());
|
|
|
|
|
- root.put("temperature", model.getTemperature() != null ? model.getTemperature() : 0.1);
|
|
|
|
|
- root.put("max_tokens", model.getMaxTokens() != null ? model.getMaxTokens() : 4000);
|
|
|
|
|
- root.put("top_p", 0.9);
|
|
|
|
|
- root.put("stream", true);
|
|
|
|
|
-
|
|
|
|
|
- ArrayNode messages = objectMapper.createArrayNode();
|
|
|
|
|
-
|
|
|
|
|
- // 1. 添加 System Prompt(评分规则)
|
|
|
|
|
- ObjectNode systemMsg = objectMapper.createObjectNode();
|
|
|
|
|
- systemMsg.put("role", "system");
|
|
|
|
|
- systemMsg.put("content", conversation.getDescription());
|
|
|
|
|
- messages.add(systemMsg);
|
|
|
|
|
-
|
|
|
|
|
- // 2. 添加历史上下文
|
|
|
|
|
- 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<AiChatMessageDO> contextList = new ArrayList<>();
|
|
|
|
|
- int contextChars = 0;
|
|
|
|
|
-
|
|
|
|
|
- for (int i = historyMessages.size() - 1; i >= startIndex && contextChars < MAX_CONTEXT_CHARS; i--) {
|
|
|
|
|
- AiChatMessageDO history = historyMessages.get(i);
|
|
|
|
|
- if (MessageType.USER.getValue().equals(history.getType())
|
|
|
|
|
- || MessageType.ASSISTANT.getValue().equals(history.getType())) {
|
|
|
|
|
- String content = StrUtil.nullToEmpty(history.getContent());
|
|
|
|
|
- int remainingChars = MAX_CONTEXT_CHARS - contextChars;
|
|
|
|
|
- if (content.length() > remainingChars) {
|
|
|
|
|
- content = content.substring(content.length() - remainingChars);
|
|
|
|
|
- }
|
|
|
|
|
- contextList.add(0, history);
|
|
|
|
|
- contextChars += content.length();
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- for (AiChatMessageDO history : contextList) {
|
|
|
|
|
- ObjectNode msg = objectMapper.createObjectNode();
|
|
|
|
|
- msg.put("role", history.getType());
|
|
|
|
|
- msg.put("content", history.getContent());
|
|
|
|
|
- messages.add(msg);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 3. 构建用户消息内容(包含附件文本)
|
|
|
|
|
- String fullUserContent = buildFullUserContent(sendReqVO, userContent, multipartFiles);
|
|
|
|
|
-
|
|
|
|
|
- ObjectNode userMsg = objectMapper.createObjectNode();
|
|
|
|
|
- userMsg.put("role", "user");
|
|
|
|
|
- userMsg.put("content", fullUserContent);
|
|
|
|
|
- messages.add(userMsg);
|
|
|
|
|
-
|
|
|
|
|
- root.set("messages", messages);
|
|
|
|
|
- return objectMapper.writeValueAsString(root);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * 构建完整的用户消息内容(包含附件解析的文本)
|
|
|
|
|
- */
|
|
|
|
|
- private String buildFullUserContent(WebQSAiChatMessageSendReqVO sendReqVO,
|
|
|
|
|
- String userContent,
|
|
|
|
|
- List<MultipartFile> multipartFiles) {
|
|
|
|
|
- StringBuilder extractedText = new StringBuilder();
|
|
|
|
|
-
|
|
|
|
|
- // 处理 URL 附件
|
|
|
|
|
- if (sendReqVO.getAttachments() != null) {
|
|
|
|
|
- for (WebQSAiChatMessageSendReqVO.Attachment attachment : sendReqVO.getAttachments()) {
|
|
|
|
|
- String url = attachment.getUrl();
|
|
|
|
|
- String name = attachment.getName();
|
|
|
|
|
- if (StrUtil.isNotBlank(url)) {
|
|
|
|
|
- try {
|
|
|
|
|
- String text = downloadAndExtractText(url, name);
|
|
|
|
|
- if (StrUtil.isNotBlank(text)) {
|
|
|
|
|
- extractedText.append("附件《").append(StrUtil.blankToDefault(name, "未知文件"))
|
|
|
|
|
- .append("》内容:\n").append(text).append("\n\n");
|
|
|
|
|
- }
|
|
|
|
|
- } catch (Exception e) {
|
|
|
|
|
- log.warn("下载或解析附件失败: {}, error: {}", name, e.getMessage());
|
|
|
|
|
- extractedText.append("附件《").append(StrUtil.blankToDefault(name, "未知文件"))
|
|
|
|
|
- .append("》解析失败,请检查文件格式。\n\n");
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 处理上传的 MultipartFile
|
|
|
|
|
- if (multipartFiles != null) {
|
|
|
|
|
- for (MultipartFile file : multipartFiles) {
|
|
|
|
|
- if (file == null || file.isEmpty()) {
|
|
|
|
|
- continue;
|
|
|
|
|
- }
|
|
|
|
|
- try {
|
|
|
|
|
- String fileName = file.getOriginalFilename();
|
|
|
|
|
- byte[] content = file.getBytes();
|
|
|
|
|
- String text = extractOfficeText(content, fileName);
|
|
|
|
|
- if (StrUtil.isNotBlank(text)) {
|
|
|
|
|
- extractedText.append("附件《").append(StrUtil.blankToDefault(fileName, "未知文件"))
|
|
|
|
|
- .append("》内容:\n").append(text).append("\n\n");
|
|
|
|
|
- }
|
|
|
|
|
- } catch (Exception e) {
|
|
|
|
|
- log.warn("解析上传文件失败: {}, error: {}", file.getOriginalFilename(), e.getMessage());
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 组合最终内容
|
|
|
|
|
- StringBuilder fullContent = new StringBuilder();
|
|
|
|
|
- if (extractedText.length() > 0) {
|
|
|
|
|
- fullContent.append("【附件内容】\n").append(extractedText);
|
|
|
|
|
- }
|
|
|
|
|
- if (StrUtil.isNotBlank(userContent)) {
|
|
|
|
|
- if (fullContent.length() > 0) {
|
|
|
|
|
- fullContent.append("\n【用户问题】\n");
|
|
|
|
|
- }
|
|
|
|
|
- fullContent.append(userContent);
|
|
|
|
|
- }
|
|
|
|
|
- return fullContent.toString();
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * 下载并提取附件文本
|
|
|
|
|
- */
|
|
|
|
|
- private String downloadAndExtractText(String fileUrl, String fileName) {
|
|
|
|
|
- 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()) {
|
|
|
|
|
- byte[] content = readWithSizeLimit(inputStream);
|
|
|
|
|
- return extractOfficeText(content, fileName);
|
|
|
|
|
- }
|
|
|
|
|
- } 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);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * 提取 Office 文档文本
|
|
|
|
|
- */
|
|
|
|
|
- private String extractOfficeText(byte[] content, String fileName) {
|
|
|
|
|
- if (content == null || content.length == 0) {
|
|
|
|
|
- return "";
|
|
|
|
|
- }
|
|
|
|
|
- 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 -> {
|
|
|
|
|
- if (extension.equals("txt") || extension.equals("csv")) {
|
|
|
|
|
- yield new String(content, java.nio.charset.StandardCharsets.UTF_8);
|
|
|
|
|
- }
|
|
|
|
|
- yield "";
|
|
|
|
|
- }
|
|
|
|
|
- };
|
|
|
|
|
- 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 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 String normalizeAttachmentUrl(String url) {
|
|
|
|
|
- try {
|
|
|
|
|
- URI uri = URI.create(url);
|
|
|
|
|
- if (!"http".equalsIgnoreCase(uri.getScheme()) && !"https".equalsIgnoreCase(uri.getScheme())) {
|
|
|
|
|
- throw new IllegalArgumentException("附件 URL 必须使用 HTTP 或 HTTPS 协议");
|
|
|
|
|
- }
|
|
|
|
|
- return uri.toASCIIString();
|
|
|
|
|
- } 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));
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|