diff --git a/frontend/src/contracts/index.ts b/frontend/src/contracts/index.ts index aaba2a7..cf1a1be 100644 --- a/frontend/src/contracts/index.ts +++ b/frontend/src/contracts/index.ts @@ -290,6 +290,14 @@ export interface ProviderConfig { has_credential: boolean } +export interface ProviderPreset { + preset_id: string + name: string + provider_type: ProviderType + base_url: string + requires_credential: boolean +} + // ============ Tasks ============ export type TaskStatus = 'todo' | 'in_progress' | 'done' | 'cancelled' @@ -510,6 +518,14 @@ export interface ApiProviderConfig { capabilities: string[] } +export interface ApiProviderPreset { + preset_id: string + name: string + provider_type: ApiProviderType + base_url: string + requires_credential: boolean +} + export interface ApiModelInfo { model: string display_name: string diff --git a/frontend/src/features/settings/SettingsView.vue b/frontend/src/features/settings/SettingsView.vue index 6462c36..62503f8 100644 --- a/frontend/src/features/settings/SettingsView.vue +++ b/frontend/src/features/settings/SettingsView.vue @@ -1,5 +1,5 @@ @@ -66,8 +157,9 @@ async function testProvider(provider: ProviderConfig) { testResults.value[provid .setting-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); min-height: 54px; padding: var(--space-sm) 0; border-bottom: 1px solid var(--color-border-subtle); } .setting-row small { display: block; color: var(--color-text-tertiary); }.short { width: min(220px, 45%); } .section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-lg); } -.provider-list { display: grid; gap: var(--space-md); }.provider-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); }.provider-card p, .provider-card .tag-list { margin-top: var(--space-sm); } +.provider-list { display: grid; gap: var(--space-md); }.provider-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); }.provider-main { min-width: 0; flex: 1; }.provider-card p, .provider-card .tag-list { margin-top: var(--space-sm); } +.model-picker { display: flex; align-items: center; gap: var(--space-sm); margin-top: var(--space-md); }.model-picker label { white-space: nowrap; font-weight: 600; }.model-picker .select { width: min(360px, 100%); }.provider-actions { flex-wrap: wrap; justify-content: flex-end; }.error-text { color: var(--color-danger, #d33); } .test-result { color: var(--color-info); }.index-summary, .diagnostic-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-md); }.index-summary > div { padding: var(--space-lg); border-radius: var(--radius-md); background: var(--color-background-secondary); }.index-summary strong, .index-summary small { display: block; }.index-summary strong { font-size: var(--font-size-3xl); } .section-description { margin-top: calc(-1 * var(--space-md)); }.diagnostic-grid { grid-template-columns: repeat(2, 1fr); }.diagnostic-grid h3 { margin: var(--space-md) 0 var(--space-xs); }.diagnostic-actions { margin-top: var(--space-md); } -@media (max-width: 700px) { .provider-card, .setting-row { align-items: flex-start; flex-direction: column; }.short { width: 100%; }.index-summary, .diagnostic-grid { grid-template-columns: 1fr; } } +@media (max-width: 700px) { .provider-card, .setting-row, .model-picker { align-items: flex-start; flex-direction: column; }.short, .model-picker .select { width: 100%; }.index-summary, .diagnostic-grid { grid-template-columns: 1fr; }.provider-actions { justify-content: flex-start; } } diff --git a/frontend/src/services/providerService.ts b/frontend/src/services/providerService.ts index ddaeee1..2e52a20 100644 --- a/frontend/src/services/providerService.ts +++ b/frontend/src/services/providerService.ts @@ -1,5 +1,5 @@ import apiClient from './apiClient' -import type { ApiModelInfo, ApiProviderConfig, ModelCapability, ModelInfo, OperationResponse, ProviderConfig } from '@/contracts' +import type { ApiModelInfo, ApiProviderConfig, ApiProviderPreset, ModelCapability, ModelInfo, OperationResponse, ProviderConfig, ProviderPreset } from '@/contracts' function capabilityMap(capabilities: string[]): Partial { return Object.fromEntries(capabilities.map((capability) => [capability, true])) as Partial @@ -44,6 +44,11 @@ export async function createProvider(data: Omit): return toProvider(response) } +export async function listProviderPresets(): Promise { + const response = await apiClient.get<{ items: ApiProviderPreset[] }>('/api/providers/presets') + return response.items +} + export async function updateProvider(providerId: string, data: Partial): Promise { const response = await apiClient.patch(`/api/providers/${providerId}`, { name: data.name, diff --git a/frontend/src/stores/provider.spec.ts b/frontend/src/stores/provider.spec.ts new file mode 100644 index 0000000..95cb3ad --- /dev/null +++ b/frontend/src/stores/provider.spec.ts @@ -0,0 +1,79 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import type { ProviderConfig, ProviderPreset } from '@/contracts' + +vi.mock('@/services/providerService', () => ({ + mockProviders: [], + mockModels: {}, + listProviders: vi.fn(), + listProviderPresets: vi.fn(), + listModels: vi.fn(), + createProvider: vi.fn(), + updateProvider: vi.fn(), + deleteProvider: vi.fn(), + testProvider: vi.fn(), +})) + +import { useProviderStore } from './provider' +import { listModels, listProviderPresets, listProviders } from '@/services/providerService' + +const providers: ProviderConfig[] = [ + { + provider_id: 'openai', + provider_type: 'openai_chat', + name: 'OpenAI', + base_url: 'https://api.openai.com/v1', + default_model: '', + enabled: true, + capabilities: { chat: true }, + has_credential: true, + }, +] + +const presets: ProviderPreset[] = [ + { + preset_id: 'deepseek', + name: 'DeepSeek', + provider_type: 'openai_compatible', + base_url: 'https://api.deepseek.com', + requires_credential: true, + }, +] + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + vi.mocked(listProviders).mockResolvedValue(providers) + vi.mocked(listProviderPresets).mockResolvedValue(presets) +}) + +describe('provider store model discovery', () => { + it('loads provider presets and automatically refreshes enabled providers', async () => { + vi.mocked(listModels).mockResolvedValue([ + { model_id: 'model-z', name: 'Zulu', capabilities: { chat: true } }, + { model_id: 'model-a', name: 'Alpha', capabilities: { chat: true } }, + { model_id: 'model-a', name: 'Alpha duplicate', capabilities: { chat: true } }, + ]) + const store = useProviderStore() + + await store.loadProviders() + await store.loadPresets() + await store.refreshEnabledModels() + + expect(store.presets[0].preset_id).toBe('deepseek') + expect(listModels).toHaveBeenCalledWith('openai') + expect(store.modelsByProvider.openai.map((model) => model.model_id)).toEqual(['model-a', 'model-z']) + expect(store.modelErrorsByProvider.openai).toBeUndefined() + }) + + it('records a provider-specific error when model discovery fails', async () => { + vi.mocked(listModels).mockRejectedValue(new Error('认证失败')) + const store = useProviderStore() + + await expect(store.loadModels('openai')).rejects.toThrow('认证失败') + + expect(store.modelLoadingByProvider.openai).toBe(false) + expect(store.modelErrorsByProvider.openai).toBe('认证失败') + }) +}) diff --git a/frontend/src/stores/provider.ts b/frontend/src/stores/provider.ts index f644a45..999920c 100644 --- a/frontend/src/stores/provider.ts +++ b/frontend/src/stores/provider.ts @@ -1,11 +1,14 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import type { ProviderConfig, ModelInfo } from '@/contracts' -import { createProvider, deleteProvider as deleteProviderRequest, listModels, listProviders, mockProviders, mockModels, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService' +import type { ProviderConfig, ModelInfo, ProviderPreset } from '@/contracts' +import { createProvider, deleteProvider as deleteProviderRequest, listModels, listProviderPresets, listProviders, mockProviders, mockModels, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService' export const useProviderStore = defineStore('provider', () => { const providers = ref(mockProviders) + const presets = ref([]) const modelsByProvider = ref>(mockModels) + const modelLoadingByProvider = ref>({}) + const modelErrorsByProvider = ref>({}) const defaultProviderId = ref('mock') const isLoading = ref(false) const error = ref(null) @@ -27,8 +30,36 @@ export const useProviderStore = defineStore('provider', () => { } } - async function loadModels(providerId: string) { - modelsByProvider.value[providerId] = await listModels(providerId) + async function loadPresets() { + try { + presets.value = await listProviderPresets() + } catch (reason) { + error.value = reason instanceof Error ? reason.message : 'Provider 预设加载失败' + } + } + + async function loadModels(providerId: string): Promise { + modelLoadingByProvider.value[providerId] = true + delete modelErrorsByProvider.value[providerId] + try { + const models = await listModels(providerId) + const uniqueModels = [...new Map(models.map((model) => [model.model_id, model])).values()] + .sort((left, right) => left.name.localeCompare(right.name)) + modelsByProvider.value[providerId] = uniqueModels + return uniqueModels + } catch (reason) { + const message = reason instanceof Error ? reason.message : '模型列表获取失败' + modelErrorsByProvider.value[providerId] = message + throw reason + } finally { + modelLoadingByProvider.value[providerId] = false + } + } + + async function refreshEnabledModels() { + await Promise.allSettled( + providers.value.filter((provider) => provider.enabled).map((provider) => loadModels(provider.provider_id)) + ) } async function addProvider(data: Omit) { @@ -41,6 +72,7 @@ export const useProviderStore = defineStore('provider', () => { const updated = await updateProviderRequest(providerId, data) const index = providers.value.findIndex((provider) => provider.provider_id === providerId) if (index >= 0) providers.value[index] = updated + return updated } async function deleteProvider(providerId: string) { @@ -61,14 +93,19 @@ export const useProviderStore = defineStore('provider', () => { return { providers, + presets, modelsByProvider, + modelLoadingByProvider, + modelErrorsByProvider, defaultProviderId, enabledProviders, defaultProvider, isLoading, error, loadProviders, + loadPresets, loadModels, + refreshEnabledModels, addProvider, updateProvider, deleteProvider,