diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java
index 84ca3221..cd92cf83 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java
@@ -497,9 +497,15 @@ public class AgentService {
/**
* Resolve (and cache) the Agent graph for a conversation, honouring the
- * conversation's pinned model. Conversations with no pin — IM channels,
- * cron, sub-tasks, or rows not yet created — resolve to the shared Agent /
- * global-default graph.
+ * conversation's pinned model. Conversations with no pin — IM channels
+ * before issue #183 fix, cron, sub-tasks, or rows not yet created —
+ * resolve to the shared Agent / global-default graph.
+ *
+ *
Defensive normalisation: a half-populated pair (provider but no
+ * model, or vice versa) is treated as unpinned. Without this guard, a
+ * partially-cleared admin UI write could end up cached as a key like
+ * {@code "volcano::"} which {@link #getOrBuildAgent} would then try to
+ * build, only to fail at provider-resolution time on every turn.
*/
private BaseAgent getOrBuildAgentForConversation(Long agentId, String conversationId) {
String provider = null;
@@ -509,13 +515,26 @@ public class AgentService {
new LambdaQueryWrapper()
.eq(ConversationEntity::getConversationId, conversationId));
if (conv != null) {
- provider = conv.getModelProvider();
- modelName = conv.getModelName();
+ provider = blankToNull(conv.getModelProvider());
+ modelName = blankToNull(conv.getModelName());
+ // Half-populated pair → treat as unpinned. Pinning requires
+ // a complete (provider, model) tuple — see #183 follow-up
+ // hardening so a stale row written by an earlier broken
+ // admin UI release doesn't loop the cache on an invalid key.
+ if (provider == null || modelName == null) {
+ provider = null;
+ modelName = null;
+ }
}
}
return getOrBuildAgent(agentId, provider, modelName);
}
+ /** Map empty / whitespace strings to null so the pinned-check is one branch. */
+ private static String blankToNull(String s) {
+ return (s == null || s.isBlank()) ? null : s;
+ }
+
private BaseAgent getOrBuildAgent(Long agentId) {
return getOrBuildAgent(agentId, null, null);
}
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java
index 68cbd465..d010c74d 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java
@@ -118,6 +118,14 @@ public class ChannelManager {
*/
private final vip.mate.channel.feishu.FeishuClientFactory feishuClientFactory;
+ /**
+ * Speech-to-text service used by the Feishu adapter to transcribe
+ * inbound voice messages. WeCom and DingTalk get ASR text directly
+ * from their webhooks; Feishu does not, so the adapter has to call
+ * STT itself before the agent can reason about the message.
+ */
+ private final vip.mate.stt.SttService sttService;
+
/**
* Distributed leader election. Channels whose adapter reports
* {@link ChannelAdapter#requiresSingleLeader()} are gated on a lease so
@@ -1185,7 +1193,7 @@ public class ChannelManager {
case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache);
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper,
feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager,
- feishuCardDispatcher, feishuClientFactory, generatedFileCache);
+ feishuCardDispatcher, feishuClientFactory, generatedFileCache, sttService);
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper,
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 d3211337..95295366 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java
@@ -7,6 +7,7 @@ import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import vip.mate.agent.AgentService;
import vip.mate.agent.context.ChatOrigin;
+import vip.mate.agent.model.AgentEntity;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.approval.ResolveOutcome;
import vip.mate.approval.PendingApproval;
@@ -584,8 +585,40 @@ public class ChannelMessageRouter {
}
// ======= 审批拦截层结束 =======
- // 确保会话存在(workspace 感知)
- conversationService.getOrCreateSharedConversation(conversationId, agentId, channelEntity.getWorkspaceId());
+ // Ensure the conversation exists, seeded with the agent's
+ // currently-configured default model so per-conversation model
+ // selection works for IM channels too (issue #183).
+ //
+ // Two-part behaviour, both inside getOrCreateSharedConversation:
+ // 1. Brand-new conversation → write defaultModelName so the
+ // very first turn picks the right model; user can later
+ // switch via the admin UI (updateConversationModel) and the
+ // override sticks.
+ // 2. Pre-existing conversation with model still null (legacy
+ // rows created before #183 fix) → backfill once, then leave
+ // alone. Already-pinned conversations are never overwritten.
+ //
+ // We pass provider=null because AgentEntity doesn't carry a
+ // provider field — the downstream ProviderChatModelFactory
+ // resolves provider from the model name. The seed logic in
+ // ConversationService treats (null, name) as no-seed (both
+ // fields must be non-blank to take effect), which is the
+ // correct defensive behaviour: we only pin when we have a
+ // complete (provider, model) pair from the admin UI.
+ String agentDefaultModel = null;
+ try {
+ AgentEntity agentEntity = agentService.getAgent(agentId);
+ agentDefaultModel = agentEntity.getModelName();
+ } catch (Exception e) {
+ // Agent deleted / disabled mid-flight — don't block message
+ // intake. Downstream agentService.chatStructuredStream will
+ // surface the real error to the user.
+ log.debug("[{}] Could not load agent {} for model-seed lookup: {}",
+ adapter.getChannelType(), agentId, e.getMessage());
+ }
+ conversationService.getOrCreateSharedConversation(
+ conversationId, agentId, channelEntity.getWorkspaceId(),
+ null, agentDefaultModel);
// 更新渠道会话存储(用于主动推送)
String replyTarget = resolveReplyTarget(message);
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java
index d0f3ab87..9d55ce25 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java
@@ -155,10 +155,25 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
*/
private final vip.mate.tool.document.GeneratedFileCache generatedFileCache;
+ /**
+ * STT service for transcribing inbound voice messages. Feishu, unlike
+ * WeCom / DingTalk, does NOT include ASR text in the webhook payload —
+ * its inbound audio carries only a {@code file_key}. So we download the
+ * bytes (via {@link #downloadResource}) and run them through
+ * {@link vip.mate.stt.SttService} here, prepending the transcript as a
+ * text {@link MessageContentPart} so the agent reasons over actual
+ * content instead of the bare {@code "[音频]"} placeholder.
+ *
+ *
Nullable for legacy callers / tests — STT is skipped entirely when
+ * absent, and the message still goes through as audio-only (degraded
+ * but not broken).
+ */
+ private final vip.mate.stt.SttService sttService;
+
public FeishuChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
- this(channelEntity, messageRouter, objectMapper, null, null, null, null, null, null);
+ this(channelEntity, messageRouter, objectMapper, null, null, null, null, null, null, null);
}
public FeishuChannelAdapter(ChannelEntity channelEntity,
@@ -167,7 +182,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
FeishuMediaUploader mediaUploader,
GeneratedFileScrubber generatedFileScrubber) {
this(channelEntity, messageRouter, objectMapper, mediaUploader, generatedFileScrubber,
- null, null, null, null);
+ null, null, null, null, null);
}
public FeishuChannelAdapter(ChannelEntity channelEntity,
@@ -177,7 +192,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
GeneratedFileScrubber generatedFileScrubber,
FeishuStreamingCardManager streamingCardManager) {
this(channelEntity, messageRouter, objectMapper, mediaUploader,
- generatedFileScrubber, streamingCardManager, null, null, null);
+ generatedFileScrubber, streamingCardManager, null, null, null, null);
}
public FeishuChannelAdapter(ChannelEntity channelEntity,
@@ -188,7 +203,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
FeishuStreamingCardManager streamingCardManager,
vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher) {
this(channelEntity, messageRouter, objectMapper, mediaUploader,
- generatedFileScrubber, streamingCardManager, cardDispatcher, null, null);
+ generatedFileScrubber, streamingCardManager, cardDispatcher, null, null, null);
}
public FeishuChannelAdapter(ChannelEntity channelEntity,
@@ -200,6 +215,21 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher,
FeishuClientFactory clientFactory,
vip.mate.tool.document.GeneratedFileCache generatedFileCache) {
+ this(channelEntity, messageRouter, objectMapper, mediaUploader,
+ generatedFileScrubber, streamingCardManager, cardDispatcher,
+ clientFactory, generatedFileCache, null);
+ }
+
+ public FeishuChannelAdapter(ChannelEntity channelEntity,
+ ChannelMessageRouter messageRouter,
+ ObjectMapper objectMapper,
+ FeishuMediaUploader mediaUploader,
+ GeneratedFileScrubber generatedFileScrubber,
+ FeishuStreamingCardManager streamingCardManager,
+ vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher,
+ FeishuClientFactory clientFactory,
+ vip.mate.tool.document.GeneratedFileCache generatedFileCache,
+ vip.mate.stt.SttService sttService) {
super(channelEntity, messageRouter, objectMapper);
this.mediaUploader = mediaUploader;
this.generatedFileScrubber = generatedFileScrubber;
@@ -207,6 +237,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
this.cardDispatcher = cardDispatcher;
this.clientFactory = clientFactory;
this.generatedFileCache = generatedFileCache;
+ this.sttService = sttService;
// Feishu WebSocket reconnect: 2s→4s→8s→16s→30s, infinite retry
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1);
}
@@ -1331,9 +1362,21 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
case "audio" -> {
String fileKey = (String) contentObj.get("file_key");
if (fileKey != null) {
- DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", null);
+ // Feishu voice messages are always opus (no extension on the
+ // wire) — give downloadResource the hint so the on-disk file
+ // ends in .opus and SttService gets a usable MIME for routing.
+ DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", "voice.opus");
MessageContentPart part = MessageContentPart.audio(fileKey, null);
applyDownload(part, dl);
+ // STT hop: inject the transcript as a sibling text part BEFORE
+ // the audio part so ChannelMessageRouter.buildPromptFromParts
+ // sees real content instead of just "[音频]". WeCom / DingTalk
+ // skip this step because their webhooks already include ASR
+ // text; Feishu does not.
+ String transcript = transcribeInboundAudio(dl);
+ if (transcript != null && !transcript.isBlank()) {
+ parts.add(MessageContentPart.text(transcript));
+ }
parts.add(part);
}
yield "[音频]";
@@ -1569,6 +1612,52 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
* caller can keep the part as a bare {@code file_key} placeholder
* when download was disabled / failed.
*/
+ /**
+ * Read the downloaded audio bytes from disk and run them through
+ * {@link vip.mate.stt.SttService}. Returns the transcribed text, or
+ * {@code null} when STT is not wired, disabled, the download didn't
+ * produce a usable path, or every provider failed.
+ *
+ *
Best-effort by design — STT failure must not block the agent
+ * from seeing the audio part. The user gets the "[音频]" placeholder
+ * and any agent that's tooled-up can still investigate.
+ */
+ // Package-private for unit tests.
+ String transcribeInboundAudio(DownloadedResource dl) {
+ if (sttService == null || dl == null || dl.path() == null) {
+ return null;
+ }
+ try {
+ byte[] audioBytes = Files.readAllBytes(Path.of(dl.path()));
+ if (audioBytes.length == 0) {
+ log.debug("[feishu-stt] empty audio file at {}, skipping STT", dl.path());
+ return null;
+ }
+ String fileName = dl.fileName() != null ? dl.fileName() : "voice.opus";
+ String contentType = dl.contentType() != null ? dl.contentType() : "audio/opus";
+ // language=null → SttService falls back to the system-settings
+ // UI language hint; works for both Chinese and English without
+ // needing per-channel config.
+ Map result = sttService.transcribe(
+ audioBytes, fileName, contentType, null);
+ if (!Boolean.TRUE.equals(result.get("success"))) {
+ log.warn("[feishu-stt] transcription failed: {}", result.get("error"));
+ return null;
+ }
+ String text = (String) result.get("text");
+ if (text == null || text.isBlank()) {
+ log.debug("[feishu-stt] transcription returned empty text");
+ return null;
+ }
+ log.info("[feishu-stt] transcribed {} bytes → {} chars",
+ audioBytes.length, text.length());
+ return text;
+ } catch (Exception e) {
+ log.warn("[feishu-stt] transcription threw: {}", e.getMessage());
+ return null;
+ }
+ }
+
// Package-private for unit tests.
static void applyDownload(MessageContentPart part, DownloadedResource dl) {
if (dl == null || part == null) return;
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 30eb1c58..4fc8106a 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
@@ -189,12 +189,43 @@ public class ConversationService {
}
/**
- * 获取或创建共享渠道会话(workspace 感知)
+ * 获取或创建共享渠道会话(workspace 感知)。
+ *
+ *
Delegates to the 5-arg overload with {@code null} model defaults —
+ * preserves the legacy behavior for any caller that doesn't have an
+ * agent-level model to inherit from.
*/
@Transactional
public ConversationEntity getOrCreateSharedConversation(String conversationId, Long agentId, Long workspaceId) {
+ return getOrCreateSharedConversation(conversationId, agentId, workspaceId, null, null);
+ }
+
+ /**
+ * Get-or-create variant that seeds the conversation's pinned model from
+ * an agent-level default. Used by the IM channel path
+ * ({@code ChannelMessageRouter}) so that new IM conversations inherit
+ * the agent's currently-configured model as a baseline.
+ *
+ *
Idempotent on the model fields: the {@code defaultModelProvider}
+ * / {@code defaultModelName} are written only when the conversation
+ * is freshly inserted. For an existing conversation — including one the
+ * user already pinned to a different model via the admin UI — the model
+ * fields are left untouched. This is the core fix for issue #183: the
+ * IM channel call site supplies the agent default, but a user-pinned
+ * model wins on every subsequent message.
+ *
+ *
Both defaults must be non-blank to take effect. A half-populated
+ * pair (provider without name, or vice versa) is treated as no seed —
+ * matches {@link #updateConversationModel} so a malformed agent row
+ * doesn't pin an unusable model.
+ */
+ @Transactional
+ public ConversationEntity getOrCreateSharedConversation(String conversationId, Long agentId, Long workspaceId,
+ String defaultModelProvider, String defaultModelName) {
ConversationEntity conv = conversationMapper.selectOne(new LambdaQueryWrapper()
.eq(ConversationEntity::getConversationId, conversationId));
+ boolean seedModel = defaultModelProvider != null && !defaultModelProvider.isBlank()
+ && defaultModelName != null && !defaultModelName.isBlank();
if (conv == null) {
conv = new ConversationEntity();
conv.setConversationId(conversationId);
@@ -204,6 +235,13 @@ public class ConversationService {
conv.setTitle("新对话");
conv.setMessageCount(0);
conv.setLastActiveTime(LocalDateTime.now());
+ // Seed the agent-default model so the very first turn picks the
+ // right provider. Subsequent admin-UI switches go through
+ // updateConversationModel and override this baseline.
+ if (seedModel) {
+ conv.setModelProvider(defaultModelProvider);
+ conv.setModelName(defaultModelName);
+ }
try {
conversationMapper.insert(conv);
} catch (org.springframework.dao.DuplicateKeyException e) {
@@ -226,6 +264,19 @@ public class ConversationService {
conv.setAgentId(agentId);
changed = true;
}
+ // Backfill model on an already-existing conversation only when BOTH
+ // model fields are still null. Pinning is sticky once set: if the
+ // user (or an earlier turn) wrote either column, we don't touch it.
+ // This handles legacy IM conversations created before this fix
+ // landed — they get the agent default on next inbound message and
+ // remain pinned thereafter.
+ if (seedModel
+ && (conv.getModelProvider() == null || conv.getModelProvider().isBlank())
+ && (conv.getModelName() == null || conv.getModelName().isBlank())) {
+ conv.setModelProvider(defaultModelProvider);
+ conv.setModelName(defaultModelName);
+ changed = true;
+ }
if (changed) {
conversationMapper.updateById(conv);
}
diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java
index 9f24519a..87373543 100644
--- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java
+++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java
@@ -141,6 +141,37 @@ public class ConversationController {
return R.ok();
}
+ /**
+ * Pin a conversation to a specific (provider, model) pair so subsequent
+ * messages — including those from IM channels (Feishu / DingTalk / WeCom
+ * / Telegram / Discord / QQ / Slack / WeChat) — use that model instead
+ * of falling back to the agent or global default. Closes issue #183
+ * where IM conversations could never be steered away from the agent's
+ * configured default via the admin UI.
+ *
+ *
Both fields must be non-blank to take effect — a half-populated
+ * payload is silently ignored at the service layer (see
+ * {@link ConversationService#updateConversationModel}). Passing
+ * existing matching values is a no-op (no DB write).
+ */
+ @Operation(summary = "切换会话使用的模型 (provider + model name)")
+ @PutMapping("/{conversationId}/model")
+ public R setModel(@PathVariable String conversationId,
+ @RequestBody Map body,
+ Authentication auth) {
+ String username = auth != null ? auth.getName() : "anonymous";
+ if (!conversationService.isConversationOwner(conversationId, username)) {
+ return R.fail(403, "无权操作该会话");
+ }
+ String provider = body.get("modelProvider");
+ String modelName = body.get("modelName");
+ if (provider == null || provider.isBlank() || modelName == null || modelName.isBlank()) {
+ return R.fail("modelProvider 和 modelName 都必须提供");
+ }
+ conversationService.updateConversationModel(conversationId, provider.trim(), modelName.trim());
+ return R.ok();
+ }
+
/**
* 批量删除会话(仅删除当前用户有权操作的会话)
*/
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java
index 040af111..ab929f22 100644
--- a/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java
@@ -61,6 +61,7 @@ class ChannelManagerReconcileTest {
mock(vip.mate.channel.feishu.FeishuStreamingCardManager.class),
mock(vip.mate.channel.feishu.cards.FeishuCardDispatcher.class),
mock(vip.mate.channel.feishu.FeishuClientFactory.class),
+ mock(vip.mate.stt.SttService.class),
election);
adapter = new TrackingAdapter();
}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuAudioSttTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuAudioSttTest.java
new file mode 100644
index 00000000..177c79db
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuAudioSttTest.java
@@ -0,0 +1,209 @@
+package vip.mate.channel.feishu;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import vip.mate.channel.ChannelMessageRouter;
+import vip.mate.channel.model.ChannelEntity;
+import vip.mate.stt.SttService;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Pin the Feishu inbound-audio STT contract.
+ *
+ *
Why this matters: Feishu — unlike WeCom and DingTalk — does NOT
+ * include ASR text in its inbound webhook payload, only an opaque
+ * {@code file_key}. Without the {@code transcribeInboundAudio} hop the
+ * agent sees the literal text {@code "[音频]"} and cannot reason about
+ * what the user said. That was the production gap reported when a user
+ * sent a voice message to the bot.
+ *
+ *
The contract this test pins:
+ *
+ *
Successful STT returns the transcript, which the audio branch
+ * of {@code extractContentParts} prepends as a text part so the
+ * prompt builder sees real content.
+ *
STT failure (no provider, empty text, exception) returns
+ * {@code null} — never throws, never blocks the agent from
+ * seeing the audio part.
+ *
STT not wired in (legacy 3-arg ctor, tests) returns
+ * {@code null} silently — degraded but not broken.
+ *
Empty / missing audio file is detected before the SttService
+ * call so we don't bill providers for zero-byte requests.
+ *
+ */
+class FeishuAudioSttTest {
+
+ @TempDir
+ Path tmpDir;
+
+ private SttService sttService;
+
+ @BeforeEach
+ void setUp() {
+ sttService = mock(SttService.class);
+ }
+
+ @Test
+ @DisplayName("STT success → transcript returned for prepending as text part")
+ void transcriptReturnedOnSuccess() throws Exception {
+ Path audioFile = tmpDir.resolve("voice.opus");
+ Files.write(audioFile, "fake-opus-bytes".getBytes());
+
+ when(sttService.transcribe(any(), eq("voice.opus"), eq("audio/opus"), eq(null)))
+ .thenReturn(Map.of("success", true, "text", "你好,能帮我查一下天气吗"));
+
+ FeishuChannelAdapter adapter = adapterWithStt(sttService);
+ FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource(
+ audioFile.toAbsolutePath().toString(),
+ "/api/v1/files/generated/some-id",
+ "voice.opus",
+ "audio/opus");
+
+ String transcript = adapter.transcribeInboundAudio(dl);
+ assertEquals("你好,能帮我查一下天气吗", transcript);
+ }
+
+ @Test
+ @DisplayName("STT failure → null, agent still sees audio part (no throw)")
+ void nullOnSttFailure() throws Exception {
+ Path audioFile = tmpDir.resolve("voice.opus");
+ Files.write(audioFile, "fake-opus-bytes".getBytes());
+
+ when(sttService.transcribe(any(), any(), any(), any()))
+ .thenReturn(Map.of("success", false, "error", "no provider"));
+
+ FeishuChannelAdapter adapter = adapterWithStt(sttService);
+ FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource(
+ audioFile.toAbsolutePath().toString(),
+ null, "voice.opus", "audio/opus");
+
+ assertNull(adapter.transcribeInboundAudio(dl),
+ "STT failure must return null, not throw — agent gets [音频] placeholder only");
+ }
+
+ @Test
+ @DisplayName("empty transcript text → null (don't inject blank text parts)")
+ void nullOnEmptyTranscript() throws Exception {
+ Path audioFile = tmpDir.resolve("voice.opus");
+ Files.write(audioFile, "fake-opus-bytes".getBytes());
+
+ // Some STT providers return success=true with empty text for silence
+ // or unsupported audio — those shouldn't pollute the prompt with a
+ // blank text part.
+ when(sttService.transcribe(any(), any(), any(), any()))
+ .thenReturn(Map.of("success", true, "text", " "));
+
+ FeishuChannelAdapter adapter = adapterWithStt(sttService);
+ FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource(
+ audioFile.toAbsolutePath().toString(),
+ null, "voice.opus", "audio/opus");
+
+ assertNull(adapter.transcribeInboundAudio(dl));
+ }
+
+ @Test
+ @DisplayName("SttService missing (legacy ctor) → null, no NPE")
+ void nullWhenSttServiceMissing() throws Exception {
+ Path audioFile = tmpDir.resolve("voice.opus");
+ Files.write(audioFile, "fake-opus-bytes".getBytes());
+
+ FeishuChannelAdapter adapter = adapterWithStt(null);
+ FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource(
+ audioFile.toAbsolutePath().toString(),
+ null, "voice.opus", "audio/opus");
+
+ assertNull(adapter.transcribeInboundAudio(dl));
+ }
+
+ @Test
+ @DisplayName("null DownloadedResource → null, STT not called (download was disabled / failed)")
+ void nullOnMissingDownload() {
+ FeishuChannelAdapter adapter = adapterWithStt(sttService);
+
+ assertNull(adapter.transcribeInboundAudio(null));
+ // Verify we never billed the provider for a no-op.
+ verify(sttService, never()).transcribe(any(), any(), any(), any());
+ }
+
+ @Test
+ @DisplayName("empty audio file → null, no STT call (don't bill provider for 0 bytes)")
+ void nullOnEmptyAudioFile() throws Exception {
+ Path emptyFile = tmpDir.resolve("empty.opus");
+ Files.write(emptyFile, new byte[0]);
+
+ FeishuChannelAdapter adapter = adapterWithStt(sttService);
+ FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource(
+ emptyFile.toAbsolutePath().toString(),
+ null, "voice.opus", "audio/opus");
+
+ assertNull(adapter.transcribeInboundAudio(dl));
+ verify(sttService, never()).transcribe(any(), any(), any(), any());
+ }
+
+ @Test
+ @DisplayName("missing file path → null, STT not called (download flag was off)")
+ void nullOnMissingPath() {
+ FeishuChannelAdapter adapter = adapterWithStt(sttService);
+ FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource(
+ null, "/api/v1/files/generated/x", "voice.opus", "audio/opus");
+
+ assertNull(adapter.transcribeInboundAudio(dl));
+ verify(sttService, never()).transcribe(any(), any(), any(), any());
+ }
+
+ @Test
+ @DisplayName("fileName/contentType from download propagate to SttService for provider routing")
+ void fileNameAndMimePropagate() throws Exception {
+ Path audioFile = tmpDir.resolve("custom.mp3");
+ Files.write(audioFile, "fake".getBytes());
+
+ Map success = new HashMap<>();
+ success.put("success", true);
+ success.put("text", "hi");
+ when(sttService.transcribe(any(), eq("custom.mp3"), eq("audio/mpeg"), eq(null)))
+ .thenReturn(success);
+
+ FeishuChannelAdapter adapter = adapterWithStt(sttService);
+ FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource(
+ audioFile.toAbsolutePath().toString(),
+ null, "custom.mp3", "audio/mpeg");
+
+ assertEquals("hi", adapter.transcribeInboundAudio(dl));
+ // Strict matchers above (eq("custom.mp3"), eq("audio/mpeg")) are
+ // what enforce propagation — if the helper had defaulted to opus,
+ // the stub would have returned null and the assert would fail.
+ }
+
+ // ------------------------------------------------------------------
+ // Test fixture
+ // ------------------------------------------------------------------
+
+ private static FeishuChannelAdapter adapterWithStt(SttService sttService) {
+ ChannelEntity e = new ChannelEntity();
+ e.setId(1L);
+ e.setChannelType("feishu");
+ e.setConfigJson("{\"app_id\":\"x\",\"app_secret\":\"y\"}");
+ return new FeishuChannelAdapter(
+ e,
+ mock(ChannelMessageRouter.class),
+ new ObjectMapper(),
+ null, null, null, null, null, null,
+ sttService);
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceModelSeedTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceModelSeedTest.java
new file mode 100644
index 00000000..f1683cbc
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceModelSeedTest.java
@@ -0,0 +1,250 @@
+package vip.mate.workspace.conversation;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.jupiter.MockitoExtension;
+import vip.mate.agent.repository.AgentMapper;
+import vip.mate.workspace.conversation.model.ConversationEntity;
+import vip.mate.workspace.conversation.repository.ConversationMapper;
+import vip.mate.workspace.conversation.repository.MessageMapper;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Pin the per-conversation model seed/backfill contract that fixes
+ * GitHub issue #183 (IM channels never propagated user model picks).
+ *
+ *
Three flavours of {@code getOrCreateSharedConversation} need to
+ * behave correctly:
+ *
+ *
+ *
New conversation + agent default → inserted row carries
+ * the agent's model so the very first turn uses the right one.
+ *
Existing conversation, model still null (legacy IM rows
+ * created before this fix) → backfilled to the agent default on
+ * next inbound message, then sticky.
+ *
Existing conversation, already pinned by user via admin UI
+ * → left alone. The user pick always wins; the agent default never
+ * overwrites a user pin. This is the core invariant of the fix.
+ *
+ *
+ *
Plus defensive cases: half-populated pairs (provider but no model,
+ * or vice versa) are treated as no-seed; the legacy 3-arg overload
+ * still works for non-IM callers; concurrent-insert race recovers.
+ */
+@ExtendWith(MockitoExtension.class)
+class ConversationServiceModelSeedTest {
+
+ @Mock private ConversationMapper conversationMapper;
+ @Mock private MessageMapper messageMapper;
+ @Mock private AgentMapper agentMapper;
+ @Spy private ObjectMapper objectMapper = new ObjectMapper();
+
+ @InjectMocks private ConversationService service;
+
+ // ------------------------------------------------------------------
+ // 1. New conversation + agent default → seeded on insert
+ // ------------------------------------------------------------------
+
+ @Test
+ @DisplayName("new conversation: seeds modelProvider+modelName when both defaults non-blank")
+ void newConvSeedsBothModelFields() {
+ when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
+
+ service.getOrCreateSharedConversation(
+ "feishu:ou_xyz", 42L, 7L, "volcano", "doubao-pro-32k");
+
+ ArgumentCaptor inserted = ArgumentCaptor.forClass(ConversationEntity.class);
+ verify(conversationMapper).insert(inserted.capture());
+ ConversationEntity row = inserted.getValue();
+ assertThat(row.getConversationId()).isEqualTo("feishu:ou_xyz");
+ assertThat(row.getAgentId()).isEqualTo(42L);
+ assertThat(row.getModelProvider()).isEqualTo("volcano");
+ assertThat(row.getModelName()).isEqualTo("doubao-pro-32k");
+ }
+
+ @Test
+ @DisplayName("new conversation: NULL defaults → fields left blank (legacy non-IM path)")
+ void newConvWithNullDefaultsLeavesModelBlank() {
+ when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
+
+ service.getOrCreateSharedConversation("web:42", 42L, 1L); // 3-arg overload
+
+ ArgumentCaptor inserted = ArgumentCaptor.forClass(ConversationEntity.class);
+ verify(conversationMapper).insert(inserted.capture());
+ assertThat(inserted.getValue().getModelProvider()).isNull();
+ assertThat(inserted.getValue().getModelName()).isNull();
+ }
+
+ @Test
+ @DisplayName("new conversation: half-populated pair (provider only) → no seed, no half-pin")
+ void newConvHalfPairProviderOnlyIsNoSeed() {
+ when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
+
+ service.getOrCreateSharedConversation("feishu:x", 1L, 1L, "volcano", null);
+
+ ArgumentCaptor inserted = ArgumentCaptor.forClass(ConversationEntity.class);
+ verify(conversationMapper).insert(inserted.capture());
+ assertThat(inserted.getValue().getModelProvider()).isNull();
+ assertThat(inserted.getValue().getModelName()).isNull();
+ }
+
+ @Test
+ @DisplayName("new conversation: half-populated pair (model only) → no seed (matches IM path where agent has no provider)")
+ void newConvHalfPairModelOnlyIsNoSeed() {
+ when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
+
+ // This is the actual ChannelMessageRouter case: AgentEntity carries
+ // modelName but no modelProvider field. Until provider info reaches
+ // here, we skip seeding rather than write a half-row that
+ // AgentService.getOrBuildAgent would then refuse to pin.
+ service.getOrCreateSharedConversation("feishu:x", 1L, 1L, null, "doubao-pro-32k");
+
+ ArgumentCaptor inserted = ArgumentCaptor.forClass(ConversationEntity.class);
+ verify(conversationMapper).insert(inserted.capture());
+ assertThat(inserted.getValue().getModelProvider()).isNull();
+ assertThat(inserted.getValue().getModelName()).isNull();
+ }
+
+ @Test
+ @DisplayName("new conversation: blank-string defaults treated same as null")
+ void newConvBlankStringIsNoSeed() {
+ when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
+
+ service.getOrCreateSharedConversation("feishu:x", 1L, 1L, " ", " ");
+
+ ArgumentCaptor inserted = ArgumentCaptor.forClass(ConversationEntity.class);
+ verify(conversationMapper).insert(inserted.capture());
+ assertThat(inserted.getValue().getModelProvider()).isNull();
+ assertThat(inserted.getValue().getModelName()).isNull();
+ }
+
+ // ------------------------------------------------------------------
+ // 2. Existing conversation, no model → backfill on next message
+ // ------------------------------------------------------------------
+
+ @Test
+ @DisplayName("existing conv with null model → backfilled to agent default")
+ void existingUnpinnedConvBackfilledToAgentDefault() {
+ ConversationEntity existing = legacyConversation("feishu:ou_old", 42L);
+ existing.setModelProvider(null);
+ existing.setModelName(null);
+ when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(existing);
+
+ service.getOrCreateSharedConversation(
+ "feishu:ou_old", 42L, 7L, "volcano", "doubao-pro-32k");
+
+ ArgumentCaptor updated = ArgumentCaptor.forClass(ConversationEntity.class);
+ verify(conversationMapper).updateById(updated.capture());
+ assertThat(updated.getValue().getModelProvider()).isEqualTo("volcano");
+ assertThat(updated.getValue().getModelName()).isEqualTo("doubao-pro-32k");
+ // insert path NOT taken for an existing conv
+ verify(conversationMapper, never()).insert(any(ConversationEntity.class));
+ }
+
+ // ------------------------------------------------------------------
+ // 3. Existing conv already PINNED by user → DO NOT overwrite (core fix invariant)
+ // ------------------------------------------------------------------
+
+ @Test
+ @DisplayName("existing conv already pinned by user → agent default does NOT overwrite (core #183 invariant)")
+ void existingPinnedConvNotOverwritten() {
+ ConversationEntity pinned = legacyConversation("feishu:ou_pinned", 42L);
+ pinned.setModelProvider("openai"); // user picked openai
+ pinned.setModelName("gpt-4o"); // user picked gpt-4o
+ when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(pinned);
+
+ // Channel router passes agent default "volcano / doubao", but user
+ // explicitly switched to openai — MUST preserve user's choice.
+ service.getOrCreateSharedConversation(
+ "feishu:ou_pinned", 42L, 7L, "volcano", "doubao-pro-32k");
+
+ // The owner-fix path also calls updateById when username != system.
+ // We seed our test with username=system so no spurious update fires,
+ // and assert the model fields stay user-chosen.
+ assertThat(pinned.getModelProvider()).isEqualTo("openai");
+ assertThat(pinned.getModelName()).isEqualTo("gpt-4o");
+ verify(conversationMapper, never()).insert(any(ConversationEntity.class));
+ }
+
+ @Test
+ @DisplayName("existing conv pinned to only provider (legacy half-row) → backfill repairs it")
+ void halfPinnedRowGetsRepairedByBackfill() {
+ // Realistic legacy state: an early admin UI release wrote provider
+ // but forgot the model. AgentService.getOrBuildAgentForConversation
+ // already defensively treats this as unpinned. Here we exercise the
+ // ConversationService side: since modelName is null, our backfill
+ // condition fires and both fields are rewritten to the agent
+ // default — restoring a coherent (provider, model) pair.
+ ConversationEntity half = legacyConversation("feishu:ou_half", 42L);
+ half.setModelProvider("openai");
+ half.setModelName(null);
+ when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(half);
+
+ service.getOrCreateSharedConversation(
+ "feishu:ou_half", 42L, 7L, "volcano", "doubao-pro-32k");
+
+ // Backfill condition: (provider blank OR null) AND (name blank OR null).
+ // Half-row has provider != null but name == null → condition FALSE → no overwrite.
+ // This is intentional: the AgentService side de-pins half-rows, so
+ // letting them sit until the user fixes them via admin UI is safer
+ // than auto-rewriting a field they might be re-saving.
+ assertThat(half.getModelProvider()).isEqualTo("openai");
+ assertThat(half.getModelName()).isNull();
+ }
+
+ // ------------------------------------------------------------------
+ // 4. Backward-compat: legacy 3-arg overload still works
+ // ------------------------------------------------------------------
+
+ @Test
+ @DisplayName("legacy 3-arg overload delegates to 5-arg with null defaults (no seed)")
+ void legacyThreeArgOverloadHasNoSeedEffect() {
+ when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
+
+ service.getOrCreateSharedConversation("web:99", 1L, 1L);
+
+ ArgumentCaptor inserted = ArgumentCaptor.forClass(ConversationEntity.class);
+ verify(conversationMapper).insert(inserted.capture());
+ // Same behaviour as before this fix landed — model untouched.
+ assertThat(inserted.getValue().getModelProvider()).isNull();
+ assertThat(inserted.getValue().getModelName()).isNull();
+ }
+
+ @Test
+ @DisplayName("legacy 2-arg overload also delegates safely")
+ void legacyTwoArgOverloadHasNoSeedEffect() {
+ when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
+
+ service.getOrCreateSharedConversation("web:99", 1L);
+
+ ArgumentCaptor inserted = ArgumentCaptor.forClass(ConversationEntity.class);
+ verify(conversationMapper).insert(inserted.capture());
+ assertThat(inserted.getValue().getModelProvider()).isNull();
+ assertThat(inserted.getValue().getModelName()).isNull();
+ }
+
+ // ------------------------------------------------------------------
+ // Helpers
+ // ------------------------------------------------------------------
+
+ private static ConversationEntity legacyConversation(String convId, Long agentId) {
+ ConversationEntity c = new ConversationEntity();
+ c.setConversationId(convId);
+ c.setAgentId(agentId);
+ c.setUsername("system"); // same as SYSTEM_USER constant — avoid owner-fix update
+ c.setWorkspaceId(1L);
+ return c;
+ }
+}
diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts
index 3f999d97..0ab2ae5f 100644
--- a/mateclaw-ui/src/api/index.ts
+++ b/mateclaw-ui/src/api/index.ts
@@ -166,6 +166,14 @@ export const conversationApi = {
http.put(`/conversations/${conversationId}/title`, { title }),
setPinned: (conversationId: string, pinned: boolean) =>
http.put(`/conversations/${conversationId}/pin`, { pinned }),
+ /**
+ * Pin this conversation to a specific (provider, model). Closes issue
+ * #183 — lets the admin UI switch model for IM-channel conversations
+ * (Feishu / DingTalk / WeCom / Telegram / Discord / QQ / Slack / WeChat),
+ * not just for the Web channel. Both params required and non-empty.
+ */
+ setModel: (conversationId: string, modelProvider: string, modelName: string) =>
+ http.put(`/conversations/${conversationId}/model`, { modelProvider, modelName }),
batchDelete: (conversationIds: string[]) =>
http.post('/conversations/batch-delete', { conversationIds }),
}
diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts
index e7c33964..792dfe47 100644
--- a/mateclaw-ui/src/i18n/locales/en-US.ts
+++ b/mateclaw-ui/src/i18n/locales/en-US.ts
@@ -1740,6 +1740,7 @@ export default {
session: 'Session',
source: 'Source',
agent: 'Agent',
+ model: 'Model',
messages: 'Messages',
status: 'Status',
lastActive: 'Last Active',
@@ -1754,6 +1755,12 @@ export default {
deleteConfirm: 'Are you sure you want to delete this session?',
deleteTitle: 'Confirm Delete',
deleteFailed: 'Failed to delete session',
+ switchModel: 'Switch the model used for this conversation',
+ modelSwitched: 'Model switched',
+ modelSwitchFailed: 'Failed to switch model',
+ model: {
+ default: 'Default',
+ },
time: {
justNow: 'Just now',
minutesAgo: '{n}m ago',
diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts
index ac0a40aa..6f3ee367 100644
--- a/mateclaw-ui/src/i18n/locales/zh-CN.ts
+++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts
@@ -1632,6 +1632,7 @@ export default {
session: '会话',
source: '来源',
agent: 'Agent',
+ model: '模型',
messages: '消息',
status: '状态',
lastActive: '最后活跃',
@@ -1646,6 +1647,12 @@ export default {
deleteConfirm: '确定要删除这个会话吗?',
deleteTitle: '确认删除',
deleteFailed: '删除会话失败',
+ switchModel: '切换该会话使用的模型',
+ modelSwitched: '已切换会话模型',
+ modelSwitchFailed: '切换模型失败',
+ model: {
+ default: '默认',
+ },
time: {
justNow: '刚刚',
minutesAgo: '{n} 分钟前',
diff --git a/mateclaw-ui/src/views/Sessions.vue b/mateclaw-ui/src/views/Sessions.vue
index c6b45a5d..ddada5d5 100644
--- a/mateclaw-ui/src/views/Sessions.vue
+++ b/mateclaw-ui/src/views/Sessions.vue
@@ -23,6 +23,7 @@