From e2747df2c5aeace6a5a9793281e3f989f5207678 Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 23 Jul 2026 11:49:28 +0800 Subject: [PATCH] feat(channel): /model magic command for per-conversation model switching (restore) --- .../vip/mate/channel/ChannelMagicCommand.java | 14 +- .../mate/channel/ChannelMessageRouter.java | 162 +++++++++++++++++- .../conversation/ConversationService.java | 18 ++ .../mate/channel/ChannelMagicCommandTest.java | 155 ++++++++++++++++- 4 files changed, 345 insertions(+), 4 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMagicCommand.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMagicCommand.java index 0ce3b0a6..3e3e0e4f 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMagicCommand.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMagicCommand.java @@ -21,7 +21,7 @@ import java.util.Optional; final class ChannelMagicCommand { /** Platform-level command kinds, dispatched by {@link ChannelMessageRouter}. */ - enum Type { CLEAR, NEW, HELP, STATUS, STOP } + enum Type { CLEAR, NEW, HELP, STATUS, STOP, MODEL } /** A recognized command plus its raw (possibly empty) argument string. */ record Parsed(Type type, String args) { @@ -85,6 +85,7 @@ final class ChannelMagicCommand { /new — 开启新会话(别名:新会话) /stop — 停止当前进行中的任务(别名:停止) /status — 查看当前会话状态(别名:状态) + /model — 查看可用模型;/model <名称> 切换本会话模型;/model reset 恢复默认 /help — 显示本帮助(别名:帮助)"""; } @@ -101,6 +102,10 @@ final class ChannelMagicCommand { "status", "状态"); register(aliases, Type.STOP, "stop", "停止"); + // Slash-only: "model" / "模型" are common standalone words in normal + // prompts ("模型是什么?"), so the bare form must never be a command. + registerSlashOnly(aliases, Type.MODEL, + "model", "模型"); return aliases; } @@ -111,6 +116,13 @@ final class ChannelMagicCommand { } } + /** Register only the "/"-prefixed form — for aliases whose bare word is ordinary prose. */ + private static void registerSlashOnly(Map aliases, Type type, String... names) { + for (String name : names) { + aliases.put("/" + name, type); + } + } + private static int indexOfWhitespace(String text) { for (int i = 0; i < text.length(); i++) { if (Character.isWhitespace(text.charAt(i))) { 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 7ee3d4af..461a7051 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -18,9 +18,12 @@ import vip.mate.channel.service.ChannelService; import vip.mate.channel.web.AgentStreamAccumulator; import vip.mate.channel.web.ChatStreamTracker; import vip.mate.exception.MateClawException; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; import vip.mate.memory.event.ConversationCompletionPublisher; import vip.mate.tts.TtsService; import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.ConversationEntity; import vip.mate.workspace.conversation.model.MessageContentPart; import vip.mate.workspace.core.service.ChatUploadLocationResolver; import vip.mate.workspace.conversation.model.MessageEntity; @@ -34,6 +37,7 @@ import java.nio.file.Paths; import java.time.Duration; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.*; @@ -79,6 +83,13 @@ public class ChannelMessageRouter { @Autowired(required = false) private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver; + /** Field-injected for the same reason as {@link #events}: backs the + * /model magic command (list + switch). Optional so tests that build + * the router directly still work; when unset the command degrades to + * a "service unavailable" reply instead of failing message intake. */ + @Autowired(required = false) + private ModelConfigService modelConfigService; + /** Field-injected so the IM sync path can scrub hallucinated * {@code /api/v1/files/generated/{id}} URLs (LLM wrote a UUID-shaped * link without ever calling a render tool). The graph's FinalAnswerNode @@ -977,6 +988,7 @@ public class ChannelMessageRouter { } case HELP -> ChannelMagicCommand.helpText(); case STATUS -> buildStatusReply(channelEntity, conversationId); + case MODEL -> handleModelCommand(channelEntity, conversationId, command.args()); }; if (replyTarget != null && reply != null) { // renderAndSend (not sendMessage) so adapters that pre-post a @@ -997,6 +1009,7 @@ public class ChannelMessageRouter { StringBuilder sb = new StringBuilder("📊 会话状态\n"); sb.append("- 会话: ").append(conversationId).append('\n'); Long agentId = channelEntity != null ? channelEntity.getAgentId() : null; + String[] pinned = findPinnedModel(conversationId); if (agentId == null) { sb.append("- 智能体: 未绑定\n"); } else { @@ -1004,7 +1017,13 @@ public class ChannelMessageRouter { AgentEntity agent = agentService.getAgent(agentId); if (agent != null) { sb.append("- 智能体: ").append(agent.getName()).append('\n'); - if (agent.getModelName() != null && !agent.getModelName().isBlank()) { + // Conversation-pinned model wins over the agent default — + // mirrors the resolution order in AgentService, so /status + // never contradicts what /model just switched to. + if (pinned != null) { + sb.append("- 模型: ").append(pinned[0]).append(':').append(pinned[1]) + .append("(会话指定)\n"); + } else if (agent.getModelName() != null && !agent.getModelName().isBlank()) { sb.append("- 模型: ").append(agent.getModelName()).append('\n'); } } else { @@ -1025,6 +1044,147 @@ public class ChannelMessageRouter { return sb.toString(); } + /** + * Handle the /model command: list enabled chat models, pin one on this + * conversation, or reset to the agent default. Listing and resetting work + * without a bound agent; switching requires one because the pinned pair + * only takes effect when the agent graph is built. + */ + private String handleModelCommand(ChannelEntity channelEntity, String conversationId, String args) { + if (modelConfigService == null) { + return "⚠️ 模型管理服务不可用,请稍后再试。"; + } + String arg = args == null ? "" : args.trim(); + if ("reset".equalsIgnoreCase(arg) || "恢复默认".equals(arg)) { + conversationService.clearConversationModel(conversationId); + return "✅ 已恢复默认模型(跟随智能体配置),下一条消息生效。"; + } + List models; + try { + models = modelConfigService.listEnabledModels(); + } catch (Exception e) { + log.warn("Failed to list models for /model on {}: {}", conversationId, e.getMessage()); + return "⚠️ 查询模型列表失败,请稍后再试。"; + } + if (arg.isEmpty() || "list".equalsIgnoreCase(arg)) { + return buildModelListReply(models, conversationId); + } + return switchConversationModel(channelEntity, conversationId, arg, models); + } + + /** Max rows shown by /model list — a full catalog can exceed 180 rows, + * which segments into several IM bubbles and buries the usage hint. */ + private static final int MODEL_LIST_MAX_ROWS = 20; + + private String buildModelListReply(List models, String conversationId) { + if (models.isEmpty()) { + return "当前没有已启用的对话模型,请先在控制台配置。"; + } + String[] pinned = findPinnedModel(conversationId); + StringBuilder sb = new StringBuilder("🧠 可用模型(/model <名称> 切换,/model reset 恢复默认):\n"); + int shown = 0; + for (ModelConfigEntity m : models) { + if (shown >= MODEL_LIST_MAX_ROWS) { + break; + } + sb.append("- ").append(m.getProvider()).append(':').append(m.getModelName()); + if (pinned != null && pinned[0].equalsIgnoreCase(String.valueOf(m.getProvider())) + && pinned[1].equalsIgnoreCase(String.valueOf(m.getModelName()))) { + sb.append(" ✅ 当前"); + } + sb.append('\n'); + shown++; + } + if (models.size() > MODEL_LIST_MAX_ROWS) { + sb.append("…共 ").append(models.size()) + .append(" 个已启用模型,仅展示前 ").append(MODEL_LIST_MAX_ROWS) + .append(" 个;发送 /model <关键词> 搜索其余模型。\n"); + } + sb.append(pinned == null + ? "当前:跟随智能体默认模型" + : "当前会话已指定:" + pinned[0] + ":" + pinned[1]); + return sb.toString(); + } + + private String switchConversationModel(ChannelEntity channelEntity, String conversationId, + String arg, List models) { + if (channelEntity == null || channelEntity.getAgentId() == null) { + return "⚠️ 当前渠道未绑定智能体,请先在控制台绑定后再切换模型。"; + } + String wantedProvider = null; + String wantedName = arg; + int colon = arg.indexOf(':'); + if (colon > 0 && colon < arg.length() - 1) { + wantedProvider = arg.substring(0, colon).trim(); + wantedName = arg.substring(colon + 1).trim(); + } + final String fProvider = wantedProvider; + final String fName = wantedName; + List matches = models.stream() + .filter(m -> fName.equalsIgnoreCase(m.getModelName())) + .filter(m -> fProvider == null || fProvider.equalsIgnoreCase(m.getProvider())) + .toList(); + if (matches.isEmpty()) { + // No exact hit — treat the arg as a search keyword so users can + // discover models the capped /model list didn't show. + String keyword = fName.toLowerCase(Locale.ROOT); + List fuzzy = models.stream() + .filter(m -> String.valueOf(m.getModelName()).toLowerCase(Locale.ROOT).contains(keyword) + || String.valueOf(m.getProvider()).toLowerCase(Locale.ROOT).contains(keyword)) + .limit(MODEL_LIST_MAX_ROWS) + .toList(); + if (fuzzy.isEmpty()) { + return "⚠️ 未找到已启用的模型「" + arg + "」,发送 /model 查看可用列表。"; + } + StringBuilder sb = new StringBuilder("未找到精确匹配「").append(arg) + .append("」,相近的可用模型:\n"); + for (ModelConfigEntity m : fuzzy) { + sb.append("- /model ").append(m.getProvider()).append(':') + .append(m.getModelName()).append('\n'); + } + return sb.toString().stripTrailing(); + } + if (matches.size() > 1) { + StringBuilder sb = new StringBuilder("⚠️ 模型「").append(fName) + .append("」在多个 provider 下存在,请带上前缀再试:\n"); + for (ModelConfigEntity m : matches) { + sb.append("- /model ").append(m.getProvider()).append(':').append(m.getModelName()).append('\n'); + } + return sb.toString().stripTrailing(); + } + ModelConfigEntity target = matches.get(0); + try { + // The magic-command layer runs before processMessage's + // get-or-create, so a /model sent as the very first message must + // create the conversation row itself — updateConversationModel + // silently no-ops on a missing row. + conversationService.getOrCreateSharedConversation( + conversationId, channelEntity.getAgentId(), channelEntity.getWorkspaceId()); + conversationService.updateConversationModel( + conversationId, target.getProvider(), target.getModelName()); + } catch (Exception e) { + log.warn("Failed to pin model {} on {}: {}", arg, conversationId, e.getMessage()); + return "⚠️ 切换失败,请稍后再试。"; + } + return "✅ 本会话模型已切换为 " + target.getProvider() + ":" + target.getModelName() + + ",下一条消息生效。发送 /model reset 可恢复默认。"; + } + + /** Conversation-pinned (provider, model) pair, or null when unpinned/unavailable. */ + private String[] findPinnedModel(String conversationId) { + try { + ConversationEntity conv = conversationService.findByConversationId(conversationId); + if (conv != null + && conv.getModelProvider() != null && !conv.getModelProvider().isBlank() + && conv.getModelName() != null && !conv.getModelName().isBlank()) { + return new String[]{conv.getModelProvider(), conv.getModelName()}; + } + } catch (Exception e) { + log.debug("Failed to load pinned model for {}: {}", conversationId, e.getMessage()); + } + return null; + } + private void cancelPending(String conversationId) { PendingMessage pending; synchronized (pendingMessages) { diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 26ec584b..dab5143a 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -757,6 +757,24 @@ public class ConversationService { conversationMapper.updateById(conv); } + /** + * Clear a conversation's pinned model so it falls back to the agent / + * global default. Counterpart of {@link #updateConversationModel}, which + * deliberately treats blank input as "no override supplied" — resetting + * therefore needs its own explicit entry point. The null-write goes + * through an update wrapper because {@code updateById} skips null fields. + */ + @Transactional + public void clearConversationModel(String conversationId) { + if (conversationId == null || conversationId.isBlank()) { + return; + } + conversationMapper.update(null, new LambdaUpdateWrapper() + .eq(ConversationEntity::getConversationId, conversationId) + .set(ConversationEntity::getModelProvider, null) + .set(ConversationEntity::getModelName, null)); + } + /** * Persist an assistant placeholder marker only when the last message is a * user turn (i.e., the assistant never got to reply). Used by the admin diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelMagicCommandTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelMagicCommandTest.java index e56c6408..a79b1c5c 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/ChannelMagicCommandTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelMagicCommandTest.java @@ -11,11 +11,16 @@ 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.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; import vip.mate.memory.event.ConversationCompletionPublisher; import vip.mate.tts.TtsService; import vip.mate.workspace.conversation.ConversationService; +import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -53,6 +58,21 @@ class ChannelMagicCommandTest { assertParsed("stop", ChannelMagicCommand.Type.STOP); } + @Test + @DisplayName("model command is slash-only: bare word stays ordinary prose") + void modelCommandIsSlashOnly() { + assertParsed("/model", ChannelMagicCommand.Type.MODEL); + assertParsed("/模型", ChannelMagicCommand.Type.MODEL); + Optional withArgs = ChannelMagicCommand.parse("/model qwen-max"); + assertTrue(withArgs.isPresent()); + assertEquals(ChannelMagicCommand.Type.MODEL, withArgs.get().type()); + assertEquals("qwen-max", withArgs.get().args()); + // "model"/"模型" are common standalone words — never commands bare. + assertNotParsed("model"); + assertNotParsed("模型"); + assertNotParsed("模型是什么"); + } + @Test @DisplayName("bare aliases with trailing text are ordinary prompts, not commands") void bareAliasesWithRemainderDoNotMatch() { @@ -80,7 +100,7 @@ class ChannelMagicCommandTest { @DisplayName("help text lists every registered command") void helpTextListsAllCommands() { String help = ChannelMagicCommand.helpText(); - for (String name : new String[]{"/clear", "/new", "/stop", "/status", "/help"}) { + for (String name : new String[]{"/clear", "/new", "/stop", "/status", "/model", "/help"}) { assertTrue(help.contains(name), "help text missing " + name); } } @@ -182,8 +202,133 @@ class ChannelMagicCommandTest { f.verifyAgentNeverCalled(); } + @Test + @DisplayName("model command lists enabled models with pin/reset usage") + void modelCommandListsModels() throws Exception { + Fixture f = new Fixture(); + when(f.modelConfigService.listEnabledModels()).thenReturn(List.of( + model("dashscope", "qwen-max"), model("anthropic", "claude-sonnet-5"))); + + f.process("/model"); + + verify(f.adapter).renderAndSend(eq("reply-1"), argThat(text -> + text.contains("qwen-max") && text.contains("claude-sonnet-5") + && text.contains("/model reset"))); + f.verifyAgentNeverCalled(); + } + + @Test + @DisplayName("model switch pins the matched model on the conversation (creating it first)") + void modelSwitchPinsConversationModel() throws Exception { + Fixture f = new Fixture(); + when(f.modelConfigService.listEnabledModels()).thenReturn(List.of( + model("dashscope", "qwen-max"), model("anthropic", "claude-sonnet-5"))); + + f.process("/model qwen-max"); + + // Magic commands run before processMessage's get-or-create, so the + // switch must ensure the row exists before pinning — otherwise a + // /model sent as the very first message is silently lost. + verify(f.conversationService).getOrCreateSharedConversation("wecom:alice", 100L, null); + verify(f.conversationService).updateConversationModel("wecom:alice", "dashscope", "qwen-max"); + verify(f.adapter).renderAndSend(eq("reply-1"), contains("已切换")); + f.verifyAgentNeverCalled(); + } + + @Test + @DisplayName("ambiguous bare model name asks for a provider prefix instead of guessing") + void modelSwitchAmbiguousAsksForPrefix() throws Exception { + Fixture f = new Fixture(); + when(f.modelConfigService.listEnabledModels()).thenReturn(List.of( + model("dashscope", "qwen-max"), model("mirror", "qwen-max"))); + + f.process("/model qwen-max"); + + verify(f.adapter).renderAndSend(eq("reply-1"), contains("多个 provider")); + verify(f.conversationService, never()).updateConversationModel(anyString(), anyString(), anyString()); + } + + @Test + @DisplayName("model list caps rows and points to keyword search") + void modelListCapsRows() throws Exception { + Fixture f = new Fixture(); + List many = new ArrayList<>(); + for (int i = 0; i < 25; i++) { + many.add(model("p" + i, "m" + i)); + } + when(f.modelConfigService.listEnabledModels()).thenReturn(many); + + f.process("/model"); + + // A 180+-row catalog would otherwise segment into several IM bubbles. + verify(f.adapter).renderAndSend(eq("reply-1"), argThat(text -> + text.contains("共 25 个") && !text.contains("- p24:m24"))); + } + + @Test + @DisplayName("no exact match but partial hits → fuzzy suggestions, no pin") + void modelSwitchFuzzySuggests() throws Exception { + Fixture f = new Fixture(); + when(f.modelConfigService.listEnabledModels()).thenReturn(List.of( + model("dashscope", "qwen-max"), model("dashscope", "qwen-plus"), + model("openai", "gpt-4o"))); + + f.process("/model qwen"); + + verify(f.adapter).renderAndSend(eq("reply-1"), argThat(text -> + text.contains("相近") && text.contains("qwen-max") + && text.contains("qwen-plus") && !text.contains("gpt-4o"))); + verify(f.conversationService, never()).updateConversationModel(anyString(), anyString(), anyString()); + } + + @Test + @DisplayName("model reset clears the conversation pin") + void modelResetClearsPin() throws Exception { + Fixture f = new Fixture(); + + f.process("/model reset"); + + verify(f.conversationService).clearConversationModel("wecom:alice"); + verify(f.adapter).renderAndSend(eq("reply-1"), contains("恢复默认")); + verify(f.conversationService, never()).updateConversationModel(anyString(), anyString(), anyString()); + } + + @Test + @DisplayName("unknown model name replies with a lookup hint, never pins") + void modelSwitchUnknownName() throws Exception { + Fixture f = new Fixture(); + when(f.modelConfigService.listEnabledModels()).thenReturn(List.of( + model("dashscope", "qwen-max"))); + + f.process("/model gpt-99"); + + verify(f.adapter).renderAndSend(eq("reply-1"), contains("未找到")); + verify(f.conversationService, never()).updateConversationModel(anyString(), anyString(), anyString()); + } + + @Test + @DisplayName("model switch without a bound agent replies binding hint") + void modelSwitchWithoutAgent() throws Exception { + Fixture f = new Fixture(); + f.channel.setAgentId(null); + when(f.modelConfigService.listEnabledModels()).thenReturn(List.of( + model("dashscope", "qwen-max"))); + + f.process("/model qwen-max"); + + verify(f.adapter).renderAndSend(eq("reply-1"), contains("未绑定")); + verify(f.conversationService, never()).updateConversationModel(anyString(), anyString(), anyString()); + } + // ==================== helpers ==================== + private static ModelConfigEntity model(String provider, String name) { + ModelConfigEntity m = new ModelConfigEntity(); + m.setProvider(provider); + m.setModelName(name); + return m; + } + private static void assertParsed(String text, ChannelMagicCommand.Type expected) { Optional parsed = ChannelMagicCommand.parse(text); assertTrue(parsed.isPresent(), "expected command match for: " + text); @@ -200,11 +345,12 @@ class ChannelMagicCommandTest { final AgentService agentService = mock(AgentService.class); final ConversationService conversationService = mock(ConversationService.class); final ChatStreamTracker streamTracker = mock(ChatStreamTracker.class); + final ModelConfigService modelConfigService = mock(ModelConfigService.class); final ChannelAdapter adapter = mock(ChannelAdapter.class); final ChannelEntity channel = new ChannelEntity(); final ChannelMessageRouter router; - Fixture() { + Fixture() throws Exception { ChannelService channelService = mock(ChannelService.class); ChannelSessionStore channelSessionStore = mock(ChannelSessionStore.class); ApprovalWorkflowService approvalService = mock(ApprovalWorkflowService.class); @@ -219,6 +365,11 @@ class ChannelMagicCommandTest { chatOriginFactory, errorClassifier); when(adapter.getChannelType()).thenReturn("wecom"); channel.setAgentId(100L); + // modelConfigService is field-injected on the real router (optional + // dep); mirror that wiring here via reflection. + Field mcs = ChannelMessageRouter.class.getDeclaredField("modelConfigService"); + mcs.setAccessible(true); + mcs.set(router, modelConfigService); } void process(String content) throws Exception {