From 6a4318c26862f2bf85751702a1acd2266251b13c Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 20 May 2026 17:49:03 +0800 Subject: [PATCH] fix(channel): IM conversations respect per-conversation model selection (#183) --- .../java/vip/mate/agent/AgentService.java | 29 +- .../java/vip/mate/channel/ChannelManager.java | 10 +- .../mate/channel/ChannelMessageRouter.java | 37 ++- .../channel/feishu/FeishuChannelAdapter.java | 99 ++++++- .../conversation/ConversationService.java | 53 +++- .../controller/ConversationController.java | 31 +++ .../channel/ChannelManagerReconcileTest.java | 1 + .../channel/feishu/FeishuAudioSttTest.java | 209 +++++++++++++++ .../ConversationServiceModelSeedTest.java | 250 ++++++++++++++++++ mateclaw-ui/src/api/index.ts | 8 + mateclaw-ui/src/i18n/locales/en-US.ts | 7 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 7 + mateclaw-ui/src/views/Sessions.vue | 128 ++++++++- 13 files changed, 851 insertions(+), 18 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuAudioSttTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceModelSeedTest.java 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: + *

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

    + *
  1. New conversation + agent default → inserted row carries + * the agent's model so the very first turn uses the right one.
  2. + *
  3. Existing conversation, model still null (legacy IM rows + * created before this fix) → backfilled to the agent default on + * next inbound message, then sticky.
  4. + *
  5. 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.
  6. + *
+ * + *

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 @@ {{ t('sessions.columns.session') }} {{ t('sessions.columns.source') }} {{ t('sessions.columns.agent') }} + {{ t('sessions.columns.model') }} {{ t('sessions.columns.messages') }} {{ t('sessions.columns.status') }} {{ t('sessions.columns.lastActive') }} @@ -49,6 +50,28 @@ {{ session.agentName || '-' }} + + + + + {{ session.messageCount }} @@ -76,7 +99,7 @@ - +

💬

{{ t('sessions.empty') }}

@@ -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([]) 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([]) +const modelEditingId = ref(null) +const modelSavingId = ref(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); }