diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMagicCommand.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMagicCommand.java new file mode 100644 index 00000000..5e21ef99 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMagicCommand.java @@ -0,0 +1,47 @@ +package vip.mate.channel; + +import java.util.Locale; +import java.util.Set; + +/** + * User-typed channel control commands that should be handled by the platform + * instead of being sent to the agent as normal prompt text. + */ +final class ChannelMagicCommand { + + private static final Set CLEAR_COMMANDS = Set.of( + "clear", + "/clear", + "reset", + "/reset", + "清空", + "/清空", + "清空上下文", + "/清空上下文", + "清理上下文", + "/清理上下文", + "清除上下文", + "/清除上下文", + "重置上下文", + "/重置上下文" + ); + + private ChannelMagicCommand() { + } + + static boolean isClearCommand(String text) { + String normalized = normalize(text); + return !normalized.isEmpty() && CLEAR_COMMANDS.contains(normalized); + } + + static String clearConfirmation() { + return "✅ 上下文已清理,后续消息会从新的上下文开始。"; + } + + private static String normalize(String text) { + if (text == null) { + return ""; + } + return text.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index 397f6766..8f68c183 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -271,6 +271,11 @@ public class ChannelMessageRouter { } channelEntity = fresh; + String conversationId = buildConversationId(message); + if (handleMagicCommand(message, adapter, conversationId)) { + return; + } + // Fan out to the trigger pipeline FIRST — channel_message and // content_match triggers fire on every received message regardless // of whether the channel has an agent attached. If we returned @@ -292,7 +297,6 @@ public class ChannelMessageRouter { } String channelType = adapter.getChannelType(); - String conversationId = buildConversationId(message); log.info("[{}] Enqueuing message: sender={}, conversationId={}, agentId={}", channelType, message.getSenderId(), conversationId, agentId); @@ -601,6 +605,10 @@ public class ChannelMessageRouter { adapter.getChannelType(), message.getSenderId(), conversationId, agentId); try { + if (handleMagicCommand(message, adapter, conversationId)) { + return; + } + // ======= 审批拦截层 ======= String userText = message.getContent() != null ? message.getContent().trim() : ""; PendingApproval pending = approvalService.findPendingByConversation(conversationId); @@ -910,6 +918,38 @@ public class ChannelMessageRouter { } } + /** + * Handle channel-native control commands before the message is persisted + * or forwarded to the agent. Mirrors QwenPaw's control-command dispatch: + * a recognized command is terminal for this inbound message. + */ + private boolean handleMagicCommand(ChannelMessage message, ChannelAdapter adapter, + String conversationId) { + String userText = message != null ? message.getContent() : null; + if (!ChannelMagicCommand.isClearCommand(userText)) { + return false; + } + cancelPending(conversationId); + conversationService.clearMessages(conversationId); + String replyTarget = resolveReplyTarget(message); + if (replyTarget != null) { + adapter.sendMessage(replyTarget, ChannelMagicCommand.clearConfirmation()); + } + log.info("[{}] Magic command handled: clear conversationId={}, sender={}", + adapter.getChannelType(), conversationId, message != null ? message.getSenderId() : null); + return true; + } + + private void cancelPending(String conversationId) { + PendingMessage pending; + synchronized (pendingMessages) { + pending = pendingMessages.remove(conversationId); + } + if (pending != null && pending.timer != null) { + pending.timer.cancel(false); + } + } + /** * 流式处理路径(渠道无关) *

diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelMagicCommandTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelMagicCommandTest.java new file mode 100644 index 00000000..9c6c7c22 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelMagicCommandTest.java @@ -0,0 +1,80 @@ +package vip.mate.channel; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.agent.AgentService; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.approval.ApprovalWorkflowService; +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 java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ChannelMagicCommandTest { + + @Test + @DisplayName("clear aliases are recognized as channel magic commands") + void clearAliasesAreRecognized() { + assertTrue(ChannelMagicCommand.isClearCommand("clear")); + assertTrue(ChannelMagicCommand.isClearCommand("/clear")); + assertTrue(ChannelMagicCommand.isClearCommand(" 清空上下文 ")); + assertTrue(ChannelMagicCommand.isClearCommand("清理上下文")); + assertTrue(ChannelMagicCommand.isClearCommand("/reset")); + + assertFalse(ChannelMagicCommand.isClearCommand("clear 一下北京天气")); + assertFalse(ChannelMagicCommand.isClearCommand("请清理上下文后继续")); + assertFalse(ChannelMagicCommand.isClearCommand("/approval approve abc123")); + } + + @Test + @DisplayName("clear command wipes current channel conversation and does not call agent") + void clearCommandClearsConversationWithoutCallingAgent() throws Exception { + AgentService agentService = mock(AgentService.class); + ConversationService conversationService = mock(ConversationService.class); + ChannelService channelService = mock(ChannelService.class); + ChannelSessionStore channelSessionStore = mock(ChannelSessionStore.class); + ApprovalWorkflowService approvalService = mock(ApprovalWorkflowService.class); + ApprovalNotificationService approvalNotificationService = mock(ApprovalNotificationService.class); + ConversationCompletionPublisher completionPublisher = mock(ConversationCompletionPublisher.class); + TtsService ttsService = mock(TtsService.class); + ChatStreamTracker streamTracker = mock(ChatStreamTracker.class); + ChannelChatOriginFactory chatOriginFactory = mock(ChannelChatOriginFactory.class); + ChannelErrorClassifier errorClassifier = mock(ChannelErrorClassifier.class); + ChannelMessageRouter router = new ChannelMessageRouter(agentService, conversationService, + channelService, channelSessionStore, approvalService, approvalNotificationService, + completionPublisher, ttsService, new ObjectMapper(), streamTracker, + chatOriginFactory, errorClassifier); + + ChannelAdapter adapter = mock(ChannelAdapter.class); + when(adapter.getChannelType()).thenReturn("wecom"); + ChannelEntity channel = new ChannelEntity(); + channel.setAgentId(100L); + ChannelMessage message = ChannelMessage.builder() + .senderId("alice") + .replyToken("reply-1") + .content("/clear") + .build(); + + Method process = ChannelMessageRouter.class.getDeclaredMethod( + "processMessage", ChannelMessage.class, ChannelAdapter.class, ChannelEntity.class, String.class); + process.setAccessible(true); + process.invoke(router, message, adapter, channel, "wecom:alice"); + + verify(conversationService).clearMessages("wecom:alice"); + verify(adapter).sendMessage(eq("reply-1"), contains("上下文已清理")); + verify(conversationService, never()).saveMessage(anyString(), anyString(), anyString(), any(), anyString()); + verify(agentService, never()).chatStructuredStream( + anyLong(), anyString(), anyString(), anyString(), any(ChatOrigin.class)); + } +}