mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat(channel): /model magic command for per-conversation model switching
This commit is contained in:
parent
8f2d76965b
commit
a4ee953d31
@ -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<String, Type> 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))) {
|
||||
|
||||
@ -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<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) {
|
||||
PendingMessage pending;
|
||||
synchronized (pendingMessages) {
|
||||
|
||||
@ -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<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
|
||||
* 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.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<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
|
||||
@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<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 ====================
|
||||
|
||||
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<ChannelMagicCommand.Parsed> 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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user