From d53d66abe3608a81c1b9dd9708c716b08040d6e2 Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 18 May 2026 16:27:27 +0800 Subject: [PATCH] feat(chat): per-conversation model selection (#150) --- .../vip/mate/agent/AgentGraphBuilder.java | 51 ++++++++--- .../java/vip/mate/agent/AgentService.java | 86 +++++++++++++++---- .../vip/mate/channel/web/ChatController.java | 13 +++ .../mate/llm/service/ModelConfigService.java | 18 ++++ .../conversation/ConversationService.java | 26 ++++++ .../model/ConversationEntity.java | 10 +++ .../conversation/vo/ConversationVO.java | 2 + .../migration/h2/V116__conversation_model.sql | 7 ++ .../mysql/V116__conversation_model.sql | 29 +++++++ .../lifecycle/LifecycleRecallCountIT.java | 6 +- mateclaw-ui/src/composables/chat/useChat.ts | 10 +++ mateclaw-ui/src/types/index.ts | 4 + mateclaw-ui/src/views/ChatConsole.vue | 53 +++++++++--- 13 files changed, 275 insertions(+), 40 deletions(-) create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V116__conversation_model.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V116__conversation_model.sql diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 0519bf1f..e4b984c0 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -126,9 +126,41 @@ public class AgentGraphBuilder { } /** - * 根据 AgentEntity 构建完整的 Agent 实例 + * 根据 AgentEntity 构建完整的 Agent 实例(沿用 Agent / 全局默认模型)。 */ public BaseAgent build(AgentEntity entity) { + return build(entity, null, null); + } + + /** + * Resolve the model the runtime should use, honouring the precedence + * conversation pin > Agent model override > global default. + * A conversation pin that no longer resolves to an enabled model (the model + * was disabled or deleted after it was picked) silently degrades to the + * Agent / global default rather than failing the chat. + */ + private ModelConfigEntity resolveRuntimeBaseModel(String modelProvider, String modelName, + String agentModelName) { + if (modelProvider != null && !modelProvider.isBlank() + && modelName != null && !modelName.isBlank()) { + ModelConfigEntity pinned = modelConfigService.findEnabledModel(modelProvider, modelName); + if (pinned != null) { + return pinned; + } + log.info("Conversation model pin {}/{} is no longer an enabled model — " + + "falling back to the Agent / global default", modelProvider, modelName); + } + return modelConfigService.resolveModel(agentModelName); + } + + /** + * 根据 AgentEntity 构建完整的 Agent 实例。 + * + *

{@code modelProvider} / {@code modelName} carry an optional + * per-conversation model pin; when both are blank the build falls back to + * the Agent's model override, then the global default.

+ */ + public BaseAgent build(AgentEntity entity, String modelProvider, String modelName) { AgentToolSet toolSet = toolRegistry.getEnabledToolSet(); // 过滤掉 denied 工具,使模型完全看不到它们(防止 prompt injection 利用 schema) @@ -142,17 +174,16 @@ public class AgentGraphBuilder { Set boundTools = agentBindingService.getEffectiveToolNames(entity.getId()); toolSet = toolSet.withAllowedToolsOnly(boundTools); // null = 全局默认 - // RFC-090 §9.2 调整 C — pick a primary model that satisfies - // the agent's bound-skill requires-model. Falls back to the - // global default when no preferred provider satisfies, so the - // existing "no default model" error path stays intact. - // Honor per-Agent model override when set. - // resolveModel() looks up entity.modelName in enabled-only models; - // null / blank / unmatched silently fall back to getDefaultModel(), - // preserving the legacy behavior for Agents without an override. + // Resolve the base model with the precedence: per-conversation pin > + // per-Agent model override > global default. resolveRuntimeBaseModel + // looks up enabled-only models and silently degrades an unmatched pin / + // override to the global default, preserving the legacy behaviour for + // Agents and conversations without an explicit choice. + // providerRouter.selectPrimary below may still swap this for a model + // that satisfies a bound skill's requires-model constraint. ModelConfigEntity globalDefault; try { - globalDefault = modelConfigService.resolveModel(entity.getModelName()); + globalDefault = resolveRuntimeBaseModel(modelProvider, modelName, entity.getModelName()); } catch (Exception e) { throw new MateClawException("err.agent.no_default_model", "无法构建 Agent:请先在「设置 → 模型」中配置并启用默认模型"); } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java index ca3833cd..84ca3221 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -21,6 +21,8 @@ import vip.mate.memory.MemoryProperties; import vip.mate.memory.lifecycle.MemoryLifecycleMediator; import vip.mate.memory.lifecycle.TurnContext; import vip.mate.memory.service.MemoryRecallTracker; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; import java.util.List; import java.util.Map; @@ -46,14 +48,22 @@ public class AgentService { private final MemoryRecallTracker memoryRecallTracker; private final MemoryLifecycleMediator lifecycleMediator; private final MemoryProperties memoryProperties; + /** Read-only lookup of a conversation's pinned model. Mapper (not service) + * to keep this a leaf dependency with no risk of a bean cycle. */ + private final ConversationMapper conversationMapper; /** Field-injected publisher for agent_lifecycle trigger events; the * trigger module's bridge listens and forwards into ingest. */ @Autowired(required = false) private ApplicationEventPublisher events; - /** 运行时 Agent 实例缓存(agentId -> BaseAgent) */ - private final Map agentInstances = new ConcurrentHashMap<>(); + /** + * Runtime Agent instance cache. Keyed first by agentId, then by a model + * key, so a conversation that pins a non-default model gets its own graph + * variant instead of mutating the one every other conversation shares. + * The model key is {@code ""} for the Agent / global-default model. + */ + private final Map> agentInstances = new ConcurrentHashMap<>(); // ==================== CRUD ==================== @@ -217,7 +227,7 @@ public class AgentService { */ public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) { memoryRecallTracker.trackRecalls(agentId, message); - BaseAgent agent = getOrBuildAgent(agentId); + BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY); try { return withLifecycleSync(agentId, message, conversationId, @@ -233,7 +243,7 @@ public class AgentService { public Flux chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) { memoryRecallTracker.trackRecalls(agentId, message); - BaseAgent agent = getOrBuildAgent(agentId); + BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); // Capture the origin into a request-scoped holder; cleared on Flux // termination so the next reactive subscriber doesn't inherit stale state. ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY; @@ -269,7 +279,7 @@ public class AgentService { String requesterId, String thinkingLevel, ChatOrigin origin) { memoryRecallTracker.trackRecalls(agentId, message); - BaseAgent agent = getOrBuildAgent(agentId); + BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); // 设置请求级思考深度(通过 ThreadLocal 传递到 StateGraph 执行) if (thinkingLevel != null && !thinkingLevel.isBlank()) { @@ -315,7 +325,7 @@ public class AgentService { public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) { memoryRecallTracker.trackRecalls(agentId, goal); - BaseAgent agent = getOrBuildAgent(agentId); + BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY); try { return withLifecycleSync(agentId, goal, conversationId, @@ -342,7 +352,7 @@ public class AgentService { public String chatWithReplay(Long agentId, String userMessage, String conversationId, String toolCallPayload, ChatOrigin origin) { memoryRecallTracker.trackRecalls(agentId, userMessage); - BaseAgent agent = getOrBuildAgent(agentId); + BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY); try { return withLifecycleSync(agentId, userMessage, conversationId, @@ -370,7 +380,7 @@ public class AgentService { String toolCallPayload, String requesterId, ChatOrigin origin) { memoryRecallTracker.trackRecalls(agentId, userMessage); - BaseAgent agent = getOrBuildAgent(agentId); + BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY; return Flux.defer(() -> { ChatOriginHolder.set(captured); @@ -383,8 +393,20 @@ public class AgentService { } public AgentState getAgentState(Long agentId) { - BaseAgent agent = agentInstances.get(agentId); - return agent != null ? agent.getState() : AgentState.IDLE; + Map variants = agentInstances.get(agentId); + if (variants == null || variants.isEmpty()) { + return AgentState.IDLE; + } + // An Agent may have several cached graph variants (one per pinned + // model). Report the first non-IDLE state so a turn running on any + // variant stays visible. + for (BaseAgent agent : variants.values()) { + AgentState state = agent.getState(); + if (state != AgentState.IDLE) { + return state; + } + } + return AgentState.IDLE; } // ==================== 缓存管理 ==================== @@ -473,14 +495,44 @@ public class AgentService { // ==================== 内部方法 ==================== - private BaseAgent getOrBuildAgent(Long agentId) { - return agentInstances.computeIfAbsent(agentId, id -> { - AgentEntity entity = getAgent(id); - if (!Boolean.TRUE.equals(entity.getEnabled())) { - throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + entity.getName()); + /** + * 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. + */ + private BaseAgent getOrBuildAgentForConversation(Long agentId, String conversationId) { + String provider = null; + String modelName = null; + if (conversationId != null && !conversationId.isBlank()) { + ConversationEntity conv = conversationMapper.selectOne( + new LambdaQueryWrapper() + .eq(ConversationEntity::getConversationId, conversationId)); + if (conv != null) { + provider = conv.getModelProvider(); + modelName = conv.getModelName(); } - return agentGraphBuilder.build(entity); - }); + } + return getOrBuildAgent(agentId, provider, modelName); + } + + private BaseAgent getOrBuildAgent(Long agentId) { + return getOrBuildAgent(agentId, null, null); + } + + private BaseAgent getOrBuildAgent(Long agentId, String modelProvider, String modelName) { + boolean pinned = modelProvider != null && !modelProvider.isBlank() + && modelName != null && !modelName.isBlank(); + String modelKey = pinned ? modelProvider + "::" + modelName : ""; + return agentInstances + .computeIfAbsent(agentId, id -> new ConcurrentHashMap<>()) + .computeIfAbsent(modelKey, key -> { + AgentEntity entity = getAgent(agentId); + if (!Boolean.TRUE.equals(entity.getEnabled())) { + throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + entity.getName()); + } + return agentGraphBuilder.build(entity, modelProvider, modelName); + }); } // ==================== StreamDelta ==================== diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index 1a559102..f6d453b8 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -520,6 +520,11 @@ public class ChatController { AtomicBoolean finalized = new AtomicBoolean(false); try { conversationService.getOrCreateConversation(conversationId, agentId, username, workspaceId); + // Pin the model the user picked for this conversation so later + // turns (and the runtime model resolver) honour it independently + // of every other conversation. + conversationService.updateConversationModel(conversationId, + request.getModelProvider(), request.getModelName()); List requestParts = normalizeRequestParts(request); String promptText = buildPromptText(message, requestParts); conversationService.saveMessage(conversationId, "user", message, requestParts); @@ -1151,6 +1156,14 @@ public class ChatController { private Long lastEventId; /** 思考深度:off / low / medium / high / max,null 表示跟随 Agent 默认 */ private String thinkingLevel; + /** + * Provider id of the model the user picked for this conversation. + * Paired with {@link #modelName}; null means "no per-conversation + * override — use the agent / global default". + */ + private String modelProvider; + /** Model id the user picked for this conversation. See {@link #modelProvider}. */ + private String modelName; } /** diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java index c278eb8d..88a6d08e 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java @@ -323,6 +323,24 @@ public class ModelConfigService { return getDefaultModel(); } + /** + * Resolve an enabled model by its exact (provider, modelName) pair. Unlike + * {@link #resolveModel(String)} this does NOT fall back to the default — + * it returns {@code null} when nothing matches, leaving the fallback + * decision to the caller. Used to honour a per-conversation model pin while + * still degrading gracefully when that model was later disabled or deleted. + */ + public ModelConfigEntity findEnabledModel(String provider, String modelName) { + if (!StringUtils.hasText(provider) || !StringUtils.hasText(modelName)) { + return null; + } + return modelConfigMapper.selectOne(new LambdaQueryWrapper() + .eq(ModelConfigEntity::getProvider, provider) + .eq(ModelConfigEntity::getModelName, modelName) + .eq(ModelConfigEntity::getEnabled, true) + .last("LIMIT 1")); + } + private void validateModel(ModelConfigEntity entity, Long currentId) { if (!StringUtils.hasText(entity.getName())) { throw new MateClawException("err.llm.name_required", "模型名称不能为空"); diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 79a831a0..30eb1c58 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -349,6 +349,32 @@ public class ConversationService { } } + /** + * Pin the model a conversation uses. A blank provider or model id is a + * no-op (no override supplied — the conversation keeps inheriting the + * agent / global default). The write is skipped when the stored value + * already matches, so persisting the same model on every turn costs only + * a SELECT. + */ + @Transactional + public void updateConversationModel(String conversationId, String modelProvider, String modelName) { + if (modelProvider == null || modelProvider.isBlank() + || modelName == null || modelName.isBlank()) { + return; + } + ConversationEntity conv = conversationMapper.selectOne(new LambdaQueryWrapper() + .eq(ConversationEntity::getConversationId, conversationId)); + if (conv == null) { + return; + } + if (modelProvider.equals(conv.getModelProvider()) && modelName.equals(conv.getModelName())) { + return; + } + conv.setModelProvider(modelProvider); + conv.setModelName(modelName); + conversationMapper.updateById(conv); + } + /** * Persist an assistant placeholder marker only when the last message is a * user turn (i.e., the assistant never got to reply). Used by the admin diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java index 92828498..4b042fcb 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java @@ -51,6 +51,16 @@ public class ConversationEntity { /** Pin flag: 0 = normal, 1 = pinned to the top of the sidebar list */ private Integer pinned; + /** + * Provider id of the model this conversation is pinned to. NULL means + * "inherit" — fall back to the agent's model override, then the global + * default. Paired with {@link #modelName}. + */ + private String modelProvider; + + /** Model id this conversation is pinned to. See {@link #modelProvider}. */ + private String modelName; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java index 43c881d9..1b35bd05 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java @@ -66,6 +66,8 @@ public class ConversationVO extends ConversationEntity { vo.setLastActiveTime(entity.getLastActiveTime()); vo.setWorkspaceId(entity.getWorkspaceId()); vo.setPinned(entity.getPinned() != null ? entity.getPinned() : 0); + vo.setModelProvider(entity.getModelProvider()); + vo.setModelName(entity.getModelName()); vo.setCreateTime(entity.getCreateTime()); vo.setUpdateTime(entity.getUpdateTime()); // 补充关联字段 diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V116__conversation_model.sql b/mateclaw-server/src/main/resources/db/migration/h2/V116__conversation_model.sql new file mode 100644 index 00000000..ee595146 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V116__conversation_model.sql @@ -0,0 +1,7 @@ +-- Per-conversation model selection. Each conversation pins the LLM it uses, so +-- switching the model in one chat no longer changes every other conversation. +-- NULL means "inherit": fall back to the agent's model override, then to the +-- global default model. + +ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS model_provider VARCHAR(64); +ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS model_name VARCHAR(128); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V116__conversation_model.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V116__conversation_model.sql new file mode 100644 index 00000000..3c081ad9 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V116__conversation_model.sql @@ -0,0 +1,29 @@ +-- See the H2 file for context. MySQL 8.0 doesn't support +-- `ADD COLUMN IF NOT EXISTS`, so the existence check goes through +-- INFORMATION_SCHEMA + a prepared statement. + +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_conversation' + AND COLUMN_NAME = 'model_provider' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_conversation ADD COLUMN model_provider VARCHAR(64)', + 'SELECT 1'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_conversation' + AND COLUMN_NAME = 'model_name' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_conversation ADD COLUMN model_name VARCHAR(128)', + 'SELECT 1'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleRecallCountIT.java b/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleRecallCountIT.java index f6f4e2bd..bb411525 100644 --- a/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleRecallCountIT.java +++ b/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleRecallCountIT.java @@ -15,6 +15,7 @@ import vip.mate.agent.repository.AgentMapper; import vip.mate.memory.MemoryProperties; import vip.mate.memory.service.MemoryRecallTracker; import vip.mate.memory.spi.MemoryManager; +import vip.mate.workspace.conversation.repository.ConversationMapper; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -41,6 +42,7 @@ class LifecycleRecallCountIT { @Mock private MemoryManager memoryManager; @Mock private ApplicationEventPublisher eventPublisher; @Mock private BaseAgent mockAgent; + @Mock private ConversationMapper conversationMapper; private MemoryProperties props; private AgentService agentService; @@ -50,14 +52,14 @@ class LifecycleRecallCountIT { props = new MemoryProperties(); MemoryLifecycleMediator mediator = new MemoryLifecycleMediator(memoryManager, eventPublisher); agentService = new AgentService(agentMapper, agentGraphBuilder, - memoryRecallTracker, mediator, props); + memoryRecallTracker, mediator, props, conversationMapper); // Stub agent resolution (lenient for structural-only tests) AgentEntity entity = new AgentEntity(); entity.setId(1L); entity.setEnabled(true); lenient().when(agentMapper.selectById(1L)).thenReturn(entity); - lenient().when(agentGraphBuilder.build(any(AgentEntity.class))).thenReturn(mockAgent); + lenient().when(agentGraphBuilder.build(any(AgentEntity.class), any(), any())).thenReturn(mockAgent); lenient().when(mockAgent.chat(any(), any())).thenReturn("reply"); } diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index f7a0a039..ea999400 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -147,6 +147,10 @@ export interface SendMessageOptions { contentParts?: MessageContentPart[] /** Thinking depth: off / low / medium / high / max */ thinkingLevel?: string + /** Provider id of the model picked for this conversation. */ + modelProvider?: string + /** Model id picked for this conversation. Paired with modelProvider. */ + modelName?: string } export function useChat(options: UseChatOptions): UseChatReturn { @@ -1644,6 +1648,12 @@ export function useChat(options: UseChatOptions): UseChatReturn { if (options.thinkingLevel) { body.thinkingLevel = options.thinkingLevel } + // Per-conversation model: the backend pins it onto the conversation row + // so switching the model here never leaks into other conversations. + if (options.modelProvider && options.modelName) { + body.modelProvider = options.modelProvider + body.modelName = options.modelName + } await stream.connect(body) } catch (e) { error.value = e instanceof Error ? e : new Error(String(e)) diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 1ba25753..83b970aa 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -66,6 +66,10 @@ export interface Conversation { streamStatus?: 'idle' | 'running' source?: string pinned?: number + /** Provider id of the model this conversation is pinned to (per-conversation model). */ + modelProvider?: string + /** Model id this conversation is pinned to. Paired with modelProvider. */ + modelName?: string lastActiveTime?: string updateTime?: string createTime?: string diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 76f605ab..bd500460 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -342,7 +342,11 @@ const providersUnavailable = ref(false) // otherwise the model selector trigger would show its 配置模型 fallback even // though there IS an active model. const enabledModels = ref([]) +// The model the CURRENT conversation uses. Per-conversation — switching it +// never leaks into other conversations (see selectModel / applyConversationModel). const activeModels = ref(null) +// Global default model — seeds the selector for conversations with no pin yet. +const globalDefaultModel = ref<{ providerId: string; model: string } | null>(null) const pendingAttachments = ref([]) const uploadingAttachment = ref(false) @@ -379,18 +383,30 @@ function onAgentPicked(value: string | number | null) { } } -async function selectModel(value: string) { +function selectModel(value: string) { const [providerId, model] = value.split('::') if (!providerId || !model) return - modelSaving.value = true - try { - const res: any = await modelApi.setActive({ providerId, model }) - activeModels.value = res.data || { activeLlm: { providerId, model } } - await loadModelState() - } catch (e) { - mcToast.error(t('chat.switchModelFailed')) - } finally { - modelSaving.value = false + // Per-conversation model: switching here only affects THIS conversation. + // The backend pins it onto the conversation row when the next message is + // sent (see sendChatMessage payload); we also patch the local list entry so + // re-opening the conversation restores the choice without a round-trip. + activeModels.value = { activeLlm: { providerId, model } } + const conv = conversations.value.find(c => c.conversationId === currentConversationId.value) + if (conv) { + conv.modelProvider = providerId + conv.modelName = model + } +} + +/** + * Point the model selector at a conversation's pinned model, or the global + * default when the conversation has no pin yet (fresh chat, IM, cron). + */ +function applyConversationModel(conv?: Conversation | null) { + if (conv?.modelProvider && conv?.modelName) { + activeModels.value = { activeLlm: { providerId: conv.modelProvider, model: conv.modelName } } + } else if (globalDefaultModel.value) { + activeModels.value = { activeLlm: { ...globalDefaultModel.value } } } } @@ -1013,7 +1029,16 @@ async function loadModelState() { modelApi.listEnabled(), ]) defaultModel.value = defaultRes.data || null - activeModels.value = activeRes.data || null + const ga = activeRes.data?.activeLlm + globalDefaultModel.value = ga?.providerId && ga?.model + ? { providerId: ga.providerId, model: ga.model } + : null + // Seed the selector when no conversation has set it yet (fresh chat, or + // before a conversation is selected). A conversation that already has a + // model keeps it — selectConversation/applyConversationModel own that. + if (!activeModels.value && globalDefaultModel.value) { + activeModels.value = { activeLlm: { ...globalDefaultModel.value } } + } enabledModels.value = enabledRes.data || [] } catch (e) { mcToast.error(t('chat.loadModelFailed')) @@ -1177,6 +1202,8 @@ async function selectConversation(conv: Conversation) { } currentConversationId.value = conv.conversationId selectedAgentId.value = conv.agentId || selectedAgentId.value + // Restore this conversation's pinned model into the selector. + applyConversationModel(conv) // Reset cron placeholder state up front; the immediate fetch below repopulates // it for cron conversations so the user doesn't wait up to 4s for the next tick. activeCronRuns.value = [] @@ -1308,6 +1335,8 @@ function newConversation() { resetForNewConversation() currentConversationId.value = `conv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` messages.value = [] + // A fresh conversation starts on the global default model. + applyConversationModel() } // The sidebar performs the delete API call(s) and emits the removed ids. @@ -1475,6 +1504,8 @@ async function handleSendMessage(content: string) { agentId: selectedAgentId.value, contentParts, thinkingLevel: thinkingLevel.value, + modelProvider: activeModels.value?.activeLlm?.providerId, + modelName: activeModels.value?.activeLlm?.model, attachments: outgoingAttachments.map(a => ({ type: 'file' as const, fileUrl: a.url,