fix(channel): IM conversations respect per-conversation model selection (#183)

This commit is contained in:
matevip 2026-05-20 17:49:03 +08:00
parent d7378273b2
commit 6a4318c268
13 changed files with 851 additions and 18 deletions

View File

@ -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.
*
* <p>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<ConversationEntity>()
.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);
}

View File

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

View File

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

View File

@ -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.
*
* <p>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: 2s4s8s16s30s, 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.
*
* <p>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<String, Object> 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;

View File

@ -189,12 +189,43 @@ public class ConversationService {
}
/**
* 获取或创建共享渠道会话workspace 感知
* 获取或创建共享渠道会话workspace 感知
*
* <p>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.
*
* <p><b>Idempotent on the model fields</b>: the {@code defaultModelProvider}
* / {@code defaultModelName} are written <i>only</i> 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.
*
* <p>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<ConversationEntity>()
.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);
}

View File

@ -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.
*
* <p>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<Void> setModel(@PathVariable String conversationId,
@RequestBody Map<String, String> 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();
}
/**
* 批量删除会话仅删除当前用户有权操作的会话
*/

View File

@ -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();
}

View File

@ -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.
*
* <p>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.
*
* <p>The contract this test pins:
* <ol>
* <li>Successful STT returns the transcript, which the audio branch
* of {@code extractContentParts} prepends as a text part so the
* prompt builder sees real content.</li>
* <li>STT failure (no provider, empty text, exception) returns
* {@code null} never throws, never blocks the agent from
* seeing the audio part.</li>
* <li>STT not wired in (legacy 3-arg ctor, tests) returns
* {@code null} silently degraded but not broken.</li>
* <li>Empty / missing audio file is detected before the SttService
* call so we don't bill providers for zero-byte requests.</li>
* </ol>
*/
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<String, Object> 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);
}
}

View File

@ -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).
*
* <p>Three flavours of {@code getOrCreateSharedConversation} need to
* behave correctly:
*
* <ol>
* <li><b>New conversation + agent default</b> inserted row carries
* the agent's model so the very first turn uses the right one.</li>
* <li><b>Existing conversation, model still null</b> (legacy IM rows
* created before this fix) backfilled to the agent default on
* next inbound message, then sticky.</li>
* <li><b>Existing conversation, already pinned by user via admin UI</b>
* left alone. The user pick always wins; the agent default never
* overwrites a user pin. This is the core invariant of the fix.</li>
* </ol>
*
* <p>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<ConversationEntity> 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<ConversationEntity> 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<ConversationEntity> 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<ConversationEntity> 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<ConversationEntity> 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<ConversationEntity> 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<ConversationEntity> 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<ConversationEntity> 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;
}
}

View File

@ -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 }),
}

View File

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

View File

@ -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} 分钟前',

View File

