mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
feat(channel): /model magic command for per-conversation model switching (restore)
This commit is contained in:
parent
49f7a74386
commit
e2747df2c5
@ -21,7 +21,7 @@ import java.util.Optional;
|
|||||||
final class ChannelMagicCommand {
|
final class ChannelMagicCommand {
|
||||||
|
|
||||||
/** Platform-level command kinds, dispatched by {@link ChannelMessageRouter}. */
|
/** 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. */
|
/** A recognized command plus its raw (possibly empty) argument string. */
|
||||||
record Parsed(Type type, String args) {
|
record Parsed(Type type, String args) {
|
||||||
@ -85,6 +85,7 @@ final class ChannelMagicCommand {
|
|||||||
/new — 开启新会话(别名:新会话)
|
/new — 开启新会话(别名:新会话)
|
||||||
/stop — 停止当前进行中的任务(别名:停止)
|
/stop — 停止当前进行中的任务(别名:停止)
|
||||||
/status — 查看当前会话状态(别名:状态)
|
/status — 查看当前会话状态(别名:状态)
|
||||||
|
/model — 查看可用模型;/model <名称> 切换本会话模型;/model reset 恢复默认
|
||||||
/help — 显示本帮助(别名:帮助)""";
|
/help — 显示本帮助(别名:帮助)""";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,6 +102,10 @@ final class ChannelMagicCommand {
|
|||||||
"status", "状态");
|
"status", "状态");
|
||||||
register(aliases, Type.STOP,
|
register(aliases, Type.STOP,
|
||||||
"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;
|
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<String, Type> aliases, Type type, String... names) {
|
||||||
|
for (String name : names) {
|
||||||
|
aliases.put("/" + name, type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static int indexOfWhitespace(String text) {
|
private static int indexOfWhitespace(String text) {
|
||||||
for (int i = 0; i < text.length(); i++) {
|
for (int i = 0; i < text.length(); i++) {
|
||||||
if (Character.isWhitespace(text.charAt(i))) {
|
if (Character.isWhitespace(text.charAt(i))) {
|
||||||
|
|||||||
@ -18,9 +18,12 @@ import vip.mate.channel.service.ChannelService;
|
|||||||
import vip.mate.channel.web.AgentStreamAccumulator;
|
import vip.mate.channel.web.AgentStreamAccumulator;
|
||||||
import vip.mate.channel.web.ChatStreamTracker;
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
import vip.mate.exception.MateClawException;
|
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.memory.event.ConversationCompletionPublisher;
|
||||||
import vip.mate.tts.TtsService;
|
import vip.mate.tts.TtsService;
|
||||||
import vip.mate.workspace.conversation.ConversationService;
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
|
import vip.mate.workspace.conversation.model.ConversationEntity;
|
||||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||||
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
|
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
|
||||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||||
@ -34,6 +37,7 @@ import java.nio.file.Paths;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.*;
|
import java.util.concurrent.*;
|
||||||
@ -79,6 +83,13 @@ public class ChannelMessageRouter {
|
|||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
|
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
|
/** Field-injected so the IM sync path can scrub hallucinated
|
||||||
* {@code /api/v1/files/generated/{id}} URLs (LLM wrote a UUID-shaped
|
* {@code /api/v1/files/generated/{id}} URLs (LLM wrote a UUID-shaped
|
||||||
* link without ever calling a render tool). The graph's FinalAnswerNode
|
* link without ever calling a render tool). The graph's FinalAnswerNode
|
||||||
@ -977,6 +988,7 @@ public class ChannelMessageRouter {
|
|||||||
}
|
}
|
||||||
case HELP -> ChannelMagicCommand.helpText();
|
case HELP -> ChannelMagicCommand.helpText();
|
||||||
case STATUS -> buildStatusReply(channelEntity, conversationId);
|
case STATUS -> buildStatusReply(channelEntity, conversationId);
|
||||||
|
case MODEL -> handleModelCommand(channelEntity, conversationId, command.args());
|
||||||
};
|
};
|
||||||
if (replyTarget != null && reply != null) {
|
if (replyTarget != null && reply != null) {
|
||||||
// renderAndSend (not sendMessage) so adapters that pre-post a
|
// renderAndSend (not sendMessage) so adapters that pre-post a
|
||||||
@ -997,6 +1009,7 @@ public class ChannelMessageRouter {
|
|||||||
StringBuilder sb = new StringBuilder("📊 会话状态\n");
|
StringBuilder sb = new StringBuilder("📊 会话状态\n");
|
||||||
sb.append("- 会话: ").append(conversationId).append('\n');
|
sb.append("- 会话: ").append(conversationId).append('\n');
|
||||||
Long agentId = channelEntity != null ? channelEntity.getAgentId() : null;
|
Long agentId = channelEntity != null ? channelEntity.getAgentId() : null;
|
||||||
|
String[] pinned = findPinnedModel(conversationId);
|
||||||
if (agentId == null) {
|
if (agentId == null) {
|
||||||
sb.append("- 智能体: 未绑定\n");
|
sb.append("- 智能体: 未绑定\n");
|
||||||
} else {
|
} else {
|
||||||
@ -1004,7 +1017,13 @@ public class ChannelMessageRouter {
|
|||||||
AgentEntity agent = agentService.getAgent(agentId);
|
AgentEntity agent = agentService.getAgent(agentId);
|
||||||
if (agent != null) {
|
if (agent != null) {
|
||||||
sb.append("- 智能体: ").append(agent.getName()).append('\n');
|
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');
|
sb.append("- 模型: ").append(agent.getModelName()).append('\n');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -1025,6 +1044,147 @@ public class ChannelMessageRouter {
|
|||||||
return sb.toString();
|
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<ModelConfigEntity> 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<ModelConfigEntity> 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<ModelConfigEntity> 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<ModelConfigEntity> 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<ModelConfigEntity> 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) {
|
private void cancelPending(String conversationId) {
|
||||||
PendingMessage pending;
|
PendingMessage pending;
|
||||||
synchronized (pendingMessages) {
|
synchronized (pendingMessages) {
|
||||||
|
|||||||
@ -757,6 +757,24 @@ public class ConversationService {
|
|||||||
conversationMapper.updateById(conv);
|
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<ConversationEntity>()
|
||||||
|
.eq(ConversationEntity::getConversationId, conversationId)
|
||||||
|
.set(ConversationEntity::getModelProvider, null)
|
||||||
|
.set(ConversationEntity::getModelName, null));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist an assistant placeholder marker only when the last message is a
|
* 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
|
* user turn (i.e., the assistant never got to reply). Used by the admin
|
||||||
|
|||||||
@ -11,11 +11,16 @@ import vip.mate.channel.model.ChannelEntity;
|
|||||||
import vip.mate.channel.notification.ApprovalNotificationService;
|
import vip.mate.channel.notification.ApprovalNotificationService;
|
||||||
import vip.mate.channel.service.ChannelService;
|
import vip.mate.channel.service.ChannelService;
|
||||||
import vip.mate.channel.web.ChatStreamTracker;
|
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.memory.event.ConversationCompletionPublisher;
|
||||||
import vip.mate.tts.TtsService;
|
import vip.mate.tts.TtsService;
|
||||||
import vip.mate.workspace.conversation.ConversationService;
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
@ -53,6 +58,21 @@ class ChannelMagicCommandTest {
|
|||||||
assertParsed("stop", ChannelMagicCommand.Type.STOP);
|
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<ChannelMagicCommand.Parsed> 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
|
@Test
|
||||||
@DisplayName("bare aliases with trailing text are ordinary prompts, not commands")
|
@DisplayName("bare aliases with trailing text are ordinary prompts, not commands")
|
||||||
void bareAliasesWithRemainderDoNotMatch() {
|
void bareAliasesWithRemainderDoNotMatch() {
|
||||||
@ -80,7 +100,7 @@ class ChannelMagicCommandTest {
|
|||||||
@DisplayName("help text lists every registered command")
|
@DisplayName("help text lists every registered command")
|
||||||
void helpTextListsAllCommands() {
|
void helpTextListsAllCommands() {
|
||||||
String help = ChannelMagicCommand.helpText();
|
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);
|
assertTrue(help.contains(name), "help text missing " + name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -182,8 +202,133 @@ class ChannelMagicCommandTest {
|
|||||||
f.verifyAgentNeverCalled();
|
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<ModelConfigEntity> 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 ====================
|
// ==================== 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) {
|
private static void assertParsed(String text, ChannelMagicCommand.Type expected) {
|
||||||
Optional<ChannelMagicCommand.Parsed> parsed = ChannelMagicCommand.parse(text);
|
Optional<ChannelMagicCommand.Parsed> parsed = ChannelMagicCommand.parse(text);
|
||||||
assertTrue(parsed.isPresent(), "expected command match for: " + text);
|
assertTrue(parsed.isPresent(), "expected command match for: " + text);
|
||||||
@ -200,11 +345,12 @@ class ChannelMagicCommandTest {
|
|||||||
final AgentService agentService = mock(AgentService.class);
|
final AgentService agentService = mock(AgentService.class);
|
||||||
final ConversationService conversationService = mock(ConversationService.class);
|
final ConversationService conversationService = mock(ConversationService.class);
|
||||||
final ChatStreamTracker streamTracker = mock(ChatStreamTracker.class);
|
final ChatStreamTracker streamTracker = mock(ChatStreamTracker.class);
|
||||||
|
final ModelConfigService modelConfigService = mock(ModelConfigService.class);
|
||||||
final ChannelAdapter adapter = mock(ChannelAdapter.class);
|
final ChannelAdapter adapter = mock(ChannelAdapter.class);
|
||||||
final ChannelEntity channel = new ChannelEntity();
|
final ChannelEntity channel = new ChannelEntity();
|
||||||
final ChannelMessageRouter router;
|
final ChannelMessageRouter router;
|
||||||
|
|
||||||
Fixture() {
|
Fixture() throws Exception {
|
||||||
ChannelService channelService = mock(ChannelService.class);
|
ChannelService channelService = mock(ChannelService.class);
|
||||||
ChannelSessionStore channelSessionStore = mock(ChannelSessionStore.class);
|
ChannelSessionStore channelSessionStore = mock(ChannelSessionStore.class);
|
||||||
ApprovalWorkflowService approvalService = mock(ApprovalWorkflowService.class);
|
ApprovalWorkflowService approvalService = mock(ApprovalWorkflowService.class);
|
||||||
@ -219,6 +365,11 @@ class ChannelMagicCommandTest {
|
|||||||
chatOriginFactory, errorClassifier);
|
chatOriginFactory, errorClassifier);
|
||||||
when(adapter.getChannelType()).thenReturn("wecom");
|
when(adapter.getChannelType()).thenReturn("wecom");
|
||||||
channel.setAgentId(100L);
|
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 {
|
void process(String content) throws Exception {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user