package vip.mate.channel; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; import vip.mate.agent.AgentService; import vip.mate.agent.context.ChatOrigin; import vip.mate.approval.ApprovalWorkflowService; import vip.mate.approval.ResolveOutcome; import vip.mate.approval.PendingApproval; import vip.mate.channel.model.ChannelEntity; import vip.mate.channel.notification.ApprovalNotificationService; import vip.mate.channel.service.ChannelService; import vip.mate.channel.web.ChatStreamTracker; import vip.mate.memory.event.ConversationCompletionPublisher; import vip.mate.tts.TtsService; import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.model.MessageContentPart; import vip.mate.workspace.conversation.model.MessageEntity; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.*; import java.util.concurrent.locks.ReentrantLock; /** * 渠道消息路由器 *

* 采用每渠道独立队列架构: * - 每渠道一个 BlockingQueue,N 个消费线程从队列取消息处理 * - 会话级锁保证同一 conversationId 串行处理 * - 500ms 防抖:同一会话的连续消息合并为一条 * - Web 渠道不走队列(有自己的 SSE 流程) * * @author MateClaw Team */ @Slf4j @Component public class ChannelMessageRouter { private final AgentService agentService; private final ConversationService conversationService; private final ChannelService channelService; private final ChannelSessionStore channelSessionStore; private final ApprovalWorkflowService approvalService; private final ApprovalNotificationService approvalNotificationService; private final ConversationCompletionPublisher completionPublisher; private final TtsService ttsService; private final ObjectMapper objectMapper; private final ChatStreamTracker streamTracker; private final ChannelChatOriginFactory chatOriginFactory; private final ChannelErrorClassifier errorClassifier; /** 队列条目:封装消息及其路由上下文 */ private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {} /** 每个渠道类型的消息队列 */ private final ConcurrentHashMap> channelQueues = new ConcurrentHashMap<>(); /** 每个渠道类型的消费线程池 */ private final ConcurrentHashMap channelExecutors = new ConcurrentHashMap<>(); /** 会话级别的锁:保证同一 conversationId 串行处理 */ private final ConcurrentHashMap sessionLocks = new ConcurrentHashMap<>(); /** 防抖调度器 */ private final ScheduledExecutorService debounceScheduler = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "channel-debounce-scheduler"); t.setDaemon(true); return t; }); /** 防抖缓冲区:conversationId -> 待合并消息 */ private final ConcurrentHashMap pendingMessages = new ConcurrentHashMap<>(); /** 每个渠道的消费线程数 */ private static final int CONSUMERS_PER_CHANNEL = 4; /** 每个渠道的队列容量 */ private static final int QUEUE_CAPACITY = 1000; /** 防抖等待时间(毫秒) */ private static final long DEBOUNCE_MS = 500; /** * Plan-Execute SSE events that the Web Console mirror needs to see when * a conversation runs through an IM channel. *

* The agent emits these via {@code GraphEventPublisher} and they ride on * the {@code chatStructuredStream} Flux as {@code StreamDelta.event(...)}. * Web direct chats already broadcast them via the ChatController * accumulator. IM channels (DingTalk + the seven sync-path adapters) * historically dropped them — DingTalk's {@code processStreamAsText} * only consumes {@code delta.content()}, and the sync {@code chat()} * collector explicitly filters {@code delta.isEvent()} out. The whitelist * is applied in the IM stream path so PlanStepsPanel renders correctly * when an operator monitors an IM conversation in the Web Console. *

* Whitelist (not pass-through) so Web-side accumulator-internal events * like {@code _usage_final} or future agent-internal markers don't leak * to subscribers. */ private static final Set MIRRORED_PLAN_EVENTS = Set.of( "plan_created", "plan_step_started", "plan_step_completed" ); /** 是否已关闭 */ private volatile boolean shutdown = false; public ChannelMessageRouter(AgentService agentService, ConversationService conversationService, ChannelService channelService, ChannelSessionStore channelSessionStore, ApprovalWorkflowService approvalService, ApprovalNotificationService approvalNotificationService, ConversationCompletionPublisher completionPublisher, TtsService ttsService, ObjectMapper objectMapper, ChatStreamTracker streamTracker, ChannelChatOriginFactory chatOriginFactory, ChannelErrorClassifier errorClassifier) { this.agentService = agentService; this.conversationService = conversationService; this.channelService = channelService; this.channelSessionStore = channelSessionStore; this.approvalService = approvalService; this.approvalNotificationService = approvalNotificationService; this.completionPublisher = completionPublisher; this.ttsService = ttsService; this.objectMapper = objectMapper; this.streamTracker = streamTracker; this.chatOriginFactory = chatOriginFactory; this.errorClassifier = errorClassifier; } // ==================== 防抖辅助类 ==================== /** * 防抖待合并消息 */ private static class PendingMessage { final ChannelAdapter adapter; final ChannelEntity channelEntity; final ChannelMessage firstMessage; final StringBuilder mergedContent; volatile ScheduledFuture timer; PendingMessage(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) { this.firstMessage = message; this.adapter = adapter; this.channelEntity = channelEntity; this.mergedContent = new StringBuilder(message.getContent() != null ? message.getContent() : ""); } synchronized void appendContent(String content) { if (content != null && !content.isBlank()) { if (!mergedContent.isEmpty()) { mergedContent.append('\n'); } mergedContent.append(content); } } synchronized String getMergedContent() { return mergedContent.toString(); } } // ==================== 入队(替代原 route 方法) ==================== /** * 将渠道消息入队到对应渠道的处理队列(防抖后入队)。 *

