diff --git a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/AgentRuntime.java b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/AgentRuntime.java index 34c520c8..e2c4dc3a 100644 --- a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/AgentRuntime.java +++ b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/AgentRuntime.java @@ -1,5 +1,6 @@ package cn.com.mfish.ai.agent; +import cn.com.mfish.ai.service.FileParseService; import cn.com.mfish.ai.service.LlmModelRouter; import cn.com.mfish.common.ai.agent.EventBus; import cn.com.mfish.common.ai.agent.TenantContext; @@ -10,6 +11,7 @@ import cn.com.mfish.common.ai.entity.EventType; import cn.com.mfish.common.ai.entity.PlanStep; import cn.com.mfish.common.core.utils.AuthInfoUtils; import cn.com.mfish.common.core.utils.ServletUtils; +import cn.com.mfish.common.core.utils.StringUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestAttributes; @@ -48,11 +50,14 @@ public class AgentRuntime { private final Planner planner; private final Executor executor; private final LlmModelRouter llmModelRouter; + private final FileParseService fileParseService; - public AgentRuntime(Planner planner, Executor executor, LlmModelRouter llmModelRouter) { + public AgentRuntime(Planner planner, Executor executor, LlmModelRouter llmModelRouter, + FileParseService fileParseService) { this.planner = planner; this.executor = executor; this.llmModelRouter = llmModelRouter; + this.fileParseService = fileParseService; } /** @@ -67,11 +72,16 @@ public class AgentRuntime { * 需要用快照构建 ToolContext 和 ChatClient。 *
*+ * 文件解析:若请求携带 fileIds,在请求线程同步加载文件内容(Feign 调用 mf-storage), + * 拼接到用户提示词前。必须在请求线程执行,因为 Feign 的 BearerTokenInterceptor 依赖 + * RequestContextHolder 中继令牌,异步线程拿不到。 + *
+ ** 所有返回的 {@link ChatResponseVo} 的 id 字段填充为 {@link AiRequest#getId()}, * 与普通聊天返回结构保持一致,前端可按 id 关联请求与响应。 *
* - * @param aiRequest AI请求参数(含 id/sessionId/message) + * @param aiRequest AI请求参数(含 id/sessionId/message/fileIds) * @return 聊天响应流(与普通聊天统一结构) */ public Flux+ * 若 fileIds 为空或全部加载失败,返回原始 prompt。 + * 文件内容加载失败时记录日志但不阻断流程,降级为纯文本对话。 + *
+ * + * @param prompt 原始用户提示词 + * @param fileIds 文件fileKey列表 + * @return 拼接后的提示词 + */ + private String resolveFileContents(String prompt, Listdiff --git a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/BaseAssistant.java b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/BaseAssistant.java index 0fae7126..198a1001 100644 --- a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/BaseAssistant.java +++ b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/BaseAssistant.java @@ -1,5 +1,6 @@ package cn.com.mfish.ai.agent; +import cn.com.mfish.ai.service.FileParseService; import cn.com.mfish.ai.service.LlmModelRouter; import cn.com.mfish.common.ai.client.IClientAssistant; import cn.com.mfish.common.ai.engine.ApiToolEngine; @@ -17,6 +18,7 @@ import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.server.ServerWebExchange; @@ -26,6 +28,7 @@ import reactor.core.scheduler.Schedulers; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -51,6 +54,13 @@ public abstract class BaseAssistant implements IClientAssistant, ToolCapable { private final LlmModelRouter llmModelRouter; protected final ApiToolEngine apiToolEngine; + /** + * 文件解析服务:用于根据AiRequest.fileIds获取文件内容并注入提示词 + * 采用字段注入避免修改所有子类构造函数 + */ + @Autowired + protected FileParseService fileParseService; + public BaseAssistant(ChatMemory chatMemory, LlmModelRouter llmModelRouter, ApiToolEngine apiToolEngine) { this.llmModelRouter = llmModelRouter; this.chatMemory = chatMemory; @@ -268,12 +278,26 @@ public abstract class BaseAssistant implements IClientAssistant, ToolCapable { /** * 聊天返回id + *
+ * 若请求携带fileIds,会先通过FileParseService从文件服务获取文件内容,
+ * 将其拼接到用户提示词前,再交由子类chat(sessionId, prompt)处理。
*
* @return 聊天信息
*/
@Override
public Flux
+ * 根据文件fileKey列表,通过远程存储服务获取文件元数据与内容,
+ * 将文件内容提取为LLM可理解的提示词片段。
+ *
+ * 支持的文件类型:
+ *
+ * 注意:旧版OLE2格式(.doc/.xls/.ppt)需要 poi-scratchpad 依赖,当前未引入,
+ * 前端应限制只允许上传 .docx/.xlsx/.pptx 格式。
+ *
+ * 调用链:FileParseService → RemoteStorageService(Feign) → mf-storage 服务
+ * BearerTokenInterceptor 自动中继用户令牌,保证私有文件可访问。
+ *
+ * @author: mfish
+ * @date: 2026/07/20
+ */
+@Slf4j
+@Service
+public class FileParseService {
+ /**
+ * 单个文件读取的最大字符数,超过则截断,避免撑爆LLM上下文
+ */
+ private static final int MAX_CONTENT_CHARS = 51200;
+ /**
+ * 文本类contentType前缀集合,命中时按文本读取
+ */
+ private static final Set
+ * 旧版OLE2格式(.doc/.xls/.ppt)需要 poi-scratchpad 依赖,当前未引入,
+ * 前端应限制只允许上传新格式。
+ */
+ private static final Set
+ * 遍历fileKey列表,逐个获取文件元数据与内容,将文本/Office文档内容拼接到StringBuilder。
+ * 单个文件读取失败不影响其他文件,异常会被记录日志并跳过。
+ *
+ * @param fileIds 文件fileKey列表
+ * @return 拼接好的文件内容提示词片段;列表为空或全部失败时返回空字符串
+ */
+ public String loadFileContents(List
+ * 按文件类型分发到不同解析路径:
+ *
+ * 使用 {@link ExtractorFactory} 自动识别文档类型(Word/Excel/PowerPoint,旧版/新版格式),
+ * 无需根据后缀手动分发,POI内部根据文件魔数选择合适的Extractor。
+ *
+ * 注意:POI要求InputStream支持mark/reset,Feign返回的InputStream需先缓存为字节数组再包装。
+ *
+ * @param fileKey 文件key
+ * @param fileName 文件名(仅用于日志)
+ * @return 提取的文本内容;提取失败返回null
+ */
+ private String extractOfficeText(String fileKey, String fileName) {
+ ResponseEntity
+ * 优先按contentType判断,再按文件名后缀兜底
+ *
+ * @param contentType 文件MIME类型
+ * @param fileName 文件名
+ * @return true表示可按文本读取
+ */
+ private boolean isTextFile(String contentType, String fileName) {
+ if (StringUtils.isNotEmpty(contentType)) {
+ String lower = contentType.toLowerCase(Locale.ROOT);
+ if (TEXT_CONTENT_TYPE_PREFIXES.stream().anyMatch(lower::startsWith)) {
+ return true;
+ }
+ if (TEXT_CONTENT_TYPES.contains(lower)) {
+ return true;
+ }
+ }
+ if (StringUtils.isNotEmpty(fileName)) {
+ String lowerName = fileName.toLowerCase(Locale.ROOT);
+ return TEXT_SUFFIXES.stream().anyMatch(lowerName::endsWith);
+ }
+ return false;
+ }
+
+ /**
+ * 判断文件是否为Office文档
+ *
+ * 优先按contentType判断,再按文件名后缀兜底
+ *
+ * @param contentType 文件MIME类型
+ * @param fileName 文件名
+ * @return true表示为Office文档(Word/Excel/PowerPoint)
+ */
+ private boolean isOfficeDocument(String contentType, String fileName) {
+ if (StringUtils.isNotEmpty(contentType)) {
+ String lower = contentType.toLowerCase(Locale.ROOT);
+ if (OFFICE_CONTENT_TYPES.contains(lower)) {
+ return true;
+ }
+ }
+ if (StringUtils.isNotEmpty(fileName)) {
+ String lowerName = fileName.toLowerCase(Locale.ROOT);
+ return OFFICE_SUFFIXES.stream().anyMatch(lowerName::endsWith);
+ }
+ return false;
+ }
+
+ private String buildTextFileSegment(String fileName, String content) {
+ return "=== 文件: " + fileName + " ===\n" + content + "\n=== 文件结束: " + fileName + " ===";
+ }
+
+ private String buildBinaryFileHint(String fileName, String fileType, Integer fileSize) {
+ return "=== 文件: " + fileName + " ===\n" +
+ "[二进制文件,无法直接作为文本解析] 类型: " + (StringUtils.isEmpty(fileType) ? "未知" : fileType) +
+ " 大小: " + (fileSize == null ? "未知" : fileSize + " bytes") +
+ "\n=== 文件结束: " + fileName + " ===";
+ }
+
+ private String buildReadFailureHint(String fileName) {
+ return "=== 文件: " + fileName + " ===\n[文件内容读取失败]\n=== 文件结束: " + fileName + " ===";
+ }
+}
diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/entity/AiRequest.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/entity/AiRequest.java
index c0f97602..9bc25a65 100644
--- a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/entity/AiRequest.java
+++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/entity/AiRequest.java
@@ -3,6 +3,8 @@ package cn.com.mfish.common.ai.entity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
+import java.util.List;
+
/**
* @description: Ai请求
* @author: mfish
@@ -17,4 +19,6 @@ public class AiRequest {
private String sessionId;
@Schema(description = "消息")
private AiMessage message;
+ @Schema(description = "文件ID列表(文件fileKey集合,AI将获取文件内容并解析)")
+ private List
+ *
+ *
+ *
+ *
+ * @param fileKey 文件key
+ * @return 文件内容片段;文件不存在返回null
+ */
+ private String loadOneFile(String fileKey) {
+ Result