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 run(AiRequest aiRequest) { @@ -84,6 +94,9 @@ public class AgentRuntime { // 在请求线程捕获租户上下文快照,供异步编排使用 TenantContext tenantContext = captureTenantContext(); + // 文件解析必须在请求线程执行:Feign BearerTokenInterceptor 依赖 RequestContextHolder + prompt = resolveFileContents(prompt, aiRequest.getFileIds()); + // 后台启动编排流程 runOrchestration(sessionId, prompt, eventBus, tenantContext); @@ -91,6 +104,31 @@ public class AgentRuntime { return eventBus.asFlux(); } + /** + * 加载文件内容并拼接到提示词前 + *

+ * 若 fileIds 为空或全部加载失败,返回原始 prompt。 + * 文件内容加载失败时记录日志但不阻断流程,降级为纯文本对话。 + *

+ * + * @param prompt 原始用户提示词 + * @param fileIds 文件fileKey列表 + * @return 拼接后的提示词 + */ + private String resolveFileContents(String prompt, List fileIds) { + if (fileIds == null || fileIds.isEmpty()) { + return prompt; + } + String fileContents = fileParseService.loadFileContents(fileIds); + if (StringUtils.isEmpty(fileContents)) { + log.warn("[AgentRuntime] 文件内容加载为空 fileIds={}", fileIds); + return prompt; + } + return "以下是用户上传的文件内容,请基于文件内容进行分析:\n\n" + + fileContents + + "\n用户需求:" + prompt; + } + /** * 在请求线程捕获租户上下文快照 *

diff --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 chat(AiRequest aiRequest) { - return chat(aiRequest.getSessionId(), aiRequest.getMessage().getContent()) + String prompt = aiRequest.getMessage().getContent(); + List fileIds = aiRequest.getFileIds(); + if (fileIds != null && !fileIds.isEmpty()) { + String fileContents = fileParseService.loadFileContents(fileIds); + if (StringUtils.isNotEmpty(fileContents)) { + prompt = "以下是用户上传的文件内容,请基于文件内容进行分析:\n\n" + + fileContents + + "\n用户问题:" + prompt; + } + } + final String finalPrompt = prompt; + return chat(aiRequest.getSessionId(), finalPrompt) .filter(resp -> "STOP".equals(Objects.requireNonNull(resp.getResult()).getMetadata().getFinishReason()) || StringUtils.isNotEmpty(resp.getResult().getOutput().getText())) .map(resp -> new ChatResponseVo().setId(aiRequest.getId()) diff --git a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/FileParseService.java b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/FileParseService.java new file mode 100644 index 00000000..bb0d7fec --- /dev/null +++ b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/FileParseService.java @@ -0,0 +1,313 @@ +package cn.com.mfish.ai.service; + +import cn.com.mfish.common.core.constants.RPCConstants; +import cn.com.mfish.common.core.utils.StringUtils; +import cn.com.mfish.common.storage.api.entity.StorageInfo; +import cn.com.mfish.common.storage.api.remote.RemoteStorageService; +import cn.com.mfish.common.core.web.Result; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.extractor.ExtractorFactory; +import org.apache.poi.extractor.POITextExtractor; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * 文件解析服务 + *

+ * 根据文件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 TEXT_CONTENT_TYPE_PREFIXES = Set.of("text/"); + /** + * 文本类contentType精确匹配集合 + */ + private static final Set TEXT_CONTENT_TYPES = Set.of( + "application/json", + "application/xml", + "application/javascript", + "application/x-yaml", + "application/yaml", + "application/x-sh", + "application/sql", + "application/csv", + "application/x-java", + "application/x-tex", + "application/manifest+json", + "application/graphql-response+json" + ); + /** + * 文本类文件后缀集合(按扩展名兜底识别) + */ + private static final Set TEXT_SUFFIXES = Set.of( + ".txt", ".log", ".md", ".markdown", ".json", ".xml", ".yaml", ".yml", + ".csv", ".sql", ".js", ".ts", ".java", ".py", ".go", ".rs", ".c", + ".cpp", ".h", ".hpp", ".cs", ".rb", ".php", ".swift", ".kt", ".scala", + ".sh", ".bat", ".ps1", ".properties", ".conf", ".ini", ".toml", + ".html", ".htm", ".css", ".scss", ".less", ".vue", ".tsx", ".jsx", + ".gradle", ".gitignore", ".dockerfile", ".env", ".proto" + ); + /** + * Office文档后缀集合(仅OOXML新格式,通过Apache POI解析) + *

+ * 旧版OLE2格式(.doc/.xls/.ppt)需要 poi-scratchpad 依赖,当前未引入, + * 前端应限制只允许上传新格式。 + */ + private static final Set OFFICE_SUFFIXES = Set.of( + ".docx", ".xlsx", ".pptx", ".docm", ".dotm", ".xlsm", ".xltm", ".pptm", ".potm" + ); + /** + * Office文档contentType精确匹配集合(仅OOXML新格式) + */ + private static final Set OFFICE_CONTENT_TYPES = Set.of( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.spreadsheetml.template", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.presentationml.template" + ); + + private final RemoteStorageService remoteStorageService; + + public FileParseService(RemoteStorageService remoteStorageService) { + this.remoteStorageService = remoteStorageService; + } + + /** + * 加载文件内容并拼接为提示词片段 + *

+ * 遍历fileKey列表,逐个获取文件元数据与内容,将文本/Office文档内容拼接到StringBuilder。 + * 单个文件读取失败不影响其他文件,异常会被记录日志并跳过。 + * + * @param fileIds 文件fileKey列表 + * @return 拼接好的文件内容提示词片段;列表为空或全部失败时返回空字符串 + */ + public String loadFileContents(List fileIds) { + if (fileIds == null || fileIds.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + int successCount = 0; + for (String fileKey : fileIds) { + if (StringUtils.isEmpty(fileKey)) { + continue; + } + try { + String segment = loadOneFile(fileKey); + if (segment != null && !segment.isEmpty()) { + sb.append(segment).append("\n\n"); + successCount++; + } + } catch (Exception e) { + log.warn("加载文件内容失败 fileKey={} reason={}", fileKey, e.getMessage()); + } + } + if (successCount == 0) { + return ""; + } + return sb.toString(); + } + + /** + * 加载单个文件并构建内容片段 + *

+ * 按文件类型分发到不同解析路径: + *

    + *
  1. 文本类:直接UTF-8读取
  2. + *
  3. Office文档:通过POI提取文本
  4. + *
  5. 其他二进制:返回提示信息
  6. + *
+ * + * @param fileKey 文件key + * @return 文件内容片段;文件不存在返回null + */ + private String loadOneFile(String fileKey) { + Result infoResult = remoteStorageService.queryByKey(RPCConstants.INNER, fileKey); + if (infoResult == null || !infoResult.isSuccess() || infoResult.getData() == null) { + log.warn("文件信息查询失败 fileKey={} msg={}", fileKey, + infoResult == null ? "result is null" : infoResult.getMsg()); + return null; + } + StorageInfo storageInfo = infoResult.getData(); + String fileName = StringUtils.isEmpty(storageInfo.getFileName()) ? fileKey : storageInfo.getFileName(); + String fileType = storageInfo.getFileType(); + + if (isTextFile(fileType, fileName)) { + String content = readFileText(fileKey); + return content == null ? buildReadFailureHint(fileName) : buildTextFileSegment(fileName, truncate(content)); + } + if (isOfficeDocument(fileType, fileName)) { + String content = extractOfficeText(fileKey, fileName); + return content == null ? buildReadFailureHint(fileName) : buildTextFileSegment(fileName, truncate(content)); + } + return buildBinaryFileHint(fileName, fileType, storageInfo.getFileSize()); + } + + /** + * 通过Feign获取文件资源并读取为UTF-8文本 + * + * @param fileKey 文件key + * @return 文件文本内容;读取失败返回null + */ + private String readFileText(String fileKey) { + ResponseEntity resp = remoteStorageService.fetch(RPCConstants.INNER, fileKey); + if (resp == null || !resp.getStatusCode().is2xxSuccessful() || resp.getBody() == null) { + log.warn("文件资源获取失败 fileKey={} status={}", fileKey, + resp == null ? "null" : resp.getStatusCode()); + return null; + } + try (InputStream is = resp.getBody().getInputStream()) { + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } catch (Exception e) { + log.warn("文件内容读取失败 fileKey={} reason={}", fileKey, e.getMessage()); + return null; + } + } + + /** + * 通过Apache POI提取Office文档文本 + *

+ * 使用 {@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 resp = remoteStorageService.fetch(RPCConstants.INNER, fileKey); + if (resp == null || !resp.getStatusCode().is2xxSuccessful() || resp.getBody() == null) { + log.warn("Office文件资源获取失败 fileKey={} status={}", fileKey, + resp == null ? "null" : resp.getStatusCode()); + return null; + } + // POI的ExtractorFactory.createExtractor需要File或mark/reset支持的InputStream + // ByteArrayInputStream支持mark/reset,避免POI内部reset失败 + try (InputStream is = resp.getBody().getInputStream()) { + byte[] bytes = is.readAllBytes(); + try (InputStream poiStream = new java.io.ByteArrayInputStream(bytes); + POITextExtractor extractor = ExtractorFactory.createExtractor(poiStream)) { + String text = extractor.getText(); + log.info("Office文档解析成功 fileName={} chars={}", fileName, + text == null ? 0 : text.length()); + return text; + } + } catch (Exception e) { + log.warn("Office文档解析失败 fileKey={} fileName={} reason={}", fileKey, fileName, e.getMessage()); + return null; + } + } + + /** + * 截断过长的内容,避免撑爆LLM上下文 + */ + private String truncate(String content) { + if (content == null) { + return ""; + } + if (content.length() > MAX_CONTENT_CHARS) { + return content.substring(0, MAX_CONTENT_CHARS) + + "\n...[文件内容过长,已截断,仅显示前" + MAX_CONTENT_CHARS + "字符]"; + } + return content; + } + + /** + * 判断文件是否为文本类文件 + *

+ * 优先按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 fileIds; }