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:
倪程伟 2026-07-15 14:51:55 +08:00 committed by GitHub
parent bf224abc05
commit 04a9bb9e13
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 253 additions and 4 deletions

View File

@ -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
* disableddefault 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
}
}

View File

@ -0,0 +1,170 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
/**
* ChatConsole seed
*
* bugper-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)
})
})