mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(chat): honour per-agent model override in web chat model selector
The web ChatConsole seeded a fresh conversation's model from the global
default, never the selected agent's model override, and handleSendMessage
then pinned that default onto the conversation row. Since a conversation
pin outranks the agent override in the backend runtime resolver
(AgentGraphBuilder.resolveRuntimeBaseModel), the model chosen on the
agent edit page was silently clobbered. IM channels and external webchat
were unaffected — they leave the conversation unpinned.
- applyConversationModel now follows the backend precedence:
conversation pin > agent model override > global default.
- The agent tier resolves via /agents/{id}/capabilities (authoritative)
with a synchronous fallback (currentAgent.modelName against the
enabled-model list) so an agent switch, a capability-fetch failure, or
a not-yet-hydrated deep-link still honour the override instead of
dropping to the global default.
- userPickedModel guards the async re-seed from clobbering an explicit
pick; reset on new/switch/delete conversation.
- Unit tests cover the precedence tiers, the empty-string capabilities
wire shape, and the synchronous fallback.
This commit is contained in:
parent
bf224abc05
commit
04a9bb9e13
@ -404,6 +404,12 @@ const enabledModels = ref<ModelConfig[]>([])
|
||||
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)
|
||||
// True once the user manually picks a model for the CURRENT conversation.
|
||||
// Guards the agent-capability re-seed (see the selectedAgentId watcher) from
|
||||
// clobbering that pick during the window where the async capability fetch
|
||||
// resolves after a fresh conversation was already seeded. Reset whenever we
|
||||
// move to a different conversation.
|
||||
const userPickedModel = ref(false)
|
||||
const pendingAttachments = ref<ChatAttachment[]>([])
|
||||
const uploadingAttachment = ref(false)
|
||||
|
||||
@ -445,6 +451,9 @@ function onAgentPicked(value: string | number | null) {
|
||||
function selectModel(value: string) {
|
||||
const [providerId, model] = value.split('::')
|
||||
if (!providerId || !model) return
|
||||
// Remember the explicit pick so a late-arriving agent-capability fetch
|
||||
// doesn't re-seed over it on a not-yet-persisted conversation.
|
||||
userPickedModel.value = true
|
||||
// Per-conversation model: switching here only affects THIS conversation.
|
||||
// We update the selector + the local list entry immediately so the UI is
|
||||
// responsive, then persist the pin to the server right away IF the
|
||||
@ -514,13 +523,62 @@ function selectModel(value: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* The selected agent's model override, as a (provider, model) pair — or null
|
||||
* when the agent has no usable override.
|
||||
*
|
||||
* Two sources, in order:
|
||||
* 1. agentCapabilities — the backend-resolved pair from /agents/{id}/capabilities.
|
||||
* Authoritative: it runs the same resolveModel the runtime uses (honouring
|
||||
* disabled→default fallback and provider disambiguation). But it arrives
|
||||
* via an async fetch that resolves AFTER the synchronous seeding on an
|
||||
* agent switch / deep-link, and is lost entirely if that fetch fails.
|
||||
* 2. currentAgent.modelName resolved against the enabled-model list — a
|
||||
* SYNCHRONOUS fallback so an agent switch, a capability-fetch failure, or a
|
||||
* not-yet-hydrated deep-link still honour the per-agent override instead of
|
||||
* silently dropping to the global default (which handleSendMessage would
|
||||
* then pin, re-introducing the very clobber this whole change fixes).
|
||||
*
|
||||
* enabledModels is viewer-accessible (/models/enabled) and loaded at mount, so
|
||||
* the fallback works for viewers too.
|
||||
*/
|
||||
function agentSeedModel(): { providerId: string; model: string } | null {
|
||||
const cap = agentCapabilities.value
|
||||
if (cap?.providerId && cap?.modelName) {
|
||||
return { providerId: cap.providerId, model: cap.modelName }
|
||||
}
|
||||
const name = currentAgent.value?.modelName
|
||||
if (name) {
|
||||
const hit = enabledModels.value.find(m => m.modelName === name)
|
||||
if (hit?.provider) {
|
||||
return { providerId: hit.provider, model: hit.modelName }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the model selector at the model this conversation should run, honouring
|
||||
* the same precedence the backend uses when it resolves the runtime model
|
||||
* (see AgentGraphBuilder.resolveRuntimeBaseModel):
|
||||
*
|
||||
* conversation pin > selected agent's model override > global default
|
||||
*
|
||||
* The middle tier is the fix for the "I set the agent's model but chat used
|
||||
* another one" bug: without it a fresh conversation seeded the global default,
|
||||
* and handleSendMessage then PINNED that default onto the conversation row —
|
||||
* which outranks the agent override and silently clobbered it.
|
||||
*/
|
||||
function applyConversationModel(conv?: Conversation | null) {
|
||||
if (conv?.modelProvider && conv?.modelName) {
|
||||
activeModels.value = { activeLlm: { providerId: conv.modelProvider, model: conv.modelName } }
|
||||
} else if (globalDefaultModel.value) {
|
||||
return
|
||||
}
|
||||
const agentModel = agentSeedModel()
|
||||
if (agentModel) {
|
||||
activeModels.value = { activeLlm: { providerId: agentModel.providerId, model: agentModel.model } }
|
||||
return
|
||||
}
|
||||
if (globalDefaultModel.value) {
|
||||
activeModels.value = { activeLlm: { ...globalDefaultModel.value } }
|
||||
}
|
||||
}
|
||||
@ -1369,6 +1427,17 @@ watch(selectedAgentId, async (id) => {
|
||||
try {
|
||||
const res: any = await agentApi.getCapabilities(id)
|
||||
agentCapabilities.value = res.data || null
|
||||
// The capability fetch is async and typically resolves AFTER the
|
||||
// synchronous newConversation()/applyConversationModel() that ran on this
|
||||
// same agent switch (which fell back to the global default because caps
|
||||
// weren't loaded yet). Re-seed now that we know the agent's resolved model
|
||||
// — but only for a conversation with no server-side pin and where the user
|
||||
// hasn't manually picked a model, so we never clobber an explicit choice.
|
||||
const conv = conversations.value.find(c => c.conversationId === currentConversationId.value)
|
||||
const hasServerPin = !!(conv?.modelProvider && conv?.modelName)
|
||||
if (!hasServerPin && !userPickedModel.value) {
|
||||
applyConversationModel(conv)
|
||||
}
|
||||
} catch {
|
||||
agentCapabilities.value = null
|
||||
}
|
||||
@ -1594,6 +1663,9 @@ async function selectConversation(conv: Conversation) {
|
||||
}
|
||||
currentConversationId.value = conv.conversationId
|
||||
selectedAgentId.value = conv.agentId || selectedAgentId.value
|
||||
// Opening another conversation: its pin (or the agent/global fallback) is
|
||||
// authoritative, so clear the previous conversation's manual-pick guard.
|
||||
userPickedModel.value = false
|
||||
// Restore this conversation's pinned model into the selector.
|
||||
applyConversationModel(conv)
|
||||
// Reset cron placeholder state up front; the immediate fetch below repopulates
|
||||
@ -1728,7 +1800,9 @@ 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.
|
||||
// A fresh conversation defers to the selected agent's model (then the global
|
||||
// default) until the user explicitly picks one.
|
||||
userPickedModel.value = false
|
||||
applyConversationModel()
|
||||
}
|
||||
|
||||
@ -1741,6 +1815,11 @@ function onConversationsDeleted(ids: string[]) {
|
||||
resetStreamingState()
|
||||
messages.value = []
|
||||
currentConversationId.value = ''
|
||||
// Deleting the open conversation drops us back to a blank slate — clear the
|
||||
// manual-pick guard so the next message (which mints a fresh conversation)
|
||||
// seeds from the agent override again instead of inheriting the deleted
|
||||
// conversation's pick. Mirrors newConversation()/selectConversation().
|
||||
userPickedModel.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
170
mateclaw-ui/src/views/__tests__/chatConsoleModelSeed.test.ts
Normal file
170
mateclaw-ui/src/views/__tests__/chatConsoleModelSeed.test.ts
Normal file
@ -0,0 +1,170 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
/**
|
||||
* 复刻 ChatConsole 中模型 seed 的核心判定逻辑做纯函数测试。
|
||||
*
|
||||
* 修复的 bug:员工编辑页选定的「模型」(per-Agent override)在网页对话里被
|
||||
* 全局默认模型覆盖。根因是 applyConversationModel 在会话无 pin 时直接回落全局
|
||||
* 默认,随后 handleSendMessage 把该默认 PIN 到会话行——而会话 pin 优先级高于
|
||||
* 员工 override,导致员工选的模型被静默覆盖。
|
||||
*
|
||||
* 修复后 seed 优先级与后端 AgentGraphBuilder.resolveRuntimeBaseModel 对齐:
|
||||
* 会话 pin > 员工 modelName override > 全局默认
|
||||
*
|
||||
* 员工 override 这一层有两个来源(复刻 agentSeedModel):
|
||||
* 1. agentCapabilities —— /agents/{id}/capabilities 的后端解析结果(异步、权威)
|
||||
* 2. currentAgent.modelName 在 enabledModels 里的同步解析(异步失败/切换/深链兜底)
|
||||
*/
|
||||
|
||||
type Model = { providerId: string; model: string }
|
||||
type EnabledModel = { provider: string; modelName: string }
|
||||
|
||||
// 复刻 agentSeedModel:先用 capabilities,再用 modelName 同步兜底。
|
||||
// 关键点:capabilities 未配默认模型时后端返回的是空串 ""(见 AgentController
|
||||
// 的 catch 分支),不是 null —— 空串是 JS falsy,`cap?.providerId && cap?.modelName`
|
||||
// 会正确落空并走同步兜底。测试特意用 "" 锁住这个 wire 契约。
|
||||
function agentSeedModel(opts: {
|
||||
capProvider?: string | null
|
||||
capModel?: string | null
|
||||
agentModelName?: string | null
|
||||
enabledModels?: EnabledModel[]
|
||||
}): Model | null {
|
||||
if (opts.capProvider && opts.capModel) {
|
||||
return { providerId: opts.capProvider, model: opts.capModel }
|
||||
}
|
||||
const name = opts.agentModelName
|
||||
if (name) {
|
||||
const hit = (opts.enabledModels || []).find(m => m.modelName === name)
|
||||
if (hit?.provider) {
|
||||
return { providerId: hit.provider, model: hit.modelName }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 复刻 applyConversationModel 的取值优先级
|
||||
function resolveSeedModel(opts: {
|
||||
convProvider?: string | null
|
||||
convModel?: string | null
|
||||
capProvider?: string | null
|
||||
capModel?: string | null
|
||||
agentModelName?: string | null
|
||||
enabledModels?: EnabledModel[]
|
||||
globalDefault?: Model | null
|
||||
}): Model | null {
|
||||
if (opts.convProvider && opts.convModel) {
|
||||
return { providerId: opts.convProvider, model: opts.convModel }
|
||||
}
|
||||
const agentModel = agentSeedModel(opts)
|
||||
if (agentModel) return agentModel
|
||||
if (opts.globalDefault) return { ...opts.globalDefault }
|
||||
return null
|
||||
}
|
||||
|
||||
// 复刻 selectedAgentId watcher 中的 re-seed 守卫:
|
||||
// 仅当会话无服务端 pin 且用户未手动选过模型时,才允许用刚加载的员工能力覆盖。
|
||||
function shouldReseedFromAgentCaps(opts: {
|
||||
convProvider?: string | null
|
||||
convModel?: string | null
|
||||
userPickedModel: boolean
|
||||
}): boolean {
|
||||
const hasServerPin = !!(opts.convProvider && opts.convModel)
|
||||
return !hasServerPin && !opts.userPickedModel
|
||||
}
|
||||
|
||||
const GLOBAL: Model = { providerId: 'openai', model: 'gpt-x' }
|
||||
const ENABLED: EnabledModel[] = [
|
||||
{ provider: 'anthropic', modelName: 'claude-x' },
|
||||
{ provider: 'volcano', modelName: 'doubao-pro' },
|
||||
]
|
||||
|
||||
describe('resolveSeedModel — 模型 seed 优先级', () => {
|
||||
it('会话已有 pin 时,优先用会话 pin(压过员工与全局)', () => {
|
||||
const r = resolveSeedModel({
|
||||
convProvider: 'volcano', convModel: 'doubao-pro',
|
||||
capProvider: 'anthropic', capModel: 'claude-x',
|
||||
globalDefault: GLOBAL,
|
||||
})
|
||||
expect(r).toEqual({ providerId: 'volcano', model: 'doubao-pro' })
|
||||
})
|
||||
|
||||
it('会话无 pin 时,用 capabilities 的员工 override(不再回落全局默认)', () => {
|
||||
const r = resolveSeedModel({
|
||||
capProvider: 'anthropic', capModel: 'claude-x',
|
||||
globalDefault: GLOBAL,
|
||||
})
|
||||
expect(r).toEqual({ providerId: 'anthropic', model: 'claude-x' })
|
||||
})
|
||||
|
||||
it('capabilities 未就绪(切换/失败/深链)时,用 modelName 在 enabledModels 里同步兜底', () => {
|
||||
const r = resolveSeedModel({
|
||||
capProvider: null, capModel: null,
|
||||
agentModelName: 'claude-x', enabledModels: ENABLED,
|
||||
globalDefault: GLOBAL,
|
||||
})
|
||||
expect(r).toEqual({ providerId: 'anthropic', model: 'claude-x' })
|
||||
})
|
||||
|
||||
it('capabilities 与同步兜底都缺失时,回落全局默认', () => {
|
||||
const r = resolveSeedModel({
|
||||
agentModelName: '', enabledModels: ENABLED,
|
||||
globalDefault: GLOBAL,
|
||||
})
|
||||
expect(r).toEqual(GLOBAL)
|
||||
})
|
||||
|
||||
it('员工的 modelName 不在 enabledModels(禁用/删除)时,同步兜底落空 → 全局默认', () => {
|
||||
const r = resolveSeedModel({
|
||||
agentModelName: 'ghost-model', enabledModels: ENABLED,
|
||||
globalDefault: GLOBAL,
|
||||
})
|
||||
expect(r).toEqual(GLOBAL)
|
||||
})
|
||||
|
||||
it('全部缺失时返回 null(无可用模型)', () => {
|
||||
expect(resolveSeedModel({ globalDefault: null })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentSeedModel — 空串 wire 契约(后端无默认模型时返回 "" 而非 null)', () => {
|
||||
it('capabilities 为空串对(providerId:"" modelName:"")视为无 override,走同步兜底', () => {
|
||||
const r = agentSeedModel({
|
||||
capProvider: '', capModel: '',
|
||||
agentModelName: 'claude-x', enabledModels: ENABLED,
|
||||
})
|
||||
expect(r).toEqual({ providerId: 'anthropic', model: 'claude-x' })
|
||||
})
|
||||
|
||||
it('半残的 capabilities 对(provider 空串、model 非空)视为无 override', () => {
|
||||
const r = agentSeedModel({
|
||||
capProvider: '', capModel: 'claude-x',
|
||||
agentModelName: null, enabledModels: ENABLED,
|
||||
})
|
||||
expect(r).toBeNull()
|
||||
})
|
||||
|
||||
it('capabilities 完整时优先于同步兜底', () => {
|
||||
const r = agentSeedModel({
|
||||
capProvider: 'openai', capModel: 'gpt-x',
|
||||
agentModelName: 'claude-x', enabledModels: ENABLED,
|
||||
})
|
||||
expect(r).toEqual({ providerId: 'openai', model: 'gpt-x' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldReseedFromAgentCaps — 能力异步到达后的 re-seed 守卫', () => {
|
||||
it('新会话、无 pin、用户未手动选 → 允许 re-seed(修复 agent 切换竞态)', () => {
|
||||
expect(shouldReseedFromAgentCaps({ userPickedModel: false })).toBe(true)
|
||||
})
|
||||
|
||||
it('会话已有服务端 pin → 不 re-seed(不覆盖已固定的会话)', () => {
|
||||
expect(shouldReseedFromAgentCaps({
|
||||
convProvider: 'volcano', convModel: 'doubao-pro', userPickedModel: false,
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('用户已手动选过模型 → 不 re-seed(不覆盖显式选择)', () => {
|
||||
expect(shouldReseedFromAgentCaps({ userPickedModel: true })).toBe(false)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user