* Webhook 调用此方法后立即返回,不阻塞。 * * @param message 入站消息 * @param adapter 来源渠道适配器(用于回复) * @param channelEntity 渠道配置(含关联 agentId) */ public void enqueue(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) { Long agentId = channelEntity.getAgentId(); if (agentId == null) { log.warn("Channel {} has no associated agent, ignoring message from {}", channelEntity.getName(), message.getSenderId()); return; } if (shutdown) { log.warn("Router is shutting down, rejecting message from {}", message.getSenderId()); return; } String channelType = adapter.getChannelType(); String conversationId = buildConversationId(message); log.info("[{}] Enqueuing message: sender={}, conversationId={}, agentId={}", channelType, message.getSenderId(), conversationId, agentId); // 防抖:同一会话 500ms 内的连续消息合并 synchronized (pendingMessages) { PendingMessage existing = pendingMessages.get(conversationId); if (existing != null) { // 合并到已有的 pending 消息 if (existing.timer != null) { existing.timer.cancel(false); } existing.appendContent(message.getContent()); existing.timer = debounceScheduler.schedule( () -> flushPending(conversationId), DEBOUNCE_MS, TimeUnit.MILLISECONDS); log.debug("[{}] Message merged with pending (debounce): conversationId={}", channelType, conversationId); return; } // 首条消息,创建 PendingMessage 并设定防抖定时器 PendingMessage pending = new PendingMessage(message, adapter, channelEntity); pendingMessages.put(conversationId, pending); pending.timer = debounceScheduler.schedule( () -> flushPending(conversationId), DEBOUNCE_MS, TimeUnit.MILLISECONDS); } } /** * 防抖到期:将合并后的消息真正放入渠道队列 */ private void flushPending(String conversationId) { PendingMessage pending; synchronized (pendingMessages) { pending = pendingMessages.remove(conversationId); } if (pending == null) return; // 更新消息内容为合并后的文本 pending.firstMessage.setContent(pending.getMergedContent()); String channelType = pending.adapter.getChannelType(); LinkedBlockingQueue queue = channelQueues.computeIfAbsent(channelType, this::createChannelQueue); boolean offered = queue.offer(new QueueEntry(pending.firstMessage, pending.adapter, pending.channelEntity)); if (!offered) { log.error("[{}] Message queue full (capacity={}), dropping message from {}", channelType, QUEUE_CAPACITY, pending.firstMessage.getSenderId()); try { String replyTarget = resolveReplyTarget(pending.firstMessage); pending.adapter.sendMessage(replyTarget, "系统繁忙,请稍后再试"); } catch (Exception e) { log.error("[{}] Failed to send busy message: {}", channelType, e.getMessage()); } } else { log.debug("[{}] Message flushed to queue: conversationId={}, queueSize={}", channelType, conversationId, queue.size()); } } // ==================== 消费线程 ==================== /** * 为渠道类型创建队列并启动消费线程 */ private LinkedBlockingQueue createChannelQueue(String channelType) { LinkedBlockingQueue queue = new LinkedBlockingQueue<>(QUEUE_CAPACITY); ExecutorService executor = Executors.newFixedThreadPool(CONSUMERS_PER_CHANNEL, new ThreadFactory() { private int counter = 0; @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, "channel-consumer-" + channelType + "-" + (counter++)); t.setDaemon(true); return t; } }); for (int i = 0; i < CONSUMERS_PER_CHANNEL; i++) { executor.execute(() -> consumeLoop(channelType, queue)); } channelExecutors.put(channelType, executor); log.info("[{}] Created message queue (capacity={}) with {} consumer threads", channelType, QUEUE_CAPACITY, CONSUMERS_PER_CHANNEL); return queue; } /** * 消费线程循环:从队列取消息,加会话锁后串行处理 */ private void consumeLoop(String channelType, LinkedBlockingQueue queue) { log.info("[{}] Consumer thread started: {}", channelType, Thread.currentThread().getName()); while (!shutdown) { try { QueueEntry entry = queue.poll(1, TimeUnit.SECONDS); if (entry == null) { continue; // 超时,重新检查 shutdown 标志 } String conversationId = buildConversationId(entry.message()); ReentrantLock lock = sessionLocks.computeIfAbsent(conversationId, k -> new ReentrantLock()); lock.lock(); try { processMessage(entry.message(), entry.adapter(), entry.channelEntity(), conversationId); } finally { lock.unlock(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } catch (Exception e) { log.error("[{}] Unexpected error in consumer loop: {}", channelType, e.getMessage(), e); } } log.info("[{}] Consumer thread stopped: {}", channelType, Thread.currentThread().getName()); } // ==================== 审批命令识别 ==================== private static final java.util.Set APPROVE_COMMANDS = java.util.Set.of( "approve", "/approve", "批准", "/批准"); private static final java.util.Set DENY_COMMANDS = java.util.Set.of( "deny", "/deny", "拒绝", "/拒绝"); /** 带 pendingId 的审批命令格式:/approve a1b2c3 */ private static final java.util.regex.Pattern APPROVE_WITH_ID = java.util.regex.Pattern.compile("^/?(approve|批准)\\s+([a-f0-9]{6,16})$", java.util.regex.Pattern.CASE_INSENSITIVE); private static final java.util.regex.Pattern DENY_WITH_ID = java.util.regex.Pattern.compile("^/?(deny|拒绝)\\s+([a-f0-9]{6,16})$", java.util.regex.Pattern.CASE_INSENSITIVE); private boolean isApproveCommand(String text) { String t = text.toLowerCase().strip(); return APPROVE_COMMANDS.contains(t) || APPROVE_WITH_ID.matcher(t).matches(); } private boolean isDenyCommand(String text) { String t = text.toLowerCase().strip(); return DENY_COMMANDS.contains(t) || DENY_WITH_ID.matcher(t).matches(); } /** * 从审批命令中提取 shortId(如 "/approve a1b2c3" → "a1b2c3"),无 id 则返回 null */ private String extractShortId(String text) { java.util.regex.Matcher m = APPROVE_WITH_ID.matcher(text.strip()); if (m.matches()) return m.group(2); m = DENY_WITH_ID.matcher(text.strip()); if (m.matches()) return m.group(2); return null; } // ==================== 消息处理(原 route 逻辑 + 审批拦截层) ==================== /** * 处理单条消息:保存 -> 调用 Agent -> 保存回复 -> 发送回复 *

* 当钉钉渠道启用 AI Card 时,走流式卡片路径。 */ private void processMessage(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity, String conversationId) { Long agentId = channelEntity.getAgentId(); log.info("[{}] Processing message: sender={}, conversationId={}, agentId={}", adapter.getChannelType(), message.getSenderId(), conversationId, agentId); try { // ======= 审批拦截层 ======= String userText = message.getContent() != null ? message.getContent().trim() : ""; PendingApproval pending = approvalService.findPendingByConversation(conversationId); if (pending != null) { String replyTarget = resolveReplyTarget(message); if (isApproveCommand(userText)) { // pendingId 校验:如果命令包含 shortId,验证是否匹配当前 pending String shortId = extractShortId(userText); if (shortId != null && !pending.getPendingId().startsWith(shortId)) { adapter.sendMessage(replyTarget, "⚠️ 审批ID不匹配。当前待审批: " + pending.getPendingId().substring(0, Math.min(6, pending.getPendingId().length()))); return; } // 身份校验:只有原始请求者可以审批(群聊安全) String originalRequester = pending.getUserId(); if (originalRequester != null && !"system".equals(originalRequester) && !originalRequester.equals(message.getSenderId())) { adapter.sendMessage(replyTarget, "⚠️ 只有原始请求者可以审批此操作。"); log.warn("[{}] Approval rejected: sender={} != requester={}", adapter.getChannelType(), message.getSenderId(), originalRequester); return; } // Approve via IM: workflow.resolveAndConsume runs DB + metadata + memory atomically. ResolveOutcome consumeOutcome = approvalService.resolveAndConsume( pending.getPendingId(), message.getSenderId()); if (consumeOutcome.isAlreadyResolved()) { adapter.sendMessage(replyTarget, "⚠️ 审批记录已过期或已被处理。"); return; } PendingApproval consumed = consumeOutcome.consumedSnapshot(); log.info("[{}] Approval APPROVED via IM command: pendingId={}, tool={}, msgRewritten={}", adapter.getChannelType(), consumed.getPendingId(), consumed.getToolName(), consumeOutcome.messagesRewritten()); replayApprovedToolCall(consumed, conversationId, adapter, message, channelEntity); return; } else if (isDenyCommand(userText)) { // Deny via IM: workflow.resolve owns the full state-machine transition. ResolveOutcome denyOutcome = approvalService.resolve( pending.getPendingId(), message.getSenderId(), "denied"); conversationService.removeApprovalPlaceholders(conversationId); adapter.sendMessage(replyTarget, "⛔ 已拒绝执行工具: " + pending.getToolName()); log.info("[{}] Approval DENIED via IM command: pendingId={}, tool={}, msgRewritten={}", adapter.getChannelType(), pending.getPendingId(), pending.getToolName(), denyOutcome.messagesRewritten()); return; } else { // Non-approval message while a pending exists → treat as implicit deny. approvalService.resolve(pending.getPendingId(), message.getSenderId(), "denied"); conversationService.removeApprovalPlaceholders(conversationId); adapter.sendMessage(replyTarget, "⛔ 审批已取消。将继续处理您的新消息。"); log.info("[{}] Approval auto-cancelled (non-approval message): pendingId={}", adapter.getChannelType(), pending.getPendingId()); // Fall through to process the new message normally. } } // ======= 审批拦截层结束 ======= // 确保会话存在(workspace 感知) conversationService.getOrCreateSharedConversation(conversationId, agentId, channelEntity.getWorkspaceId()); // 更新渠道会话存储(用于主动推送) String replyTarget = resolveReplyTarget(message); if (replyTarget != null) { channelSessionStore.saveOrUpdate( conversationId, adapter.getChannelType(), replyTarget, message.getSenderId(), message.getSenderName(), channelEntity.getId() ); } else { log.warn("[{}] No reply target resolved for sender={}, skipping session store update", adapter.getChannelType(), message.getSenderId()); } // 保存用户消息(带 contentParts) List parts = message.getContentParts(); conversationService.saveMessage(conversationId, "user", message.getContent(), parts); // 构建 prompt(语音输入时注入场景提示词) String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode()); // 注册到 ChatStreamTracker:让 graph 节点广播的事件(phase / content_delta / tool_call_* 等) // 能被 ChatConsole observer 订阅到。不注册 → broadcast() 会因 state==null 短路丢弃。 // 同步 DB stream_status 让侧栏列表可以识别"该渠道对话正在运行",从而触发 selectConversation 的 reconnect 分支。 streamTracker.register(conversationId); streamTracker.incrementFlux(conversationId); conversationService.updateStreamStatus(conversationId, "running"); // 捕获已保存 assistant 的 DB id,供 finally 的 done 事件带出来 —— 前端 useChat.done 处理器 // 依赖 data.assistantMessageId 把本地流式 placeholder 的 client-uuid 升级为 DB id,这样 // 紧接着 refreshCurrentConversationMessages 的 reconcile 能按 id 干净匹配,不会走"认领"兜底 // 导致气泡丢失。 Long savedAssistantId = null; try { // 流式路径:渠道实现了 StreamingChannelAdapter 则委托渠道渲染流式事件 // RFC-063r §2.5: build the ChatOrigin once per channel-message // so cron jobs created during this conversation inherit the // channel binding (Issue #25 root path). ChatOrigin chatOrigin = chatOriginFactory.from( channelEntity, message, conversationId, /* workspaceBasePath */ null); if (adapter instanceof StreamingChannelAdapter streamingAdapter) { savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity, chatOrigin); } else { // Sync path for non-streaming IM adapters (feishu / wecom / weixin / // slack / discord / qq / telegram). We can't use agentService.chat() // because its collector filters out `delta.isEvent()` deltas — that // would silently drop plan_created / plan_step_* events that the Web // Console mirror needs to render PlanStepsPanel. Instead we consume // chatStructuredStream directly: content gets accumulated for the IM // reply, and whitelisted plan events are mirrored to ChatStreamTracker // for any Web SSE viewer of the same conversationId. StringBuilder replyAccumulator = new StringBuilder(); final String channelType = adapter.getChannelType(); agentService.chatStructuredStream(agentId, promptText, conversationId, message.getSenderId(), chatOrigin) .doOnNext(delta -> { if (delta.isEvent()) { mirrorPlanEventToTracker(conversationId, delta, channelType); } else if (delta.content() != null) { // Match the legacy agentService.chat() behavior: include // persistOnly deltas too. DirectAnswerNode-routed answers // arrive as persistOnly when CONTENT_STREAMED=true and IM // channels still need the text for the outgoing reply. replyAccumulator.append(delta.content()); } }) .blockLast(Duration.ofMinutes(10)); String reply = replyAccumulator.toString(); // 检查 chat 过程中是否产生了审批 pending PendingApproval newPending = approvalService.findPendingByConversation(conversationId); if (newPending != null) { // 有审批需求:不保存 LLM 的审批占位回复到 DB,直接从 pending 元数据构建通知 String approvalNotice = buildApprovalNotice(newPending); adapter.renderAndSend(replyTarget, approvalNotice); log.info("[{}] Approval triggered during chat, sent notice (NOT saved to DB): tool={}", adapter.getChannelType(), newPending.getToolName()); } else { // Tag error replies (matched by ChannelErrorClassifier — the // "[错误]" content prefix / Bad request: / LLM error templates) // with status='error' so BaseAgent.sanitizeForLlm drops them // from the next turn's LLM history, breaking the self-replicating // 400 loop. Only successful replies fire the ConversationCompletedEvent — // error turns must not pollute memory extraction. boolean isError = errorClassifier.isErrorReply(reply); String status = isError ? "error" : "completed"; MessageEntity saved = conversationService.saveMessage( conversationId, "assistant", reply, null, status); savedAssistantId = saved != null ? saved.getId() : null; if (!isError) { publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply); } adapter.renderAndSend(replyTarget, reply); log.info("[{}] Reply sent to {}: {}chars", adapter.getChannelType(), replyTarget, reply.length()); // 给 observer 推送完整的 assistant 消息以便前端一次性渲染为气泡 // (在 content_delta 事件可能没开的同步路径下作为兜底) Map msgCompletePayload = new HashMap<>(); msgCompletePayload.put("conversationId", conversationId); msgCompletePayload.put("content", reply); if (savedAssistantId != null) { msgCompletePayload.put("assistantMessageId", savedAssistantId); } msgCompletePayload.put("timestamp", System.currentTimeMillis()); streamTracker.broadcastObject(conversationId, "message_complete", msgCompletePayload); // 语音回复:异步 TTS 合成并追加发送(先文本后语音,不阻塞) maybeGenerateVoiceReply(message, adapter, replyTarget, conversationId, reply, channelEntity); } } } finally { // 广播 done 事件让 observer 前端收尾(HashMap 允许 null 值缺失,Map.of 不允许) Map donePayload = new HashMap<>(); donePayload.put("conversationId", conversationId); donePayload.put("status", "completed"); if (savedAssistantId != null) { donePayload.put("assistantMessageId", savedAssistantId); } donePayload.put("timestamp", System.currentTimeMillis()); streamTracker.broadcastObject(conversationId, "done", donePayload); streamTracker.completeAndConsumeIfLast(conversationId); try { conversationService.updateStreamStatus(conversationId, "idle"); } catch (Exception e) { log.debug("Failed to reset stream_status for {}: {}", conversationId, e.getMessage()); } } } catch (Exception e) { log.error("[{}] Failed to process message from {}: {}", adapter.getChannelType(), message.getSenderId(), e.getMessage(), e); // 尝试发送错误提示 try { String errorTarget = resolveReplyTarget(message); adapter.sendMessage(errorTarget, "抱歉,处理消息时出现错误:" + e.getMessage()); } catch (Exception sendErr) { log.error("[{}] Failed to send error message: {}", adapter.getChannelType(), sendErr.getMessage()); } } } /** * 流式处理路径(渠道无关) *

* 事件流与渲染分离: * - Router 负责产生 StreamDelta 流(调用 AgentService) * - StreamingChannelAdapter 负责渲染(AI Card / 卡片更新 / 文本累积等) * - Router 负责后续的审批检查、消息持久化、事件发布 */ /** * Forward whitelisted Plan-Execute SSE events to ChatStreamTracker so a * Web Console viewer of an IM-routed conversation sees PlanStepsPanel. *

* Bounded to {@link #MIRRORED_PLAN_EVENTS} — see the constant's javadoc * for why this is a whitelist rather than a pass-through. Failures here * are best-effort and never propagate, since dropping a UI update is * preferable to derailing the channel reply. */ private void mirrorPlanEventToTracker(String conversationId, AgentService.StreamDelta delta, String channelTypeForLog) { String eventType = delta.eventType(); if (eventType == null || !MIRRORED_PLAN_EVENTS.contains(eventType)) { return; } try { streamTracker.broadcastObject(conversationId, eventType, delta.eventData()); } catch (Exception ex) { log.debug("[{}] Failed to mirror plan event {}: {}", channelTypeForLog, eventType, ex.getMessage()); } } private Long processWithStreaming(ChannelMessage message, StreamingChannelAdapter streamingAdapter, String conversationId, Long agentId, String promptText, ChannelEntity channelEntity, ChatOrigin chatOrigin) { String channelType = streamingAdapter.getChannelType(); log.info("[{}] Streaming processing started: conversationId={}", channelType, conversationId); try { // Step 1: 产生事件流(RFC-063r §2.5: forward ChatOrigin so tools see channelId) Flux stream = agentService.chatStructuredStream( agentId, promptText, conversationId, message.getSenderId(), chatOrigin); // Mirror plan-execute SSE events to ChatStreamTracker before the // adapter consumes the Flux. DingTalkChannelAdapter.processStreamAsText // only reads `delta.content()` and would otherwise eat plan_created / // plan_step_* events, leaving the Web Console mirror with no // PlanStepsPanel for IM-routed conversations. Flux mirroredStream = stream.doOnNext(delta -> mirrorPlanEventToTracker(conversationId, delta, channelType)); // Step 2: 委托渠道渲染(渠道内部消费 Flux 并处理 UI 更新) String finalContent = streamingAdapter.processStream(mirroredStream, message, conversationId); // Step 3: 审批检查 + 持久化(渠道无关逻辑,由 Router 统一处理) PendingApproval newPending = approvalService.findPendingByConversation(conversationId); if (newPending != null) { String replyTarget = resolveReplyTarget(message); streamingAdapter.sendMessage(replyTarget, buildApprovalNotice(newPending)); log.info("[{}] Approval triggered during streaming (NOT saved to DB): tool={}", channelType, newPending.getToolName()); } else if (finalContent != null && !finalContent.isBlank()) { boolean isError = errorClassifier.isErrorReply(finalContent); String status = isError ? "error" : "completed"; MessageEntity saved = conversationService.saveMessage( conversationId, "assistant", finalContent, null, status); if (!isError) { publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent); } log.info("[{}] Streaming completed: contentLen={}, isError={}", channelType, finalContent.length(), isError); // 流式回复完成后也触发语音回复 String replyTarget = resolveReplyTarget(message); if (replyTarget != null) { maybeGenerateVoiceReply(message, streamingAdapter, replyTarget, conversationId, finalContent, channelEntity); } return saved != null ? saved.getId() : null; } } catch (Exception e) { log.error("[{}] Streaming processing failed: {}", channelType, e.getMessage(), e); // Persist an error placeholder (status='error') so that the next // turn's history does not show a user → user sequence (which some // providers reject with 400). sanitizeForLlm filters this row out // before the LLM sees it, so it costs nothing at the prompt layer. try { conversationService.saveMessage(conversationId, "assistant", "[错误] " + e.getMessage(), null, "error"); } catch (Exception persistErr) { log.warn("[{}] Failed to persist error placeholder: {}", channelType, persistErr.getMessage()); } try { String errorTarget = resolveReplyTarget(message); streamingAdapter.sendMessage(errorTarget, "抱歉,流式处理失败:" + e.getMessage()); } catch (Exception sendErr) { log.error("[{}] Failed to send streaming error message: {}", channelType, sendErr.getMessage()); } } return null; } // ==================== 审批重放 ==================== /** * 重放被审批阻塞的工具调用 *

* 接收已消费的审批记录(由 resolveAndConsume 原子获取),通过 AgentService.chatWithReplay 重新执行工具。 * 重放前清理 DB 中的审批占位消息,防止 LLM 看到残留文本后重新发起工具调用(死循环根因)。 */ private void replayApprovedToolCall(PendingApproval consumed, String conversationId, ChannelAdapter adapter, ChannelMessage triggerMessage, ChannelEntity channelEntity) { String replyTarget = resolveReplyTarget(triggerMessage); Long agentId = channelEntity.getAgentId(); // 通知用户审批已通过 adapter.sendMessage(replyTarget, "✅ 已批准执行工具: " + consumed.getToolName()); // 清理 DB 中残留的审批占位消息 conversationService.removeApprovalPlaceholders(conversationId); // 简化 replay prompt(不重复工具名,防止 LLM 误解) String replayPrompt = "继续执行已批准的工具调用。"; try { // RFC-063r §2.12: prefer the persisted Memento (covers // cross-restart approval where the channel session changed) and // only fall back to rebuilding from the current inbound message // when no snapshot was captured (legacy rows from before this PR). ChatOrigin replayOrigin = approvalService.restoreChatOrigin(consumed.getChatOrigin()); if (replayOrigin == ChatOrigin.EMPTY) { replayOrigin = chatOriginFactory.from( channelEntity, triggerMessage, conversationId, /* workspaceBasePath */ null); } String reply = agentService.chatWithReplay( agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin); // Persist the replay result. If the LLM 400'd during replay, // the error reply must also get status='error' — otherwise the // next turn's history would re-feed the error placeholder back // into the prompt and re-trigger the same failure. boolean isError = errorClassifier.isErrorReply(reply); conversationService.saveMessage(conversationId, "assistant", reply, null, isError ? "error" : "completed"); // 发送回复 adapter.renderAndSend(replyTarget, reply); log.info("[{}] Replay completed: tool={}, replyLen={}", adapter.getChannelType(), consumed.getToolName(), reply.length()); } catch (Exception e) { log.error("[approval-replay] Replay failed: {}", e.getMessage(), e); adapter.sendMessage(replyTarget, "❌ 工具执行失败: " + e.getMessage()); } } /** * 从 PendingApproval 元数据构建 IM 友好的审批通知(委托给 ApprovalNotificationService) */ private String buildApprovalNotice(PendingApproval pending) { return approvalNotificationService.buildApprovalText(pending); } /** * Publish the conversation-completed event (triggers async memory extraction). * Delegates to {@link ConversationCompletionPublisher} so the try/catch and * messageCount lookup no longer live here. */ private void publishConversationCompletedEvent(Long agentId, String conversationId, String userMessage, String assistantReply) { completionPublisher.publish(agentId, conversationId, userMessage, assistantReply, "channel"); } // ==================== 流式处理(Web 渠道专用,不走队列) ==================== /** * 路由消息并使用流式处理(用于支持流式的渠道,如 Web) */ public Flux routeStream(ChannelMessage message, ChannelEntity channelEntity) { Long agentId = channelEntity.getAgentId(); if (agentId == null) { return Flux.error(new IllegalStateException("Channel has no associated agent")); } String conversationId = buildConversationId(message); String username = message.getSenderName() != null ? message.getSenderName() : message.getSenderId(); conversationService.getOrCreateConversation(conversationId, agentId, username, channelEntity.getWorkspaceId()); List parts = message.getContentParts(); conversationService.saveMessage(conversationId, "user", message.getContent(), parts); String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode()); // RFC-063r §2.5: forward ChatOrigin so tools created during this // streaming conversation inherit channel binding. ChatOrigin origin = chatOriginFactory.from( channelEntity, message, conversationId, /* workspaceBasePath */ null); return agentService.chatStream(agentId, promptText, conversationId, origin); } // ==================== 优雅关闭 ==================== /** * 优雅关闭:停止防抖调度器和所有消费线程 */ public void shutdown() { log.info("Shutting down ChannelMessageRouter..."); shutdown = true; // 1. 关闭防抖调度器 debounceScheduler.shutdownNow(); // 2. 清理残留的 pending 消息 synchronized (pendingMessages) { pendingMessages.forEach((convId, pending) -> { if (pending.timer != null) { pending.timer.cancel(false); } log.warn("Dropping pending debounced message for conversation: {}", convId); }); pendingMessages.clear(); } // 3. 关闭每个渠道的消费线程池:shutdown -> 等待 5 秒 -> shutdownNow channelExecutors.forEach((channelType, executor) -> { log.info("[{}] Shutting down consumer threads...", channelType); executor.shutdown(); try { if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { log.warn("[{}] Consumer threads did not terminate in 5s, forcing shutdown", channelType); executor.shutdownNow(); } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); } }); channelExecutors.clear(); channelQueues.clear(); sessionLocks.clear(); // 4. 关闭语音回复线程池 voiceReplyExecutor.shutdownNow(); log.info("ChannelMessageRouter shutdown complete"); } // ==================== 工具方法 ==================== /** * 构建会话 ID * 格式:{channelType}:{chatId 或 senderId} * 格式采用 {channelType}:{identifier} 命名规则 */ private String buildConversationId(ChannelMessage message) { String identifier = message.getChatId() != null ? message.getChatId() : message.getSenderId(); return message.getChannelType() + ":" + identifier; } /** * 确定回复目标 * 优先使用 replyToken(渠道特有的回复标识),其次 chatId,最后 senderId */ private String resolveReplyTarget(ChannelMessage message) { if (message.getReplyToken() != null) { return message.getReplyToken(); } return message.getChatId() != null ? message.getChatId() : message.getSenderId(); } /** * 从 contentParts 构建完整 prompt 文本。 * 文本直接拼接;媒体类型生成描述性占位符,让 Agent 知道用户发送了什么。 * 语音输入时注入场景提示词,引导 Agent 用简短口语化方式回复。 */ private String buildPromptFromParts(String fallbackContent, List parts, String inputMode) { if (parts == null || parts.isEmpty()) { return fallbackContent != null ? fallbackContent : ""; } StringBuilder sb = new StringBuilder(); for (MessageContentPart part : parts) { if (part == null || part.getType() == null) continue; switch (part.getType()) { case "text" -> appendLine(sb, part.getText()); case "image" -> appendLine(sb, "[用户发送了图片" + descMedia(part) + "]"); case "file" -> appendLine(sb, "[用户发送了文件: " + safe(part.getFileName()) + "]"); case "audio" -> appendLine(sb, "[用户发送了音频" + descMedia(part) + "]"); case "video" -> appendLine(sb, "[用户发送了视频" + descMedia(part) + "]"); default -> appendLine(sb, part.getText()); } } String result = sb.toString().trim(); if (result.isEmpty()) { return fallbackContent != null ? fallbackContent : ""; } // 语音场景:注入提示词让 Agent 回复更简短口语化 if ("voice".equals(inputMode)) { result = "[用户通过语音输入,请用简短口语化的方式回复]\n" + result; } return result; } private void appendLine(StringBuilder sb, String text) { if (text == null || text.isBlank()) return; if (!sb.isEmpty()) sb.append('\n'); sb.append(text); } private String descMedia(MessageContentPart part) { if (part.getFileName() != null && !part.getFileName().isBlank()) { return ": " + part.getFileName(); } return ""; } private String safe(String s) { return s == null ? "" : s; } // ==================== 语音回复(TTS)==================== /** TTS 异步工作线程池 */ private final ExecutorService voiceReplyExecutor = Executors.newFixedThreadPool(2, r -> { Thread t = new Thread(r, "voice-reply-worker"); t.setDaemon(true); return t; }); /** * 根据消息上下文和渠道配置,判断是否需要生成语音回复。 * 若需要,异步合成 TTS 并通过渠道发送音频文件。 *

* 设计原则(借鉴 OpenClaw): * - 文本先行,语音异步追加,不阻塞用户体验 * - 短回复(<10字)跳过 TTS(不值得合成) * - TTS 失败静默降级,不影响已发出的文本回复 */ private void maybeGenerateVoiceReply(ChannelMessage message, ChannelAdapter adapter, String replyTarget, String conversationId, String replyText, ChannelEntity channelEntity) { if (!shouldGenerateVoiceReply(message, channelEntity, replyText)) { return; } voiceReplyExecutor.submit(() -> { try { // 读取渠道级语音配置 Map channelConfig = parseChannelConfig(channelEntity.getConfigJson()); String voiceName = channelConfig.getOrDefault("voice_name", "").toString(); double voiceSpeed = 1.0; Object speedObj = channelConfig.get("voice_speed"); if (speedObj instanceof Number n) voiceSpeed = n.doubleValue(); // 调用 TtsService 合成 Map result = ttsService.synthesize( conversationId, replyText, voiceName.isBlank() ? null : voiceName, voiceSpeed, "mp3"); if (!Boolean.TRUE.equals(result.get("success"))) { log.debug("[voice-reply] TTS synthesis failed: {}", result.get("error")); return; } // 构建音频 MessageContentPart String audioUrl = (String) result.get("audioUrl"); String fileName = Paths.get(audioUrl).getFileName().toString(); Path audioPath = Paths.get("data", "chat-uploads", conversationId, fileName); if (!Files.exists(audioPath)) { log.warn("[voice-reply] TTS output file not found: {}", audioPath); return; } MessageContentPart audioPart = new MessageContentPart(); audioPart.setType("audio"); audioPart.setFileName(fileName); audioPart.setPath(audioPath.toString()); audioPart.setContentType("audio/mpeg"); adapter.sendContentParts(replyTarget, List.of(audioPart)); log.info("[voice-reply] Sent to {} via {}: {} ({}KB)", replyTarget, adapter.getChannelType(), fileName, Files.size(audioPath) / 1024); } catch (Exception e) { // 静默降级:TTS 失败不影响已发出的文本回复 log.warn("[voice-reply] Failed for conversation {}: {}", conversationId, e.getMessage()); } }); } /** * 判断是否需要为此消息生成语音回复 */ private boolean shouldGenerateVoiceReply(ChannelMessage message, ChannelEntity channelEntity, String replyText) { // 1. TTS 全局开关 if (!ttsService.isTtsEnabled()) return false; // 2. 回复内容过短或为空,跳过 TTS(借鉴 OpenClaw: <10字不合成) if (replyText == null || replyText.trim().length() < 10) return false; // 3. 读取渠道级语音回复模式 Map channelConfig = parseChannelConfig(channelEntity.getConfigJson()); String voiceMode = channelConfig.getOrDefault("voice_reply_mode", "off").toString(); if ("off".equals(voiceMode)) return false; if ("always".equals(voiceMode)) return true; // 4. auto 模式:仅当用户通过语音输入时回语音 return "auto".equals(voiceMode) && "voice".equals(message.getInputMode()); } /** * 解析 Channel 的 configJson 为 Map */ private Map parseChannelConfig(String configJson) { if (configJson == null || configJson.isBlank()) return Map.of(); try { return objectMapper.readValue(configJson, new TypeReference<>() {}); } catch (Exception e) { log.debug("[voice-reply] Failed to parse channel config: {}", e.getMessage()); return Map.of(); } } }