|
|
@@ -13,33 +13,15 @@ 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.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.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 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;
|
|
|
@@ -68,6 +50,10 @@ 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;
|
|
|
@@ -80,14 +66,16 @@ 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 问答服务。
|
|
|
+ * Web 端支持附件的 DeepSeek AI 问答服务。
|
|
|
*/
|
|
|
@Service
|
|
|
@Validated
|
|
|
@Slf4j
|
|
|
public class WebQSAiServiceImpl {
|
|
|
|
|
|
- private static final String DEFAULT_ARK_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
|
|
|
+ // DeepSeek API URL 固定
|
|
|
+ private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions";
|
|
|
+
|
|
|
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;
|
|
|
@@ -107,13 +95,18 @@ public class WebQSAiServiceImpl {
|
|
|
@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 提交的本地文件。文件只会写入临时目录并上传至 Ark,不会保存到业务文件存储。
|
|
|
+ * 处理 multipart/form-data 提交的本地文件。
|
|
|
*/
|
|
|
public Flux<CommonResult<AiChatMessageSendRespVO>> sendChatMessageStream(
|
|
|
WebQSAiChatMessageSendReqVO sendReqVO, Long userId, List<MultipartFile> multipartFiles) {
|
|
|
@@ -135,9 +128,6 @@ public class WebQSAiServiceImpl {
|
|
|
|
|
|
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) {
|
|
|
@@ -145,17 +135,7 @@ public class WebQSAiServiceImpl {
|
|
|
}
|
|
|
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(),
|
|
|
@@ -166,56 +146,116 @@ public class WebQSAiServiceImpl {
|
|
|
|
|
|
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);
|
|
|
+ // 构建 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(120))
|
|
|
+ .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 -> {
|
|
|
- TenantUtils.executeIgnore(() -> chatMessageMapper.updateById(
|
|
|
- new AiChatMessageDO().setId(assistantMessage.getId()).setContent(answer.toString())));
|
|
|
if (serviceClosed.compareAndSet(false, true)) {
|
|
|
- arkService.shutdownExecutor();
|
|
|
+ if (answer.length() > 0) {
|
|
|
+ TenantUtils.executeIgnore(() ->
|
|
|
+ chatMessageMapper.updateById(
|
|
|
+ new AiChatMessageDO().setId(assistantMessage.getId())
|
|
|
+ .setContent(answer.toString())));
|
|
|
+ }
|
|
|
}
|
|
|
})
|
|
|
.onErrorResume(throwable -> {
|
|
|
- log.error("[sendChatMessageStream][userId({}) 会话({}) 调用豆包附件问答失败]", userId,
|
|
|
- conversation.getId(), throwable);
|
|
|
+ log.error("[sendChatMessageStream][userId({}) 会话({}) 调用 DeepSeek 附件问答失败]",
|
|
|
+ userId, conversation.getId(), throwable);
|
|
|
return Flux.just(error(ErrorCodeConstants.CHAT_STREAM_ERROR));
|
|
|
});
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 纯文本请求复用通用聊天链路,以获得其稳定的增量输出、连接复用和可选 TTS 能力。
|
|
|
+ * 纯文本请求复用通用聊天链路
|
|
|
*/
|
|
|
private Flux<CommonResult<AiChatMessageSendRespVO>> sendTextChatMessageStream(
|
|
|
WebQSAiChatMessageSendReqVO sendReqVO, Long userId) {
|
|
|
@@ -230,161 +270,215 @@ public class WebQSAiServiceImpl {
|
|
|
return chatMessageService.sendChatMessageStream(textSendReqVO, userId);
|
|
|
}
|
|
|
|
|
|
- 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();
|
|
|
+ /**
|
|
|
+ * 构建 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<ItemEasyMessage> contextMessages = new ArrayList<>();
|
|
|
+ List<AiChatMessageDO> contextList = 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());
|
|
|
+ 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);
|
|
|
}
|
|
|
- contextMessages.add(0, ItemEasyMessage.builder()
|
|
|
- .role(historyMessage.getType())
|
|
|
- .content(MessageContent.builder().stringValue(content).build())
|
|
|
- .build());
|
|
|
+ contextList.add(0, history);
|
|
|
contextChars += content.length();
|
|
|
}
|
|
|
}
|
|
|
- contextMessages.forEach(inputBuilder::addListItem);
|
|
|
+
|
|
|
+ for (AiChatMessageDO history : contextList) {
|
|
|
+ ObjectNode msg = objectMapper.createObjectNode();
|
|
|
+ msg.put("role", history.getType());
|
|
|
+ msg.put("content", history.getContent());
|
|
|
+ messages.add(msg);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- MessageContent.Builder contentBuilder = MessageContent.builder();
|
|
|
- AttachmentSizeCounter attachmentSizeCounter = new AttachmentSizeCounter();
|
|
|
+ // 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()) {
|
|
|
- contentBuilder.addListItem(buildAttachmentContent(attachment, attachmentSizeCounter));
|
|
|
+ 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");
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
- 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();
|
|
|
+ // 处理上传的 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());
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
- if (!isDocument(extension, attachment.getContentType())) {
|
|
|
- throw new IllegalArgumentException("暂只支持图片、PDF、Word 和 Excel 文件:" + fileName);
|
|
|
+
|
|
|
+ // 组合最终内容
|
|
|
+ StringBuilder fullContent = new StringBuilder();
|
|
|
+ if (extractedText.length() > 0) {
|
|
|
+ fullContent.append("【附件内容】\n").append(extractedText);
|
|
|
}
|
|
|
- if (isOfficeDocument(extension, attachment.getContentType())) {
|
|
|
- byte[] content = downloadAttachment(attachment.getUrl());
|
|
|
- attachmentSizeCounter.add(content.length);
|
|
|
- return buildOfficeTextContent(content, fileName);
|
|
|
+ if (StrUtil.isNotBlank(userContent)) {
|
|
|
+ if (fullContent.length() > 0) {
|
|
|
+ fullContent.append("\n【用户问题】\n");
|
|
|
+ }
|
|
|
+ fullContent.append(userContent);
|
|
|
}
|
|
|
- // PDF URL 必须能由 Ark 服务端访问(公网 URL 或未过期的签名 URL)。
|
|
|
- // file_url 不能同时携带 filename。
|
|
|
- InputContentItemFile file = new InputContentItemFile();
|
|
|
- file.setFileUrl(attachment.getUrl());
|
|
|
- return file;
|
|
|
+ return fullContent.toString();
|
|
|
}
|
|
|
|
|
|
- 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);
|
|
|
- }
|
|
|
+ /**
|
|
|
+ * 下载并提取附件文本
|
|
|
+ */
|
|
|
+ private String downloadAndExtractText(String fileUrl, String fileName) {
|
|
|
try {
|
|
|
- byte[] source = multipartFile.getBytes();
|
|
|
- if (isOfficeDocument(extension, multipartFile.getContentType())) {
|
|
|
- return buildOfficeTextContent(source, fileName);
|
|
|
+ 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);
|
|
|
}
|
|
|
- return buildFileDataContent(source, fileName);
|
|
|
} catch (IOException e) {
|
|
|
- throw new IllegalArgumentException("读取上传附件失败: " + fileName, e);
|
|
|
+ throw new IllegalArgumentException("下载 Office 附件失败", e);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private InputContentItemFile buildFileDataContent(byte[] content, String fileName) {
|
|
|
- InputContentItemFile inputFile = new InputContentItemFile();
|
|
|
- inputFile.setFileData(Base64.getEncoder().encodeToString(content));
|
|
|
- inputFile.setFileName(fileName);
|
|
|
- return inputFile;
|
|
|
+ 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 InputContentItemText buildOfficeTextContent(byte[] content, String fileName) {
|
|
|
- String text = extractOfficeText(content, fileName);
|
|
|
- return InputContentItemText.builder()
|
|
|
- .text("以下是附件《" + fileName + "》解析出的文本内容:\n" + text)
|
|
|
- .build();
|
|
|
+ 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 -> throw new IllegalArgumentException("不支持解析的 Office 文件类型:" + fileName);
|
|
|
+ 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);
|
|
|
@@ -470,75 +564,6 @@ public class WebQSAiServiceImpl {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- 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)
|
|
|
@@ -593,12 +618,13 @@ public class WebQSAiServiceImpl {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private void validateAttachmentUrl(String url) {
|
|
|
+ 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);
|
|
|
}
|
|
|
@@ -606,19 +632,26 @@ public class WebQSAiServiceImpl {
|
|
|
|
|
|
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);
|
|
|
+ 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) {
|
|
|
+ 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));
|
|
|
}
|
|
|
-}
|
|
|
+}
|