Files
NotesAgentic/frontend/src/features/settings/ModelRoutingSettings.spec.ts
T
admin a71cd1d0bf feat: 添加知识库检索功能和改进模型路由错误处理
- 在ChatRequest中添加Citation事件类型,支持引用来源展示
- 实现聊天上下文准备服务,构建带源元数据的受限聊天上下文
- 添加ThreadedProcess类以支持Windows平台的子进程操作
- 改进检索引擎中的错误处理和向量搜索逻辑
- 实现严格的嵌入模型验证和索引重建机制
- 添加前端聊天界面的知识库检索开关
- 实现搜索历史记录功能和错误降级处理
- 更新模型路由设置提示信息以反映索引重建需求
2026-09-04 13:02:08 +08:00

171 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment happy-dom
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ModelRoutingResponse, ProviderConfig } from '@/contracts'
import { ApiErrorClass } from '@/services/apiClient'
import * as service from '@/services/modelRoutingService'
import { listProviders } from '@/services/providerService'
import ModelRoutingSettings from './ModelRoutingSettings.vue'
vi.mock('@/services/modelRoutingService', () => ({ getModelRouting: vi.fn(), saveModelRouting: vi.fn() }))
vi.mock('@/services/providerService', () => ({ listProviders: vi.fn() }))
const providers: ProviderConfig[] = [
{ provider_id: 'p1', provider_type: 'openai_compatible', name: 'Custom API', enabled: true, default_model: 'chat-model', capabilities: {}, has_credential: true },
{ provider_id: 'p2', provider_type: 'openai_chat', name: 'OpenAI', enabled: true, default_model: '', capabilities: {}, has_credential: true },
{ provider_id: 'responses', provider_type: 'openai_responses', name: 'Responses', enabled: true, default_model: '', capabilities: {}, has_credential: true },
{ provider_id: 'anthropic', provider_type: 'anthropic_messages', name: 'Anthropic', enabled: true, default_model: '', capabilities: {}, has_credential: true },
{ provider_id: 'ollama', provider_type: 'ollama', name: 'Ollama', enabled: true, default_model: '', capabilities: {}, has_credential: false },
{ provider_id: 'disabled', provider_type: 'openai_chat', name: 'Disabled', enabled: false, default_model: '', capabilities: {}, has_credential: true },
]
const initial: ModelRoutingResponse = { config: { version: 3, embedding: null, transcription: null, speaker_matching: null }, local_backends: [
{ capability: 'embedding', status: 'placeholder', message: 'hash fallback' },
{ capability: 'transcription', status: 'not_installed', message: 'ASR not installed' },
{ capability: 'speaker_matching', status: 'not_installed', message: 'speaker not installed' },
] }
const wrappers: ReturnType<typeof mount>[] = []
async function render() {
const wrapper = mount(ModelRoutingSettings)
wrappers.push(wrapper)
await flushPromises()
return wrapper
}
beforeEach(() => {
vi.resetAllMocks()
vi.mocked(service.getModelRouting).mockResolvedValue(structuredClone(initial))
vi.mocked(listProviders).mockResolvedValue(providers)
vi.mocked(service.saveModelRouting).mockImplementation(async config => ({ ...initial, config: { ...config, version: config.version + 1 } }))
})
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
describe('ModelRoutingSettings', () => {
it('loads local selections honestly, explains index rebuilds, and disables incompatible providers', async () => {
const wrapper = await render()
expect(wrapper.findAll('select').map(select => (select.element as HTMLSelectElement).value)).toEqual(['', '', ''])
expect(wrapper.text()).toContain('本地支持 Bekko / Granite')
expect(wrapper.text()).toContain('本地采用 Qwen3-ASR')
expect(wrapper.text()).toContain('本地采用 ERes2NetV2')
expect(wrapper.text()).toContain('重建全部')
expect(wrapper.text()).toContain('重建完成前可使用全文检索')
expect(wrapper.text()).toContain('不是 OpenAI 标准接口')
for (const id of ['responses', 'anthropic', 'ollama', 'disabled']) expect(wrapper.get(`option[value="${id}"]`).attributes()).toHaveProperty('disabled')
expect(wrapper.get('option[value="p1"]').attributes()).not.toHaveProperty('disabled')
})
it('saves all three independent bindings with expected version and remote dimensions', async () => {
const wrapper = await render()
for (const capability of ['embedding', 'transcription', 'speaker_matching']) {
const card = wrapper.get(`[data-capability="${capability}"]`)
await card.get('select').setValue('p1')
await card.get('[data-field="model"]').setValue(`${capability}-model`)
}
await wrapper.get('[data-field="dimensions"]').setValue('3072')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.saveModelRouting).toHaveBeenCalledWith({ version: 3,
embedding: { provider_id: 'p1', model: 'embedding-model', endpoint: '/embeddings', dimensions: 3072 },
transcription: { provider_id: 'p1', model: 'transcription-model', endpoint: '/audio/transcriptions' },
speaker_matching: { provider_id: 'p1', model: 'speaker_matching-model', endpoint: '/audio/speaker-matches' },
})
expect(wrapper.text()).toContain('配置版本 4')
expect(wrapper.text()).toContain('模型路由已保存')
await wrapper.get('[data-capability="embedding"] select').setValue('')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.saveModelRouting).toHaveBeenLastCalledWith(expect.objectContaining({ version: 4, embedding: null }))
})
it('supports omitted dimensions and clears stale models/endpoints when switching providers', async () => {
const wrapper = await render()
const card = wrapper.get('[data-capability="embedding"]')
await card.get('select').setValue('p1')
await card.get('[data-field="model"]').setValue('custom-embedding')
await card.get('[data-field="endpoint"]').setValue('/custom/embeddings')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.saveModelRouting).toHaveBeenCalledWith(expect.objectContaining({ embedding: { provider_id: 'p1', model: 'custom-embedding', endpoint: '/custom/embeddings', dimensions: null } }))
await card.get('select').setValue('p2')
expect((card.get('[data-field="model"]').element as HTMLInputElement).value).toBe('')
expect((card.get('[data-field="endpoint"]').element as HTMLInputElement).value).toBe('/embeddings')
})
it('shows a loading state and does not offer a default local configuration after load failure', async () => {
let fail!: (error: Error) => void
vi.mocked(service.getModelRouting).mockReturnValueOnce(new Promise((_, reject) => { fail = reject }))
const wrapper = await render()
expect(wrapper.text()).toContain('正在加载模型路由')
expect(wrapper.find('form').exists()).toBe(false)
fail(new Error('offline'))
await flushPromises()
expect(wrapper.text()).toContain('加载失败:offline')
expect(wrapper.find('form').exists()).toBe(false)
await wrapper.get('button').trigger('click')
await flushPromises()
expect(wrapper.find('form').exists()).toBe(true)
})
it('keeps unsaved input on save failure and retries without inventing a new version', async () => {
const wrapper = await render()
const card = wrapper.get('[data-capability="transcription"]')
await card.get('select').setValue('p1')
await card.get('[data-field="model"]').setValue('asr-model')
vi.mocked(service.saveModelRouting).mockRejectedValueOnce(new Error('disk full'))
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('保存失败:disk full')
expect((card.get('[data-field="model"]').element as HTMLInputElement).value).toBe('asr-model')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.saveModelRouting).toHaveBeenLastCalledWith(expect.objectContaining({ version: 3 }))
})
it('blocks overwrite after a conflict until explicitly reloading the latest configuration', async () => {
const wrapper = await render()
vi.mocked(service.saveModelRouting).mockRejectedValueOnce(new ApiErrorClass('MODEL_ROUTING_VERSION_CONFLICT', 'stale'))
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('配置版本冲突')
expect(wrapper.get('button[type="submit"]').attributes()).toHaveProperty('disabled')
await wrapper.get('form').trigger('submit')
expect(service.saveModelRouting).toHaveBeenCalledTimes(1)
vi.mocked(service.getModelRouting).mockResolvedValueOnce({ ...initial, config: { ...initial.config, version: 8 } })
await wrapper.findAll('button').find(button => button.text().includes('放弃当前输入'))!.trigger('click')
await flushPromises()
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.saveModelRouting).toHaveBeenLastCalledWith(expect.objectContaining({ version: 8 }))
})
it('prevents invalid dimensions, endpoints, and missing provider bindings from being saved', async () => {
vi.mocked(service.getModelRouting).mockResolvedValueOnce({ ...initial, config: { ...initial.config, embedding: { provider_id: 'missing', model: 'old-model', endpoint: '/embeddings' } } })
const wrapper = await render()
expect(wrapper.text()).toContain('原提供商已不可用')
await wrapper.get('form').trigger('submit')
expect(service.saveModelRouting).not.toHaveBeenCalled()
const card = wrapper.get('[data-capability="embedding"]')
await card.get('select').setValue('p1')
await card.get('[data-field="model"]').setValue('embedding-model')
await card.get('[data-field="dimensions"]').setValue('1.5')
await wrapper.get('form').trigger('submit')
expect(wrapper.text()).toContain('嵌入维度必须为 116384 的整数')
await card.get('[data-field="dimensions"]').setValue('16385')
await wrapper.get('form').trigger('submit')
expect(service.saveModelRouting).not.toHaveBeenCalled()
expect(card.get('[data-field="dimensions"]').attributes('max')).toBe('16384')
await card.get('[data-field="dimensions"]').setValue('')
await card.get('[data-field="endpoint"]').setValue('https://example.test/embeddings')
await wrapper.get('form').trigger('submit')
expect(wrapper.text()).toContain('Endpoint 必须是以 / 开头的相对路径')
expect(service.saveModelRouting).not.toHaveBeenCalled()
})
it('labels injected ready local backends accurately', async () => {
vi.mocked(service.getModelRouting).mockResolvedValueOnce({ ...initial, local_backends: [{ capability: 'transcription', status: 'ready', message: 'Local ASR ready' }] })
const wrapper = await render()
const card = wrapper.get('[data-capability="transcription"]')
expect(card.get('option[value=""]').text()).toBe('本地 · 已安装')
expect(card.text()).toContain('本地后端已就绪')
expect(card.text()).toContain('Local ASR ready')
expect(card.text()).not.toContain('真实本地 ASR 尚未接入')
})
})