feat(provider): 完成阶段E协议适配、国内预设与模型路由

This commit is contained in:
2026-09-04 06:19:32 +08:00
parent f8e499df5d
commit 85f3169fbd
52 changed files with 4109 additions and 514 deletions
@@ -0,0 +1,170 @@
// @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('当前为占位实现')
expect(wrapper.text()).toContain('真实本地 ASR 尚未接入')
expect(wrapper.text()).toContain('真实本地说话人匹配尚未接入')
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 尚未接入')
})
})