mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +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
|
* Resolve (and cache) the Agent graph for a conversation, honouring the
|
||||||
* conversation's pinned model. Conversations with no pin — IM channels,
|
* conversation's pinned model. Conversations with no pin — IM channels
|
||||||
* cron, sub-tasks, or rows not yet created — resolve to the shared Agent /
|
* before issue #183 fix, cron, sub-tasks, or rows not yet created —
|
||||||
* global-default graph.
|
* 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) {
|
private BaseAgent getOrBuildAgentForConversation(Long agentId, String conversationId) {
|
||||||
String provider = null;
|
String provider = null;
|
||||||
@ -509,13 +515,26 @@ public class AgentService {
|
|||||||
new LambdaQueryWrapper<ConversationEntity>()
|
new LambdaQueryWrapper<ConversationEntity>()
|
||||||
.eq(ConversationEntity::getConversationId, conversationId));
|
.eq(ConversationEntity::getConversationId, conversationId));
|
||||||
if (conv != null) {
|
if (conv != null) {
|
||||||
provider = conv.getModelProvider();
|
provider = blankToNull(conv.getModelProvider());
|
||||||
modelName = conv.getModelName();
|
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);
|
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) {
|
private BaseAgent getOrBuildAgent(Long agentId) {
|
||||||
return getOrBuildAgent(agentId, null, null);
|
return getOrBuildAgent(agentId, null, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -118,6 +118,14 @@ public class ChannelManager {
|
|||||||
*/
|
*/
|
||||||
private final vip.mate.channel.feishu.FeishuClientFactory feishuClientFactory;
|
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
|
* Distributed leader election. Channels whose adapter reports
|
||||||
* {@link ChannelAdapter#requiresSingleLeader()} are gated on a lease so
|
* {@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 "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache);
|
||||||
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper,
|
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper,
|
||||||
feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager,
|
feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager,
|
||||||
feishuCardDispatcher, feishuClientFactory, generatedFileCache);
|
feishuCardDispatcher, feishuClientFactory, generatedFileCache, sttService);
|
||||||
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
|
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
|
||||||
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
|
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
|
||||||
case "wecom" -> new WeComChannelAdapter(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 reactor.core.publisher.Flux;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
import vip.mate.agent.context.ChatOrigin;
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.agent.model.AgentEntity;
|
||||||
import vip.mate.approval.ApprovalWorkflowService;
|
import vip.mate.approval.ApprovalWorkflowService;
|
||||||
import vip.mate.approval.ResolveOutcome;
|
import vip.mate.approval.ResolveOutcome;
|
||||||
import vip.mate.approval.PendingApproval;
|
import vip.mate.approval.PendingApproval;
|
||||||
@ -584,8 +585,40 @@ public class ChannelMessageRouter {
|
|||||||
}
|
}
|
||||||
// ======= 审批拦截层结束 =======
|
// ======= 审批拦截层结束 =======
|
||||||
|
|
||||||
// 确保会话存在(workspace 感知)
|
// Ensure the conversation exists, seeded with the agent's
|
||||||
conversationService.getOrCreateSharedConversation(conversationId, agentId, channelEntity.getWorkspaceId());
|
// 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);
|
String replyTarget = resolveReplyTarget(message);
|
||||||
|
|||||||
@ -155,10 +155,25 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
*/
|
*/
|
||||||
private final vip.mate.tool.document.GeneratedFileCache generatedFileCache;
|
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,
|
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||||
ChannelMessageRouter messageRouter,
|
ChannelMessageRouter messageRouter,
|
||||||
ObjectMapper objectMapper) {
|
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,
|
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||||
@ -167,7 +182,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
FeishuMediaUploader mediaUploader,
|
FeishuMediaUploader mediaUploader,
|
||||||
GeneratedFileScrubber generatedFileScrubber) {
|
GeneratedFileScrubber generatedFileScrubber) {
|
||||||
this(channelEntity, messageRouter, objectMapper, mediaUploader, generatedFileScrubber,
|
this(channelEntity, messageRouter, objectMapper, mediaUploader, generatedFileScrubber,
|
||||||
null, null, null, null);
|
null, null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||||
@ -177,7 +192,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
GeneratedFileScrubber generatedFileScrubber,
|
GeneratedFileScrubber generatedFileScrubber,
|
||||||
FeishuStreamingCardManager streamingCardManager) {
|
FeishuStreamingCardManager streamingCardManager) {
|
||||||
this(channelEntity, messageRouter, objectMapper, mediaUploader,
|
this(channelEntity, messageRouter, objectMapper, mediaUploader,
|
||||||
generatedFileScrubber, streamingCardManager, null, null, null);
|
generatedFileScrubber, streamingCardManager, null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||||
@ -188,7 +203,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
FeishuStreamingCardManager streamingCardManager,
|
FeishuStreamingCardManager streamingCardManager,
|
||||||
vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher) {
|
vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher) {
|
||||||
this(channelEntity, messageRouter, objectMapper, mediaUploader,
|
this(channelEntity, messageRouter, objectMapper, mediaUploader,
|
||||||
generatedFileScrubber, streamingCardManager, cardDispatcher, null, null);
|
generatedFileScrubber, streamingCardManager, cardDispatcher, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||||
@ -200,6 +215,21 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher,
|
vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher,
|
||||||
FeishuClientFactory clientFactory,
|
FeishuClientFactory clientFactory,
|
||||||
vip.mate.tool.document.GeneratedFileCache generatedFileCache) {
|
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);
|
super(channelEntity, messageRouter, objectMapper);
|
||||||
this.mediaUploader = mediaUploader;
|
this.mediaUploader = mediaUploader;
|
||||||
this.generatedFileScrubber = generatedFileScrubber;
|
this.generatedFileScrubber = generatedFileScrubber;
|
||||||
@ -207,6 +237,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
this.cardDispatcher = cardDispatcher;
|
this.cardDispatcher = cardDispatcher;
|
||||||
this.clientFactory = clientFactory;
|
this.clientFactory = clientFactory;
|
||||||
this.generatedFileCache = generatedFileCache;
|
this.generatedFileCache = generatedFileCache;
|
||||||
|
this.sttService = sttService;
|
||||||
// Feishu WebSocket reconnect: 2s→4s→8s→16s→30s, infinite retry
|
// Feishu WebSocket reconnect: 2s→4s→8s→16s→30s, infinite retry
|
||||||
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1);
|
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1);
|
||||||
}
|
}
|
||||||
@ -1331,9 +1362,21 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
case "audio" -> {
|
case "audio" -> {
|
||||||
String fileKey = (String) contentObj.get("file_key");
|
String fileKey = (String) contentObj.get("file_key");
|
||||||
if (fileKey != null) {
|
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);
|
MessageContentPart part = MessageContentPart.audio(fileKey, null);
|
||||||
applyDownload(part, dl);
|
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);
|
parts.add(part);
|
||||||
}
|
}
|
||||||
yield "[音频]";
|
yield "[音频]";
|
||||||
@ -1569,6 +1612,52 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
* caller can keep the part as a bare {@code file_key} placeholder
|
* caller can keep the part as a bare {@code file_key} placeholder
|
||||||
* when download was disabled / failed.
|
* 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.
|
// Package-private for unit tests.
|
||||||
static void applyDownload(MessageContentPart part, DownloadedResource dl) {
|
static void applyDownload(MessageContentPart part, DownloadedResource dl) {
|
||||||
if (dl == null || part == null) return;
|
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
|
@Transactional
|
||||||
public ConversationEntity getOrCreateSharedConversation(String conversationId, Long agentId, Long workspaceId) {
|
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>()
|
ConversationEntity conv = conversationMapper.selectOne(new LambdaQueryWrapper<ConversationEntity>()
|
||||||
.eq(ConversationEntity::getConversationId, conversationId));
|
.eq(ConversationEntity::getConversationId, conversationId));
|
||||||
|
boolean seedModel = defaultModelProvider != null && !defaultModelProvider.isBlank()
|
||||||
|
&& defaultModelName != null && !defaultModelName.isBlank();
|
||||||
if (conv == null) {
|
if (conv == null) {
|
||||||
conv = new ConversationEntity();
|
conv = new ConversationEntity();
|
||||||
conv.setConversationId(conversationId);
|
conv.setConversationId(conversationId);
|
||||||
@ -204,6 +235,13 @@ public class ConversationService {
|
|||||||
conv.setTitle("新对话");
|
conv.setTitle("新对话");
|
||||||
conv.setMessageCount(0);
|
conv.setMessageCount(0);
|
||||||
conv.setLastActiveTime(LocalDateTime.now());
|
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 {
|
try {
|
||||||
conversationMapper.insert(conv);
|
conversationMapper.insert(conv);
|
||||||
} catch (org.springframework.dao.DuplicateKeyException e) {
|
} catch (org.springframework.dao.DuplicateKeyException e) {
|
||||||
@ -226,6 +264,19 @@ public class ConversationService {
|
|||||||
conv.setAgentId(agentId);
|
conv.setAgentId(agentId);
|
||||||
changed = true;
|
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) {
|
if (changed) {
|
||||||
conversationMapper.updateById(conv);
|
conversationMapper.updateById(conv);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -141,6 +141,37 @@ public class ConversationController {
|
|||||||
return R.ok();
|
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.FeishuStreamingCardManager.class),
|
||||||
mock(vip.mate.channel.feishu.cards.FeishuCardDispatcher.class),
|
mock(vip.mate.channel.feishu.cards.FeishuCardDispatcher.class),
|
||||||
mock(vip.mate.channel.feishu.FeishuClientFactory.class),
|
mock(vip.mate.channel.feishu.FeishuClientFactory.class),
|
||||||
|
mock(vip.mate.stt.SttService.class),
|
||||||
election);
|
election);
|
||||||
adapter = new TrackingAdapter();
|
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 }),
|
http.put(`/conversations/${conversationId}/title`, { title }),
|
||||||
setPinned: (conversationId: string, pinned: boolean) =>
|
setPinned: (conversationId: string, pinned: boolean) =>
|
||||||
http.put(`/conversations/${conversationId}/pin`, { pinned }),
|
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[]) =>
|
batchDelete: (conversationIds: string[]) =>
|
||||||
http.post('/conversations/batch-delete', { conversationIds }),
|
http.post('/conversations/batch-delete', { conversationIds }),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1740,6 +1740,7 @@ export default {
|
|||||||
session: 'Session',
|
session: 'Session',
|
||||||
source: 'Source',
|
source: 'Source',
|
||||||
agent: 'Agent',
|
agent: 'Agent',
|
||||||
|
model: 'Model',
|
||||||
messages: 'Messages',
|
messages: 'Messages',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
lastActive: 'Last Active',
|
lastActive: 'Last Active',
|
||||||
@ -1754,6 +1755,12 @@ export default {
|
|||||||
deleteConfirm: 'Are you sure you want to delete this session?',
|
deleteConfirm: 'Are you sure you want to delete this session?',
|
||||||
deleteTitle: 'Confirm Delete',
|
deleteTitle: 'Confirm Delete',
|
||||||
deleteFailed: 'Failed to delete session',
|
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: {
|
time: {
|
||||||
justNow: 'Just now',
|
justNow: 'Just now',
|
||||||
minutesAgo: '{n}m ago',
|
minutesAgo: '{n}m ago',
|
||||||
|
|||||||
@ -1632,6 +1632,7 @@ export default {
|
|||||||
session: '会话',
|
session: '会话',
|
||||||
source: '来源',
|
source: '来源',
|
||||||
agent: 'Agent',
|
agent: 'Agent',
|
||||||
|
model: '模型',
|
||||||
messages: '消息',
|
messages: '消息',
|
||||||
status: '状态',
|
status: '状态',
|
||||||
lastActive: '最后活跃',
|
lastActive: '最后活跃',
|
||||||
@ -1646,6 +1647,12 @@ export default {
|
|||||||
deleteConfirm: '确定要删除这个会话吗?',
|
deleteConfirm: '确定要删除这个会话吗?',
|
||||||
deleteTitle: '确认删除',
|
deleteTitle: '确认删除',
|
||||||
deleteFailed: '删除会话失败',
|
deleteFailed: '删除会话失败',
|
||||||
|
switchModel: '切换该会话使用的模型',
|
||||||
|
modelSwitched: '已切换会话模型',
|
||||||
|
modelSwitchFailed: '切换模型失败',
|
||||||
|
model: {
|
||||||
|
default: '默认',
|
||||||
|
},
|
||||||
time: {
|
time: {
|
||||||
justNow: '刚刚',
|
justNow: '刚刚',
|
||||||
minutesAgo: '{n} 分钟前',
|
minutesAgo: '{n} 分钟前',
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
<th>{{ t('sessions.columns.session') }}</th>
|
<th>{{ t('sessions.columns.session') }}</th>
|
||||||
<th>{{ t('sessions.columns.source') }}</th>
|
<th>{{ t('sessions.columns.source') }}</th>
|
||||||
<th>{{ t('sessions.columns.agent') }}</th>
|
<th>{{ t('sessions.columns.agent') }}</th>
|
||||||
|
<th>{{ t('sessions.columns.model') }}</th>
|
||||||
<th>{{ t('sessions.columns.messages') }}</th>
|
<th>{{ t('sessions.columns.messages') }}</th>
|
||||||
<th>{{ t('sessions.columns.status') }}</th>
|
<th>{{ t('sessions.columns.status') }}</th>
|
||||||
<th>{{ t('sessions.columns.lastActive') }}</th>
|
<th>{{ t('sessions.columns.lastActive') }}</th>
|
||||||
@ -49,6 +50,28 @@
|
|||||||
<span>{{ session.agentName || '-' }}</span>
|
<span>{{ session.agentName || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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>
|
<td>
|
||||||
<span class="msg-count">{{ session.messageCount }}</span>
|
<span class="msg-count">{{ session.messageCount }}</span>
|
||||||
</td>
|
</td>
|
||||||
@ -76,7 +99,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="filteredSessions.length === 0">
|
<tr v-if="filteredSessions.length === 0">
|
||||||
<td colspan="7" class="empty-row">
|
<td colspan="8" class="empty-row">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<span class="empty-icon">💬</span>
|
<span class="empty-icon">💬</span>
|
||||||
<p>{{ t('sessions.empty') }}</p>
|
<p>{{ t('sessions.empty') }}</p>
|
||||||
@ -95,16 +118,23 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { mcToast } from '@/composables/useMcToast'
|
import { mcToast } from '@/composables/useMcToast'
|
||||||
import { mcConfirm } from '@/components/common/useConfirm'
|
import { mcConfirm } from '@/components/common/useConfirm'
|
||||||
import { conversationApi } from '@/api/index'
|
import { conversationApi, modelApi } from '@/api/index'
|
||||||
import { channelIconUrl, sourceLabel } from '@/utils/channelSource'
|
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 SkillIcon from '@/components/common/SkillIcon.vue'
|
||||||
|
import ModelSelector from '@/components/chat/ModelSelector.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const sessions = ref<Conversation[]>([])
|
const sessions = ref<Conversation[]>([])
|
||||||
const searchText = 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<ProviderInfo[]>([])
|
||||||
|
const modelEditingId = ref<string | null>(null)
|
||||||
|
const modelSavingId = ref<string | null>(null)
|
||||||
|
|
||||||
const filteredSessions = computed(() => {
|
const filteredSessions = computed(() => {
|
||||||
if (!searchText.value) return sessions.value
|
if (!searchText.value) return sessions.value
|
||||||
const q = searchText.value.toLowerCase()
|
const q = searchText.value.toLowerCase()
|
||||||
@ -114,7 +144,10 @@ const filteredSessions = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(loadSessions)
|
onMounted(async () => {
|
||||||
|
await loadSessions()
|
||||||
|
await loadProviders()
|
||||||
|
})
|
||||||
|
|
||||||
async function loadSessions() {
|
async function loadSessions() {
|
||||||
try {
|
try {
|
||||||
@ -123,6 +156,17 @@ async function loadSessions() {
|
|||||||
} catch (e: any) { mcToast.error(t('sessions.loadFailed')) }
|
} 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) {
|
function viewSession(session: Conversation) {
|
||||||
router.push({ path: '/chat', query: { agentId: String(session.agentId), conversationId: session.conversationId } })
|
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')) }
|
} 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) {
|
function formatTime(time?: string) {
|
||||||
if (!time) return '-'
|
if (!time) return '-'
|
||||||
@ -178,6 +293,11 @@ function formatTime(time?: string) {
|
|||||||
.agent-cell { display: flex; align-items: center; gap: 6px; }
|
.agent-cell { display: flex; align-items: center; gap: 6px; }
|
||||||
.agent-icon-sm { font-size: 16px; }
|
.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; }
|
.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-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-active { background: var(--mc-primary-bg); color: var(--mc-primary); }
|
||||||
.status-closed { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
|
.status-closed { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user