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,