Parcourir la source

demo2的开发,支持文件附件请求

liyanbo il y a 1 mois
Parent
commit
e7c19aaa0d

+ 12 - 1
byzs-web/pom.xml

@@ -38,6 +38,17 @@
             <artifactId>byzs-module-ai</artifactId>
             <version>${revision}</version>
         </dependency>
+        <!-- 解析 DOC/DOCX/XLS/XLSX 附件内容,作为文本发送至 AI 模型 -->
+        <dependency>
+            <groupId>org.apache.poi</groupId>
+            <artifactId>poi-ooxml</artifactId>
+            <version>5.2.5</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.poi</groupId>
+            <artifactId>poi-scratchpad</artifactId>
+            <version>5.2.5</version>
+        </dependency>
     </dependencies>
 
-</project>
+</project>

+ 66 - 0
byzs-web/src/main/java/cn/iocoder/byzs/module/web/controller/admin/ai/WebQSAiController.java

@@ -0,0 +1,66 @@
+package cn.iocoder.byzs.module.web.controller.admin.ai;
+
+import cn.iocoder.byzs.framework.common.pojo.CommonResult;
+import cn.iocoder.byzs.module.ai.controller.admin.chat.vo.conversation.AiChatConversationCreateMyReqVO;
+import cn.iocoder.byzs.module.ai.controller.admin.chat.vo.message.AiChatMessageSendRespVO;
+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.tts.AiTtsService;
+import cn.iocoder.byzs.module.web.controller.admin.ai.vo.WebQSAiChatMessageSendReqVO;
+import cn.iocoder.byzs.module.web.service.ai.WebQSAiServiceImpl;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import org.springframework.http.MediaType;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import reactor.core.publisher.Flux;
+
+import java.util.List;
+
+import static cn.iocoder.byzs.framework.common.pojo.CommonResult.success;
+import static cn.iocoder.byzs.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
+
+@Tag(name = "Web-前端接口")
+@RestController
+@RequestMapping("/bjdxWeb/qsAi")
+@Validated
+public class WebQSAiController {
+
+    @Resource
+    private AiChatConversationService chatConversationService;
+    @Resource
+    private AiChatMessageService chatMessageService;
+    @Resource
+    private WebQSAiServiceImpl webQSAiService;
+    @Resource
+    private AiTtsService ttsService;
+
+
+    // ================ 智能问答 ================
+
+    @Operation(summary = "创建智能问答")
+    @PostMapping("/create-dialogue")
+    public CommonResult<Long> createChatConversationMy(@RequestBody @Valid AiChatConversationCreateMyReqVO createReqVO) {
+        return success(chatConversationService.createChatConversationMy(createReqVO, getLoginUserId()));
+    }
+
+    @Operation(summary = "智能问答-发送消息(流式)", description = "流式返回,响应较快")
+    @PostMapping(value = "/dialogue-send-stream", consumes = MediaType.APPLICATION_JSON_VALUE,
+            produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    public Flux<CommonResult<AiChatMessageSendRespVO>> sendChatMessageStream(@Valid @RequestBody WebQSAiChatMessageSendReqVO sendReqVO) {
+        return webQSAiService.sendChatMessageStream(sendReqVO, getLoginUserId());
+    }
+
+    @Operation(summary = "智能问答-发送消息(流式,携带本地文件)",
+            description = "使用 multipart/form-data 提交:request 为 JSON 请求体,files 为待分析的本地文件,可同时上传最多 5 个文件")
+    @PostMapping(value = "/dialogue-send-stream", consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
+            produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    public Flux<CommonResult<AiChatMessageSendRespVO>> sendChatMessageStream(
+            @Valid @RequestPart("request") WebQSAiChatMessageSendReqVO sendReqVO,
+            @RequestPart(value = "files", required = false) List<MultipartFile> files) {
+        return webQSAiService.sendChatMessageStream(sendReqVO, getLoginUserId(), files);
+    }
+}

+ 45 - 0
byzs-web/src/main/java/cn/iocoder/byzs/module/web/controller/admin/ai/vo/WebQSAiChatMessageSendReqVO.java

@@ -0,0 +1,45 @@
+package cn.iocoder.byzs.module.web.controller.admin.ai.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.util.List;
+
+@Schema(description = "Web - 支持附件的 AI 问答请求")
+@Data
+public class WebQSAiChatMessageSendReqVO {
+
+    @Schema(description = "聊天对话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+    @NotNull(message = "聊天对话编号不能为空")
+    private Long conversationId;
+
+    @Schema(description = "问题内容;仅上传附件时可不传", example = "请总结附件内容")
+    private String content;
+
+    @Schema(description = "是否携带历史上下文", example = "true")
+    private Boolean useContext;
+
+    @Schema(description = "已上传的附件列表,最多 5 个")
+    @Valid
+    @Size(max = 5, message = "单次最多上传 5 个附件")
+    private List<Attachment> attachments;
+
+    @Schema(description = "附件")
+    @Data
+    public static class Attachment {
+
+        @Schema(description = "文件可访问 URL;须为豆包服务可访问的公开 URL 或有效签名 URL", requiredMode = Schema.RequiredMode.REQUIRED)
+        @NotBlank(message = "附件 URL 不能为空")
+        private String url;
+
+        @Schema(description = "原始文件名;未传时从 URL 推断", example = "实验报告.pdf")
+        private String name;
+
+        @Schema(description = "文件 MIME 类型", example = "application/pdf")
+        private String contentType;
+    }
+}

+ 599 - 0
byzs-web/src/main/java/cn/iocoder/byzs/module/web/service/ai/WebQSAiServiceImpl.java

@@ -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));
+    }
+}