From 150cf0d994ddb788dc26e031275465911debe574 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Fri, 4 Sep 2026 08:24:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E4=BF=9D=E7=95=99=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=88=87=E6=8D=A2=E5=90=8E=E7=9A=84=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E5=95=86=E4=B8=8E=E6=A8=A1=E5=9E=8B=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/development/前端壳子与接口层开发说明.md | 4 ++ frontend/src/features/chat/ChatView.spec.ts | 66 ++++++++++++++++++++ frontend/src/features/chat/ChatView.vue | 16 ++++- 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 frontend/src/features/chat/ChatView.spec.ts diff --git a/docs/development/前端壳子与接口层开发说明.md b/docs/development/前端壳子与接口层开发说明.md index 5e5aa28..7709c43 100644 --- a/docs/development/前端壳子与接口层开发说明.md +++ b/docs/development/前端壳子与接口层开发说明.md @@ -227,3 +227,7 @@ Agent 工具列表按 `mcp..` 的远程工具名匹配 卡片默认显示三行摘要,完整服务原文可展开查看,展开操作不会改变工具选择。服务已提供中文说明时优先保留;未收录的 MCP 工具明确提示暂无中文说明,不将本地摘要当作服务协议或自动翻译结果。原始说明及其中的参数规则完整保留。 验证:前端 84 项测试、类型检查与生产构建通过。新增回归覆盖不同服务器命名空间、未知工具、服务中文说明、原文完整性,以及选择工具时保留原始 ID。 + +### 聊天模型选择审阅修复 + +返回聊天页时保留仍启用的提供商与手动模型 ID,仅刷新其模型列表;未选择、已删除或已禁用的提供商才回退到默认值。提供商加载失败时保留当前选择并展示错误。新增页面重新挂载与异常分支回归,前端共 89 项测试通过。 diff --git a/frontend/src/features/chat/ChatView.spec.ts b/frontend/src/features/chat/ChatView.spec.ts new file mode 100644 index 0000000..46c73d2 --- /dev/null +++ b/frontend/src/features/chat/ChatView.spec.ts @@ -0,0 +1,66 @@ +// @vitest-environment happy-dom +import { beforeEach, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { useChatStore } from '@/stores/chat' +import { useProviderStore } from '@/stores/provider' +import { useSkillStore } from '@/stores/skill' +import ChatView from './ChatView.vue' + +vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) +vi.mock('@/stores/editor', () => ({ useEditorStore: () => ({}) })) +vi.mock('@/stores/workspace', () => ({ useWorkspaceStore: () => ({}) })) +vi.mock('@/components/common/MarkdownContent.vue', () => ({ default: { template: '
' } })) + +beforeEach(() => { + setActivePinia(createPinia()) + const providers = useProviderStore() + providers.providers = ['a', 'b'].map(id => ({ + provider_id: id, provider_type: 'openai_compatible', name: id, + default_model: `${id}-default`, enabled: true, capabilities: { chat: true }, has_credential: false, + })) + providers.defaultProviderId = 'a' + vi.spyOn(providers, 'loadProviders').mockResolvedValue(undefined) + vi.spyOn(providers, 'loadModels').mockResolvedValue([]) + vi.spyOn(useSkillStore(), 'loadSkills').mockResolvedValue(undefined) +}) + +it('preserves the selected provider and manual model after leaving and returning to chat', async () => { + const chat = useChatStore() + const first = mount(ChatView) + await flushPromises() + await first.get('select').setValue('b') + await first.get('input[list="chat-models"]').setValue('b-manual') + first.unmount() + const returned = mount(ChatView) + await flushPromises() + expect(chat.selectedProviderId).toBe('b') + expect(chat.selectedModel).toBe('b-manual') + expect(useProviderStore().loadModels).toHaveBeenLastCalledWith('b') + returned.unmount() +}) + +it.each(['missing', 'disabled', 'unselected'])('uses the default when the selected provider is %s', async state => { + const chat = useChatStore() + chat.selectedProviderId = state === 'unselected' ? '' : state === 'missing' ? 'deleted' : 'b' + chat.selectedModel = 'old-model' + if (state === 'disabled') useProviderStore().providers[1]!.enabled = false + const wrapper = mount(ChatView) + await flushPromises() + expect(chat.selectedProviderId).toBe('a') + expect(chat.selectedModel).toBe('a-default') + wrapper.unmount() +}) + +it('preserves the selection when provider discovery fails', async () => { + const chat = useChatStore() + chat.selectedProviderId = 'b' + chat.selectedModel = 'b-manual' + useProviderStore().error = 'offline' + const wrapper = mount(ChatView) + await flushPromises() + expect(chat.selectedProviderId).toBe('b') + expect(chat.selectedModel).toBe('b-manual') + expect(wrapper.get('.error-banner').text()).toBe('offline') + wrapper.unmount() +}) diff --git a/frontend/src/features/chat/ChatView.vue b/frontend/src/features/chat/ChatView.vue index 56e85df..51ad5d0 100644 --- a/frontend/src/features/chat/ChatView.vue +++ b/frontend/src/features/chat/ChatView.vue @@ -22,18 +22,28 @@ const availableModels = computed(() => providerStore.modelsByProvider[chatStore. onMounted(async () => { try { await Promise.all([providerStore.loadProviders(), skillStore.loadSkills()]) - chatStore.selectedProviderId = providerStore.defaultProviderId + if (providerStore.error) return + const selected = providerStore.enabledProviders.find(p => p.provider_id === chatStore.selectedProviderId) + if (!selected) { + chatStore.selectedProviderId = providerStore.defaultProviderId + } else { + await refreshModels(selected.provider_id) + } } catch (error) { loadError.value = error instanceof Error ? error.message : '无法加载 AI 配置,请检查后端连接。' } }) -watch(() => chatStore.selectedProviderId, async (providerId) => { - chatStore.selectedModel = providerStore.providers.find(p => p.provider_id === providerId)?.default_model ?? '' +async function refreshModels(providerId: string) { loadError.value = '' if (!providerId) return try { await providerStore.loadModels(providerId) } catch (error) { if (chatStore.selectedProviderId === providerId) loadError.value = error instanceof Error ? error.message : '模型列表加载失败,请手动填写模型 ID。' } +} + +watch(() => chatStore.selectedProviderId, async (providerId) => { + chatStore.selectedModel = providerStore.providers.find(p => p.provider_id === providerId)?.default_model ?? '' + await refreshModels(providerId) }) function send() { void chatStore.sendMessage(chatStore.inputText) }