mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +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) {
|
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();
|
AgentToolSet toolSet = toolRegistry.getEnabledToolSet();
|
||||||
|
|
||||||
// 过滤掉 denied 工具,使模型完全看不到它们(防止 prompt injection 利用 schema)
|
// 过滤掉 denied 工具,使模型完全看不到它们(防止 prompt injection 利用 schema)
|
||||||
@ -142,17 +174,16 @@ public class AgentGraphBuilder {
|
|||||||
Set<String> boundTools = agentBindingService.getEffectiveToolNames(entity.getId());
|
Set<String> boundTools = agentBindingService.getEffectiveToolNames(entity.getId());
|
||||||
toolSet = toolSet.withAllowedToolsOnly(boundTools); // null = 全局默认
|
toolSet = toolSet.withAllowedToolsOnly(boundTools); // null = 全局默认
|
||||||
|
|
||||||
// RFC-090 §9.2 调整 C — pick a primary model that satisfies
|
// Resolve the base model with the precedence: per-conversation pin >
|
||||||
// the agent's bound-skill requires-model. Falls back to the
|
// per-Agent model override > global default. resolveRuntimeBaseModel
|
||||||
// global default when no preferred provider satisfies, so the
|
// looks up enabled-only models and silently degrades an unmatched pin /
|
||||||
// existing "no default model" error path stays intact.
|
// override to the global default, preserving the legacy behaviour for
|
||||||
// Honor per-Agent model override when set.
|
// Agents and conversations without an explicit choice.
|
||||||
// resolveModel() looks up entity.modelName in enabled-only models;
|
// providerRouter.selectPrimary below may still swap this for a model
|
||||||
// null / blank / unmatched silently fall back to getDefaultModel(),
|
// that satisfies a bound skill's requires-model constraint.
|
||||||
// preserving the legacy behavior for Agents without an override.
|
|
||||||
ModelConfigEntity globalDefault;
|
ModelConfigEntity globalDefault;
|
||||||
try {
|
try {
|
||||||
globalDefault = modelConfigService.resolveModel(entity.getModelName());
|
globalDefault = resolveRuntimeBaseModel(modelProvider, modelName, entity.getModelName());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new MateClawException("err.agent.no_default_model", "无法构建 Agent:请先在「设置 → 模型」中配置并启用默认模型");
|
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.MemoryLifecycleMediator;
|
||||||
import vip.mate.memory.lifecycle.TurnContext;
|
import vip.mate.memory.lifecycle.TurnContext;
|
||||||
import vip.mate.memory.service.MemoryRecallTracker;
|
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.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -46,14 +48,22 @@ public class AgentService {
|
|||||||
private final MemoryRecallTracker memoryRecallTracker;
|
private final MemoryRecallTracker memoryRecallTracker;
|
||||||
private final MemoryLifecycleMediator lifecycleMediator;
|
private final MemoryLifecycleMediator lifecycleMediator;
|
||||||
private final MemoryProperties memoryProperties;
|
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
|
/** Field-injected publisher for agent_lifecycle trigger events; the
|
||||||
* trigger module's bridge listens and forwards into ingest. */
|
* trigger module's bridge listens and forwards into ingest. */
|
||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private ApplicationEventPublisher events;
|
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 ====================
|
// ==================== CRUD ====================
|
||||||
|
|
||||||
@ -217,7 +227,7 @@ public class AgentService {
|
|||||||
*/
|
*/
|
||||||
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, message);
|
memoryRecallTracker.trackRecalls(agentId, message);
|
||||||
BaseAgent agent = getOrBuildAgent(agentId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||||
try {
|
try {
|
||||||
return withLifecycleSync(agentId, message, conversationId,
|
return withLifecycleSync(agentId, message, conversationId,
|
||||||
@ -233,7 +243,7 @@ public class AgentService {
|
|||||||
|
|
||||||
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, message);
|
memoryRecallTracker.trackRecalls(agentId, message);
|
||||||
BaseAgent agent = getOrBuildAgent(agentId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
// Capture the origin into a request-scoped holder; cleared on Flux
|
// Capture the origin into a request-scoped holder; cleared on Flux
|
||||||
// termination so the next reactive subscriber doesn't inherit stale state.
|
// termination so the next reactive subscriber doesn't inherit stale state.
|
||||||
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||||
@ -269,7 +279,7 @@ public class AgentService {
|
|||||||
String requesterId, String thinkingLevel,
|
String requesterId, String thinkingLevel,
|
||||||
ChatOrigin origin) {
|
ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, message);
|
memoryRecallTracker.trackRecalls(agentId, message);
|
||||||
BaseAgent agent = getOrBuildAgent(agentId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
|
|
||||||
// 设置请求级思考深度(通过 ThreadLocal 传递到 StateGraph 执行)
|
// 设置请求级思考深度(通过 ThreadLocal 传递到 StateGraph 执行)
|
||||||
if (thinkingLevel != null && !thinkingLevel.isBlank()) {
|
if (thinkingLevel != null && !thinkingLevel.isBlank()) {
|
||||||
@ -315,7 +325,7 @@ public class AgentService {
|
|||||||
|
|
||||||
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
|
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, goal);
|
memoryRecallTracker.trackRecalls(agentId, goal);
|
||||||
BaseAgent agent = getOrBuildAgent(agentId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||||
try {
|
try {
|
||||||
return withLifecycleSync(agentId, goal, conversationId,
|
return withLifecycleSync(agentId, goal, conversationId,
|
||||||
@ -342,7 +352,7 @@ public class AgentService {
|
|||||||
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
||||||
String toolCallPayload, ChatOrigin origin) {
|
String toolCallPayload, ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||||
BaseAgent agent = getOrBuildAgent(agentId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||||
try {
|
try {
|
||||||
return withLifecycleSync(agentId, userMessage, conversationId,
|
return withLifecycleSync(agentId, userMessage, conversationId,
|
||||||
@ -370,7 +380,7 @@ public class AgentService {
|
|||||||
String toolCallPayload, String requesterId,
|
String toolCallPayload, String requesterId,
|
||||||
ChatOrigin origin) {
|
ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||||
BaseAgent agent = getOrBuildAgent(agentId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||||
return Flux.defer(() -> {
|
return Flux.defer(() -> {
|
||||||
ChatOriginHolder.set(captured);
|
ChatOriginHolder.set(captured);
|
||||||
@ -383,8 +393,20 @@ public class AgentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public AgentState getAgentState(Long agentId) {
|
public AgentState getAgentState(Long agentId) {
|
||||||
BaseAgent agent = agentInstances.get(agentId);
|
Map<String, BaseAgent> variants = agentInstances.get(agentId);
|
||||||
return agent != null ? agent.getState() : AgentState.IDLE;
|
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 -> {
|
* Resolve (and cache) the Agent graph for a conversation, honouring the
|
||||||
AgentEntity entity = getAgent(id);
|
* conversation's pinned model. Conversations with no pin — IM channels,
|
||||||
if (!Boolean.TRUE.equals(entity.getEnabled())) {
|
* cron, sub-tasks, or rows not yet created — resolve to the shared Agent /
|
||||||
throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + entity.getName());
|
* 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 ====================
|
// ==================== StreamDelta ====================
|
||||||
|
|||||||
@ -520,6 +520,11 @@ public class ChatController {
|
|||||||
AtomicBoolean finalized = new AtomicBoolean(false);
|
AtomicBoolean finalized = new AtomicBoolean(false);
|
||||||
try {
|
try {
|
||||||
conversationService.getOrCreateConversation(conversationId, agentId, username, workspaceId);
|
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);
|
List<MessageContentPart> requestParts = normalizeRequestParts(request);
|
||||||
String promptText = buildPromptText(message, requestParts);
|
String promptText = buildPromptText(message, requestParts);
|
||||||
conversationService.saveMessage(conversationId, "user", message, requestParts);
|
conversationService.saveMessage(conversationId, "user", message, requestParts);
|
||||||
@ -1151,6 +1156,14 @@ public class ChatController {
|
|||||||
private Long lastEventId;
|
private Long lastEventId;
|
||||||
/** 思考深度:off / low / medium / high / max,null 表示跟随 Agent 默认 */
|
/** 思考深度:off / low / medium / high / max,null 表示跟随 Agent 默认 */
|
||||||
private String thinkingLevel;
|
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();
|
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) {
|
private void validateModel(ModelConfigEntity entity, Long currentId) {
|
||||||
if (!StringUtils.hasText(entity.getName())) {
|
if (!StringUtils.hasText(entity.getName())) {
|
||||||
throw new MateClawException("err.llm.name_required", "模型名称不能为空");
|
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
|
* 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
|
* 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 */
|
/** Pin flag: 0 = normal, 1 = pinned to the top of the sidebar list */
|
||||||
private Integer pinned;
|
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)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
|||||||
@ -66,6 +66,8 @@ public class ConversationVO extends ConversationEntity {
|
|||||||
vo.setLastActiveTime(entity.getLastActiveTime());
|
vo.setLastActiveTime(entity.getLastActiveTime());
|
||||||
vo.setWorkspaceId(entity.getWorkspaceId());
|
vo.setWorkspaceId(entity.getWorkspaceId());
|
||||||
vo.setPinned(entity.getPinned() != null ? entity.getPinned() : 0);
|
vo.setPinned(entity.getPinned() != null ? entity.getPinned() : 0);
|
||||||
|
vo.setModelProvider(entity.getModelProvider());
|
||||||
|
vo.setModelName(entity.getModelName());
|
||||||
vo.setCreateTime(entity.getCreateTime());
|
vo.setCreateTime(entity.getCreateTime());
|
||||||
vo.setUpdateTime(entity.getUpdateTime());
|
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.MemoryProperties;
|
||||||
import vip.mate.memory.service.MemoryRecallTracker;
|
import vip.mate.memory.service.MemoryRecallTracker;
|
||||||
import vip.mate.memory.spi.MemoryManager;
|
import vip.mate.memory.spi.MemoryManager;
|
||||||
|
import vip.mate.workspace.conversation.repository.ConversationMapper;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
@ -41,6 +42,7 @@ class LifecycleRecallCountIT {
|
|||||||
@Mock private MemoryManager memoryManager;
|
@Mock private MemoryManager memoryManager;
|
||||||
@Mock private ApplicationEventPublisher eventPublisher;
|
@Mock private ApplicationEventPublisher eventPublisher;
|
||||||
@Mock private BaseAgent mockAgent;
|
@Mock private BaseAgent mockAgent;
|
||||||
|
@Mock private ConversationMapper conversationMapper;
|
||||||
|
|
||||||
private MemoryProperties props;
|
private MemoryProperties props;
|
||||||
private AgentService agentService;
|
private AgentService agentService;
|
||||||
@ -50,14 +52,14 @@ class LifecycleRecallCountIT {
|
|||||||
props = new MemoryProperties();
|
props = new MemoryProperties();
|
||||||
MemoryLifecycleMediator mediator = new MemoryLifecycleMediator(memoryManager, eventPublisher);
|
MemoryLifecycleMediator mediator = new MemoryLifecycleMediator(memoryManager, eventPublisher);
|
||||||
agentService = new AgentService(agentMapper, agentGraphBuilder,
|
agentService = new AgentService(agentMapper, agentGraphBuilder,
|
||||||
memoryRecallTracker, mediator, props);
|
memoryRecallTracker, mediator, props, conversationMapper);
|
||||||
|
|
||||||
// Stub agent resolution (lenient for structural-only tests)
|
// Stub agent resolution (lenient for structural-only tests)
|
||||||
AgentEntity entity = new AgentEntity();
|
AgentEntity entity = new AgentEntity();
|
||||||
entity.setId(1L);
|
entity.setId(1L);
|
||||||
entity.setEnabled(true);
|
entity.setEnabled(true);
|
||||||
lenient().when(agentMapper.selectById(1L)).thenReturn(entity);
|
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");
|
lenient().when(mockAgent.chat(any(), any())).thenReturn("reply");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -147,6 +147,10 @@ export interface SendMessageOptions {
|
|||||||
contentParts?: MessageContentPart[]
|
contentParts?: MessageContentPart[]
|
||||||
/** Thinking depth: off / low / medium / high / max */
|
/** Thinking depth: off / low / medium / high / max */
|
||||||
thinkingLevel?: string
|
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 {
|
export function useChat(options: UseChatOptions): UseChatReturn {
|
||||||
@ -1644,6 +1648,12 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
if (options.thinkingLevel) {
|
if (options.thinkingLevel) {
|
||||||
body.thinkingLevel = 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)
|
await stream.connect(body)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e : new Error(String(e))
|
error.value = e instanceof Error ? e : new Error(String(e))
|
||||||
|
|||||||
@ -66,6 +66,10 @@ export interface Conversation {
|
|||||||
streamStatus?: 'idle' | 'running'
|
streamStatus?: 'idle' | 'running'
|
||||||
source?: string
|
source?: string
|
||||||
pinned?: number
|
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
|
lastActiveTime?: string
|
||||||
updateTime?: string
|
updateTime?: string
|
||||||
createTime?: string
|
createTime?: string
|
||||||
|
|||||||
@ -342,7 +342,11 @@ const providersUnavailable = ref(false)
|
|||||||
// otherwise the model selector trigger would show its 配置模型 fallback even
|
// otherwise the model selector trigger would show its 配置模型 fallback even
|
||||||
// though there IS an active model.
|
// though there IS an active model.
|
||||||
const enabledModels = ref<ModelConfig[]>([])
|
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)
|
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 pendingAttachments = ref<ChatAttachment[]>([])
|
||||||
const uploadingAttachment = ref(false)
|
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('::')
|
const [providerId, model] = value.split('::')
|
||||||
if (!providerId || !model) return
|
if (!providerId || !model) return
|
||||||
modelSaving.value = true
|
// Per-conversation model: switching here only affects THIS conversation.
|
||||||
try {
|
// The backend pins it onto the conversation row when the next message is
|
||||||
const res: any = await modelApi.setActive({ providerId, model })
|
// sent (see sendChatMessage payload); we also patch the local list entry so
|
||||||
activeModels.value = res.data || { activeLlm: { providerId, model } }
|
// re-opening the conversation restores the choice without a round-trip.
|
||||||
await loadModelState()
|
activeModels.value = { activeLlm: { providerId, model } }
|
||||||
} catch (e) {
|
const conv = conversations.value.find(c => c.conversationId === currentConversationId.value)
|
||||||
mcToast.error(t('chat.switchModelFailed'))
|
if (conv) {
|
||||||
} finally {
|
conv.modelProvider = providerId
|
||||||
modelSaving.value = false
|
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(),
|
modelApi.listEnabled(),
|
||||||
])
|
])
|
||||||
defaultModel.value = defaultRes.data || null
|
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 || []
|
enabledModels.value = enabledRes.data || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
mcToast.error(t('chat.loadModelFailed'))
|
mcToast.error(t('chat.loadModelFailed'))
|
||||||
@ -1177,6 +1202,8 @@ async function selectConversation(conv: Conversation) {
|
|||||||
}
|
}
|
||||||
currentConversationId.value = conv.conversationId
|
currentConversationId.value = conv.conversationId
|
||||||
selectedAgentId.value = conv.agentId || selectedAgentId.value
|
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
|
// 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.
|
// it for cron conversations so the user doesn't wait up to 4s for the next tick.
|
||||||
activeCronRuns.value = []
|
activeCronRuns.value = []
|
||||||
@ -1308,6 +1335,8 @@ function newConversation() {
|
|||||||
resetForNewConversation()
|
resetForNewConversation()
|
||||||
currentConversationId.value = `conv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
currentConversationId.value = `conv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||||
messages.value = []
|
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.
|
// 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,
|
agentId: selectedAgentId.value,
|
||||||
contentParts,
|
contentParts,
|
||||||
thinkingLevel: thinkingLevel.value,
|
thinkingLevel: thinkingLevel.value,
|
||||||
|
modelProvider: activeModels.value?.activeLlm?.providerId,
|
||||||
|
modelName: activeModels.value?.activeLlm?.model,
|
||||||
attachments: outgoingAttachments.map(a => ({
|
attachments: outgoingAttachments.map(a => ({
|
||||||
type: 'file' as const,
|
type: 'file' as const,
|
||||||
fileUrl: a.url,
|
fileUrl: a.url,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user