mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(channel): IM conversations respect per-conversation model selection (#183)
This commit is contained in:
parent
d7378273b2
commit
6a4318c268
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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: 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.
|
||||
*
|
||||
* <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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除会话(仅删除当前用户有权操作的会话)
|
||||
*/
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 }),
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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} 分钟前',
|
||||
|
||||
@ -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); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user