@ -23,6 +23,7 @@
<th>{{ t('sessions.columns.session') }}</th>
<th>{{ t('sessions.columns.source') }}</th>
<th>{{ t('sessions.columns.agent') }}</th>
<th>{{ t('sessions.columns.model') }}</th>
<th>{{ t('sessions.columns.messages') }}</th>
<th>{{ t('sessions.columns.status') }}</th>
<th>{{ t('sessions.columns.lastActive') }}</th>
@ -49,6 +50,28 @@
<span>{{ session.agentName || '-' }}</span>
</div>
</td>
<td>
<!-- Closes #183: per-conversation model selector available for
IM channels too, not just Web. The selector mounts only
when this row is expanded so the table stays light. -->
<ModelSelector
v-if="modelEditingId === session.conversationId"
:providers="providers"
:active-value="modelValue(session)"
:active-label="modelLabel(session)"
:saving="modelSavingId === session.conversationId"
:show-all-states="true"
@select="(val) => onModelSelect(session, val)"
@navigate-fix="onProviderFix"
/>
<button v-else class="model-chip" :title="t('sessions.switchModel')"
@click="openModelEditor(session)">
<span class="model-chip__name">{{ modelLabel(session) || t('sessions.model.default') }}</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</td>
<td>
<span class="msg-count">{{ session.messageCount }}</span>
</td>
@ -76,7 +99,7 @@
</td>
</tr>
<tr v-if="filteredSessions.length === 0">
<td colspan="7" class="empty-row">
<td colspan="8" class="empty-row">
<div class="empty-state">
<span class="empty-icon">💬</span>
<p>{{ t('sessions.empty') }}</p>
@ -95,16 +118,23 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import { mcConfirm } from '@/components/common/useConfirm'
import { conversationApi } from '@/api/index'
import { conversationApi, modelApi } from '@/api/index'
import { channelIconUrl, sourceLabel } from '@/utils/channelSource'
import type { Conversation } from '@/types/index'
import type { Conversation, ProviderInfo } from '@/types/index'
import SkillIcon from '@/components/common/SkillIcon.vue'
import ModelSelector from '@/components/chat/ModelSelector.vue'
const router = useRouter()
const { t } = useI18n()
const sessions = ref<Conversation[]>([])
const searchText = ref('')
// Per-conversation model selection state (closes #183). Loaded once on mount,
// not per-row, because providers don't change during a session-list view.
const providers = ref<ProviderInfo[]>([])
const modelEditingId = ref<string | null>(null)
const modelSavingId = ref<string | null>(null)
const filteredSessions = computed(() => {
if (!searchText.value) return sessions.value
const q = searchText.value.toLowerCase()
@ -114,7 +144,10 @@ const filteredSessions = computed(() => {
)
})
onMounted(loadSessions)
onMounted(async () => {
await loadSessions()
await loadProviders()
})
async function loadSessions() {
try {
@ -123,6 +156,17 @@ async function loadSessions() {
} catch (e: any) { mcToast.error(t('sessions.loadFailed')) }
}
async function loadProviders() {
try {
const res: any = await modelApi.listEnabled()
providers.value = res.data || []
} catch (e: any) {
// Non-fatal: model selector just falls back to the "no providers"
// empty state; session list still works.
providers.value = []
}
}
function viewSession(session: Conversation) {
router.push({ path: '/chat', query: { agentId: String(session.agentId), conversationId: session.conversationId } })
}
@ -140,6 +184,77 @@ async function deleteSession(conversationId: string) {
} catch (e: any) { mcToast.error(t('sessions.deleteFailed')) }
}
// ==================== Model selection (#183) ====================
/** Composite key consumed by ModelSelector: "{providerId}::{modelName}". */
function modelValue(session: Conversation): string {
const p = session.modelProvider
const m = session.modelName
return p && m ? `${p}::${m}` : ''
}
/**
* Human-friendly label shown in the chip and the selector trigger.
* Pre-resolves the provider name from the loaded providers list; falls
* back to the raw id when providers haven't loaded yet (rare race) or
* the provider was since removed.
*/
function modelLabel(session: Conversation): string {
const p = session.modelProvider
const m = session.modelName
if (!p || !m) return ''
const provider = providers.value.find(x => x.id === p)
const providerName = provider?.name || p
return `${providerName} / ${m}`
}
function openModelEditor(session: Conversation) {
modelEditingId.value = session.conversationId
}
async function onModelSelect(session: Conversation, value: string) {
// ModelSelector emits the same "{providerId}::{modelName}" composite key
// we hand it back in :active-value. Split + persist.
const sep = value.indexOf('::')
if (sep <= 0) {
modelEditingId.value = null
return
}
const providerId = value.slice(0, sep)
const modelName = value.slice(sep + 2)
if (!providerId || !modelName) {
modelEditingId.value = null
return
}
// Skip the round-trip when nothing changed (user re-picked current model).
if (session.modelProvider === providerId && session.modelName === modelName) {
modelEditingId.value = null
return
}
modelSavingId.value = session.conversationId
try {
await conversationApi.setModel(session.conversationId, providerId, modelName)
// Local mutation: avoid a full reload the table is sorted by lastActive
// and a re-list would jump the row out from under the user's cursor.
session.modelProvider = providerId
session.modelName = modelName
mcToast.success(t('sessions.modelSwitched'))
} catch (e: any) {
mcToast.error(e?.response?.data?.message || t('sessions.modelSwitchFailed'))
} finally {
modelSavingId.value = null
modelEditingId.value = null
}
}
function onProviderFix(provider: { id: string }) {
// Match ChatConsole's behaviour: jump to the model-settings page with the
// provider deep-linked so the user can fix credentials / enable it, then
// come back and switch model.
modelEditingId.value = null
router.push({ path: '/settings/models', query: { providerId: provider.id } })
}
function formatTime(time?: string) {
if (!time) return '-'
@ -178,6 +293,11 @@ function formatTime(time?: string) {
.agent-cell { display: flex; align-items: center; gap: 6px; }
.agent-icon-sm { font-size: 16px; }
.msg-count { background: var(--mc-bg-sunken); padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 500; }
/* Model chip: collapsed state for the per-conversation model selector
(issue #183). Click opens the inline ModelSelector dropdown. */
.model-chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; background: var(--mc-bg-sunken); border: 1px solid var(--mc-border); border-radius: 6px; font-size: 12px; color: var(--mc-text-secondary); cursor: pointer; max-width: 220px; transition: all 0.15s; }
.model-chip:hover { background: var(--mc-bg-elevated); border-color: var(--mc-primary); color: var(--mc-text-primary); }
.model-chip__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.status-badge { padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; }
.status-active { background: var(--mc-primary-bg); color: var(--mc-primary); }
.status-closed { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }