mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(chat): per-conversation model selection (#150)
This commit is contained in:
parent
0ff8da0caa
commit
d53d66abe3
@ -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
|
||||
* <em>conversation pin > Agent model override > global default</em>.
|
||||
* 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 实例。
|
||||
*
|
||||
* <p>{@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.</p>
|
||||
*/
|
||||
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<String> 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:请先在「设置 → 模型」中配置并启用默认模型");
|
||||
}
|
||||
|
||||
@ -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<Long, BaseAgent> 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<Long, Map<String, BaseAgent>> 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<String> 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<String, BaseAgent> 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<ConversationEntity>()
|
||||
.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 ====================
|
||||
|
||||
@ -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<MessageContentPart> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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<ModelConfigEntity>()
|
||||
.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", "模型名称不能为空");
|
||||
|
||||
@ -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<ConversationEntity>()
|
||||
.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
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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());
|
||||
// 补充关联字段
|
||||
|
||||
@ -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);
|
||||
@ -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;
|
||||
@ -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");
|
||||
}
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<ModelConfig[]>([])
|
||||
// The model the CURRENT conversation uses. Per-conversation — switching it
|
||||
// never leaks into other conversations (see selectModel / applyConversationModel).
|
||||
const activeModels = ref<ActiveModelsInfo | null>(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<ChatAttachment[]>([])
|
||||
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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user