diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 5b6d8fd3..4331c2e9 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -404,6 +404,12 @@ const enabledModels = ref([]) 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) +// 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([]) 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 } } diff --git a/mateclaw-ui/src/views/__tests__/chatConsoleModelSeed.test.ts b/mateclaw-ui/src/views/__tests__/chatConsoleModelSeed.test.ts new file mode 100644 index 00000000..e4dd55c7 --- /dev/null +++ b/mateclaw-ui/src/views/__tests__/chatConsoleModelSeed.test.ts @@ -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) + }) +})