package com.wok.supportbot.app; import com.wok.supportbot.advisor.ContentSafetyAdvisor; import com.wok.supportbot.advisor.MyLoggerAdvisor; import com.wok.supportbot.chatmemory.DatabaseChatMemory; import com.wok.supportbot.config.ChatModelFactory; import com.wok.supportbot.config.SimpleCircuitBreaker; import com.wok.supportbot.mcp.McpToolCallback; import com.wok.supportbot.mcp.McpToolCallbackAdapter; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.document.Document; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.vectorstore.VectorStore; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import reactor.core.Disposable; import reactor.core.publisher.Flux; import reactor.core.publisher.FluxSink; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import static org.springframework.ai.chat.memory.ChatMemory.CONVERSATION_ID; /** * AI 对话执行层 —— ChatClient 构建、缓存管理与 LLM 调用执行。 *

* 本类负责 ChatClient 生命周期(按 appType + allowedMcpTools 缓存,LRU 淘汰), * Advisor 链装配(ContentSafetyAdvisor → MessageChatMemoryAdvisor → MyLoggerAdvisor), * 以及同步/流式对话执行(含熔断保护)。对话编排决策由 {@link ChatPipeline} 完成。 *

* 核心方法: *

*

* {@code @pipeline} execution-layer order=2
* {@code @pipeline-step} 熔断检查: SimpleCircuitBreaker(3次失败/5分钟恢复) → 熔断时返回降级提示
* {@code @pipeline-step} Advisor链: ContentSafetyAdvisor(HIGHEST) → MessageChatMemoryAdvisor → MyLoggerAdvisor
* {@code @pipeline-step} ChatClient.call/stream: LLM 大模型调用 → 成功/失败记录到熔断器
* 同步至: frontend/src/views/PipelineFlow.vue Advisor 子图 & 熔断节点 * * @author lyx * @version 1.0.0 * @date 2025/06/27 14:11 */ @Component @Slf4j public class AssistantApp { @Resource private ContentSafetyAdvisor contentSafetyAdvisor; @Resource private McpToolCallbackAdapter mcpToolCallbackAdapter; @Resource private ChatPipeline chatPipeline; /** MCP 工具开关,默认启用,可通过 application.yml 的 chat.mcp.enabled 关闭 */ @Value("${chat.mcp.enabled:true}") private boolean enableMcpTools; private final ChatModelFactory chatModelFactory; private final DatabaseChatMemory chatMemory; /** ChatClient 缓存上限,超出后按 LRU 自动淘汰最早最少使用的实例 */ private static final int CHAT_CLIENT_CACHE_MAX = 32; /** ChatClient 缓存(LinkedHashMap + removeEldestEntry 实现 LRU 淘汰) */ private final Map chatClientCache = Collections.synchronizedMap(new LinkedHashMap<>(16, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry eldest) { if (size() > CHAT_CLIENT_CACHE_MAX) { log.info("ChatClient 缓存淘汰: key={}", eldest.getKey()); return true; } return false; } }); private final SimpleCircuitBreaker aiCircuitBreaker = new SimpleCircuitBreaker(3, 5 * 60 * 1000L, "AI"); /** 全局 AI 调用的熔断 key(非模型配置级,整体熔断) */ private static final Long AI_CIRCUIT_KEY = -1L; /** AI 熔断时的降级提示语 */ private static final String CIRCUIT_OPEN_MESSAGE = "AI 服务暂时不可用,请稍后重试。"; private static final String SYSTEM_PROMPT = ""; /** 尾部空白缓冲上限:超过后强制发出,避免纯空白输出导致 buffer 无界增长 */ private static final int MAX_TRAILING_WHITESPACE_BUFFER = 256; /** * 初始化 ChatClient * * @param chatModelFactory */ public AssistantApp(ChatModelFactory chatModelFactory, DatabaseChatMemory chatMemory) { this.chatModelFactory = chatModelFactory; this.chatMemory = chatMemory; } private ChatClient getChatClient(String appType) { return getChatClient(appType, enableMcpTools ? List.of("*") : null); } /** * 获取 ChatClient(支持按角色过滤 MCP 工具),缓存上限 32 个,按 LRU 淘汰。 */ private ChatClient getChatClient(String appType, List allowedMcpTools) { // 仅 null(无角色)降级为 ["*"](向后兼容允许全部);空列表表示有角色但未授权,保持空(不注册任何工具) final List effectiveTools; if (enableMcpTools && allowedMcpTools == null) { effectiveTools = List.of("*"); } else { effectiveTools = allowedMcpTools; } String cacheKey = appType + ":" + (effectiveTools == null ? "none" : String.join(",", effectiveTools)); ChatClient cached = chatClientCache.get(cacheKey); if (cached != null) return cached; return chatClientCache.computeIfAbsent(cacheKey, key -> { ChatModel chatModel = chatModelFactory.getChatModel(appType); var builder = ChatClient.builder(chatModel); if (StringUtils.hasText(SYSTEM_PROMPT)) { builder.defaultSystem(SYSTEM_PROMPT); } // 注册 MCP 工具(按角色权限过滤) if (enableMcpTools && effectiveTools != null && !effectiveTools.isEmpty()) { ToolCallback[] mcpTools = mcpToolCallbackAdapter.getFilteredToolCallbacks(effectiveTools); if (mcpTools.length > 0) { builder.defaultToolCallbacks(mcpTools); log.info("已注册 {} 个 MCP 工具到 ChatClient [{}]", mcpTools.length, cacheKey); for (ToolCallback tc : mcpTools) { log.info(" 工具: {} — {}", tc.getToolDefinition().name(), tc.getToolDefinition().description()); } } else { log.warn("MCP 工具已启用但无可注册的工具 [{}],请检查:", cacheKey); log.warn(" 1. 是否在「MCP 服务管理」页面添加了 MCP Server 配置?"); log.warn(" 2. 配置是否已启用(is_active=true)?"); log.warn(" 3. MCP Server 是否连接成功(点击「测试连接」验证)?"); log.warn(" 4. MCP Server 是否暴露了工具(listTools 返回非空)?"); } } else { log.info("MCP 工具未启用 [enableMcpTools={}, effectiveTools={}]", enableMcpTools, effectiveTools); } return builder .defaultAdvisors( contentSafetyAdvisor, MessageChatMemoryAdvisor.builder(chatMemory).build(), new MyLoggerAdvisor() ) .build(); }); } public void clearCache() { chatClientCache.clear(); log.info("AssistantApp ChatClient cache cleared"); } // ==================== 统一入口(新) ==================== /** * 同步对话(新入口,委托 {@link ChatPipeline} 编排)。 * * @param ctx 对话上下文 * @return AI 回答文本 */ public String chat(ChatContext ctx) { return chatWithEvents(ctx).text(); } /** * 同步对话 + MCP 工具调用事件(新入口)。 * 比 {@link #chat(ChatContext)} 多返回本次触发的工具调用事件,供需要展示调用过程的场景。 * * @param ctx 对话上下文 * @return 回答文本 + MCP 事件 */ public ChatResult chatWithEvents(ChatContext ctx) { // 熔断:全局 AI 调用处于熔断状态,直接返回降级提示 if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) { log.warn("AI 调用熔断中,返回降级提示"); return new ChatResult(CIRCUIT_OPEN_MESSAGE, List.of()); } ChatRequest req = chatPipeline.buildRequest(ctx); if (req.faqHit()) { return new ChatResult(req.faqAnswer().get(), List.of()); } McpToolCallback.resetEvents(); McpToolCallback.resetCallRounds(); try { ChatClient.ChatClientRequestSpec spec = getChatClient(ctx.appType(), ctx.allowedMcpTools()) .prompt() .user(req.finalMessage()) .advisors(s -> s.param(CONVERSATION_ID, ctx.chatId())); if (StringUtils.hasText(req.finalSystemPrompt())) { spec = spec.system(req.finalSystemPrompt()); } String text = spec.call().chatResponse().getResult().getOutput().getText(); aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY); // 推荐问题已不再由主回复同步生成,改由 SuggestionGenerator 异步按需生成 return new ChatResult(text, McpToolCallback.drainEvents(), List.of()); } catch (Exception e) { aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY); log.error("AI 同步调用失败: chatId={}, error={}", ctx.chatId(), e.getMessage()); return new ChatResult("抱歉,AI 服务调用失败:" + e.getMessage(), List.of()); } } /** * 流式对话(新入口,委托 {@link ChatPipeline} 编排)。 *

* 注意:返回类型为 Flux<String>,Spring WebFlux 会对每个元素做 data: 帧包装。 * 因此 Flux 元素必须只是纯文本内容,不应包含 event: / data: 等 SSE 协议行。 * * @param ctx 对话上下文 * @return 纯文本流式回答(每个元素为一段自然语言文本) */ public Flux chatStream(ChatContext ctx) { // 熔断:全局 AI 调用处于熔断状态 if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) { log.warn("AI 调用熔断中(流式),返回降级提示"); return Flux.just(CIRCUIT_OPEN_MESSAGE); } ChatRequest req = chatPipeline.buildRequest(ctx); if (req.faqHit()) { // FAQ 命中:整段答案原样输出,由 SSE 编码器处理内部换行, // 后端不做任何格式增删(不拆行、不加换行、不补空格)。 return Flux.just(req.faqAnswer().get()); } McpToolCallback.resetEvents(); McpToolCallback.resetCallRounds(); ChatClient.ChatClientRequestSpec spec = getChatClient(ctx.appType(), ctx.allowedMcpTools()) .prompt() .user(req.finalMessage()) .advisors(s -> s.param(CONVERSATION_ID, ctx.chatId())); if (StringUtils.hasText(req.finalSystemPrompt())) { spec = spec.system(req.finalSystemPrompt()); } // 原始文本流;推荐问题已不再由主回复同步生成,改由 SuggestionGenerator 异步按需生成 Flux rawStream = spec.stream().content(); return preserveTrailingWhitespace(rawStream) .doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY)) .doOnError(e -> { aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY); log.error("AI 流式调用失败: chatId={}, error={}", ctx.chatId(), e.getMessage()); }) .doFinally(signalType -> { // 确保 ThreadLocal 清理,防止线程池复用时数据残留 McpToolCallback.resetEvents(); McpToolCallback.resetCallRounds(); }) .onErrorResume(e -> Flux.just("抱歉,AI 服务调用失败:" + e.getMessage())); } /** * 缓冲以空白字符结尾的 chunk,将其与下一个 chunk 合并后再发出。 *

* 原因:前端 SSE 解析会对每行执行 trim(),如果 chunk 以空白结尾(如 Markdown 标题 "## "), * 行尾空白会被削掉,导致 "## " + "一、..." 拼成 "##一、...",破坏 Markdown 渲染。 * 本方法不改变大模型输出的文本内容,只调整 chunk 边界以避开 SSE 的空白截断。 * * @param source 原始大模型输出流 * @return 调整后的流,每个元素均不以空白字符结尾(流末尾除外) */ private static Flux preserveTrailingWhitespace(Flux source) { return Flux.create(sink -> { StringBuilder buffer = new StringBuilder(); // 捕获内部订阅,下游取消/释放时同步取消上游,避免资源泄漏 Disposable subscription = source.subscribe( chunk -> { if (chunk == null) { return; } buffer.append(chunk); String current = buffer.toString(); if (current.isEmpty()) { return; } char last = current.charAt(current.length() - 1); // 当前累积内容不以空白结尾,可以安全发出 if (!Character.isWhitespace(last)) { sink.next(current); buffer.setLength(0); } else if (buffer.length() >= MAX_TRAILING_WHITESPACE_BUFFER) { // 尾部空白过长(如纯空白输出),强制发出以限制内存占用 sink.next(current); buffer.setLength(0); } // 若以空白结尾,继续缓存,等下一个 chunk }, e -> { // 出错前先把已缓冲内容发出,避免已生成文本丢失 if (buffer.length() > 0) { sink.next(buffer.toString()); buffer.setLength(0); } sink.error(e); }, () -> { if (buffer.length() > 0) { sink.next(buffer.toString()); } sink.complete(); } ); sink.onCancel(subscription); sink.onDispose(subscription); }, FluxSink.OverflowStrategy.BUFFER); } /** * 统一检索引用来源(新入口,委托 {@link ChatPipeline#retrieveSources})。 * * @param ctx 对话上下文(使用 message / rewriteStrategy / categoryIds) * @return 命中的知识库片段,含 metadata(documentId/title/sourceName/chunkIndex/distance) */ public List retrieveSources(ChatContext ctx) { return chatPipeline.retrieveSources(ctx); } // ==================== 内部工具方法已移除,不再需要 ==================== }