feat(channel): /model magic command for per-conversation model switching (restore)

This commit is contained in:
matevip 2026-07-23 11:49:28 +08:00
parent 49f7a74386
commit e2747df2c5
4 changed files with 345 additions and 4 deletions

View File

@ -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))) {

View File

@ -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) {

View File

@ -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

View File

@ -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 {