feat(channel): add clear magic command

This commit is contained in:
matevip 2026-07-20 18:21:46 +08:00
parent ae62faee4e
commit d10ed9dd06
3 changed files with 168 additions and 1 deletions

View File

@ -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<String> 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);
}
}

View File

@ -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);
}
}
/**
* 流式处理路径渠道无关
* <p>

View File

@ -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));
}
}