diff --git a/src/__tests__/__mocks__/services-container.ts b/src/__tests__/__mocks__/services-container.ts index f417993d..bc31c8bc 100644 --- a/src/__tests__/__mocks__/services-container.ts +++ b/src/__tests__/__mocks__/services-container.ts @@ -80,7 +80,7 @@ export const serviceInstances: { } as Partial; })(), externalAPI: { - getAIConfig: vi.fn(async () => ({ api: { model: 'test-model', provider: 'test-provider' }, modelParameters: {} })), + getAIConfig: vi.fn(async () => ({ default: { model: 'test-model', provider: 'test-provider' }, modelParameters: {} })), getAIProviders: vi.fn(async () => []), generateFromAI: vi.fn(async function*() { // harmless await for linter diff --git a/src/services/agentInstance/tools/__tests__/wikiSearchPlugin.test.ts b/src/services/agentInstance/tools/__tests__/wikiSearchPlugin.test.ts index cdd4e0d3..dffc077a 100644 --- a/src/services/agentInstance/tools/__tests__/wikiSearchPlugin.test.ts +++ b/src/services/agentInstance/tools/__tests__/wikiSearchPlugin.test.ts @@ -602,10 +602,13 @@ describe('Wiki Search Plugin - Comprehensive Tests', () => { id: 'test-agent', agentDefId: 'test-agent-def', aiApiConfig: { - api: { + default: { provider: 'openai', model: 'gpt-4', - embeddingModel: 'text-embedding-ada-002', + }, + embedding: { + provider: 'openai', + model: 'text-embedding-ada-002', }, modelParameters: {}, }, @@ -665,10 +668,14 @@ describe('Wiki Search Plugin - Comprehensive Tests', () => { expect.any(String), // workspaceID 'How to use AI agents', expect.objectContaining({ - api: expect.objectContaining({ + default: expect.objectContaining({ provider: 'openai', model: 'gpt-4', }), + embedding: expect.objectContaining({ + provider: 'openai', + model: 'text-embedding-ada-002', + }), }), 10, 0.7, @@ -701,7 +708,7 @@ describe('Wiki Search Plugin - Comprehensive Tests', () => { id: 'test-agent', agentDefId: 'test-agent-def', aiApiConfig: { - api: { + default: { provider: 'openai', model: 'gpt-4', }, @@ -772,7 +779,7 @@ describe('Wiki Search Plugin - Comprehensive Tests', () => { id: 'test-agent', agentDefId: 'test-agent-def', aiApiConfig: { - api: { + default: { provider: 'openai', model: 'gpt-4', }, diff --git a/src/services/externalAPI/defaultProviders.ts b/src/services/externalAPI/defaultProviders.ts index 6c700716..f88bc478 100644 --- a/src/services/externalAPI/defaultProviders.ts +++ b/src/services/externalAPI/defaultProviders.ts @@ -151,7 +151,7 @@ export default { }, ], defaultConfig: { - api: { + default: { provider: 'siliconflow', model: 'Qwen/Qwen2.5-7B-Instruct', }, diff --git a/src/services/externalAPI/index.ts b/src/services/externalAPI/index.ts index b2e80407..00259f2e 100644 --- a/src/services/externalAPI/index.ts +++ b/src/services/externalAPI/index.ts @@ -353,8 +353,8 @@ export class ExternalAPIService implements IExternalAPIService { async deleteFieldFromDefaultAIConfig(fieldPath: string): Promise { this.ensureSettingsLoaded(); - // Support nested field deletion like 'api.embeddingModel' - const pathParts = fieldPath.split('.'); + // Support field deletion like 'embedding', 'speech', 'default' + const parts = fieldPath.split('.'); let current: Record = this.userSettings.defaultConfig; // Navigate to the parent object diff --git a/src/services/externalAPI/interface.ts b/src/services/externalAPI/interface.ts index e3cbe097..a4ad3742 100644 --- a/src/services/externalAPI/interface.ts +++ b/src/services/externalAPI/interface.ts @@ -340,7 +340,7 @@ export interface IExternalAPIService { /** * Delete a field from default AI configuration - * @param fieldPath - Dot-separated path to the field (e.g., 'api.embeddingModel') + * @param fieldPath - Dot-separated path to the field (e.g., 'embedding', 'speech', 'default') */ deleteFieldFromDefaultAIConfig(fieldPath: string): Promise; diff --git a/src/windows/Preferences/sections/ExternalAPI/__tests__/addProviderIntegration.test.tsx b/src/windows/Preferences/sections/ExternalAPI/__tests__/addProviderIntegration.test.tsx index 4c885b37..87e74a4a 100644 --- a/src/windows/Preferences/sections/ExternalAPI/__tests__/addProviderIntegration.test.tsx +++ b/src/windows/Preferences/sections/ExternalAPI/__tests__/addProviderIntegration.test.tsx @@ -51,11 +51,11 @@ describe('ExternalAPI Add Provider with Embedding Model', () => { Object.defineProperty(window.service.externalAPI, 'getAIConfig', { value: vi.fn().mockResolvedValue({ - api: { + default: { provider: 'existing-provider', model: 'gpt-4o', - // No embeddingModel initially }, + // No embedding initially modelParameters: { temperature: 0.7, systemPrompt: 'You are a helpful assistant.', @@ -87,7 +87,7 @@ describe('ExternalAPI Add Provider with Embedding Model', () => { // Mock observables for externalAPI const mockConfig: AiAPIConfig = { - api: { + default: { provider: 'existing-provider', model: 'gpt-4o', }, diff --git a/src/windows/Preferences/sections/ExternalAPI/__tests__/index.test.tsx b/src/windows/Preferences/sections/ExternalAPI/__tests__/index.test.tsx index eb7882aa..52141bad 100644 --- a/src/windows/Preferences/sections/ExternalAPI/__tests__/index.test.tsx +++ b/src/windows/Preferences/sections/ExternalAPI/__tests__/index.test.tsx @@ -23,21 +23,71 @@ const mockEmbeddingModel: ModelInfo = { features: ['embedding' as ModelFeature], }; +const mockSpeechModel: ModelInfo = { + name: 'gpt-speech', + caption: 'GPT Speech', + features: ['speech' as ModelFeature], +}; + +const mockImageModel: ModelInfo = { + name: 'dall-e', + caption: 'DALL-E', + features: ['imageGeneration' as ModelFeature], +}; + +const mockTranscriptionsModel: ModelInfo = { + name: 'whisper', + caption: 'Whisper', + features: ['transcriptions' as ModelFeature], +}; + +const mockFreeModel: ModelInfo = { + name: 'gpt-free', + caption: 'GPT Free', + features: ['free' as ModelFeature], +}; + const mockProvider: AIProviderConfig = { provider: 'openai', apiKey: 'sk-test', baseURL: 'https://api.openai.com/v1', - models: [mockLanguageModel, mockEmbeddingModel], + models: [ + mockLanguageModel, + mockEmbeddingModel, + mockSpeechModel, + mockImageModel, + mockTranscriptionsModel, + mockFreeModel, + ], providerClass: 'openai', isPreset: false, enabled: true, }; const mockAIConfig = { - api: { + default: { provider: 'openai', model: 'gpt-4', - embeddingModel: 'text-embedding-3-small', + }, + embedding: { + provider: 'openai', + model: 'text-embedding-3-small', + }, + speech: { + provider: 'openai', + model: 'gpt-speech', + }, + imageGeneration: { + provider: 'openai', + model: 'dall-e', + }, + transcriptions: { + provider: 'openai', + model: 'whisper', + }, + free: { + provider: 'openai', + model: 'gpt-free', }, modelParameters: { temperature: 0.7, @@ -155,11 +205,11 @@ describe('ExternalAPI Component', () => { // Mock config with no embedding model Object.defineProperty(window.service.externalAPI, 'getAIConfig', { value: vi.fn().mockResolvedValue({ - api: { + default: { provider: 'openai', model: 'gpt-4', - // No embeddingModel }, + // No embedding modelParameters: { temperature: 0.7, systemPrompt: 'You are a helpful assistant.', @@ -180,12 +230,9 @@ describe('ExternalAPI Component', () => { if (clearButton) { await user.click(clearButton as HTMLElement); - // Verify both model and provider fields are deleted when no embedding model exists + // Verify default field is deleted await waitFor(() => { - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.model'); - }); - await waitFor(() => { - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.provider'); + expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('default'); }); // Also verify that handleConfigChange was called to update local state @@ -203,23 +250,25 @@ describe('ExternalAPI Component', () => { // Verify the delete API was called await waitFor(() => { - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.model'); - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.provider'); + expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('default'); }); } } }); - it('should only clear model field when embedding model exists', async () => { + it('should only clear default field when embedding model exists', async () => { const user = userEvent.setup(); - // Mock config with embedding model - this should preserve the provider + // Mock config with embedding model Object.defineProperty(window.service.externalAPI, 'getAIConfig', { value: vi.fn().mockResolvedValue({ - api: { + default: { provider: 'openai', model: 'gpt-4', - embeddingModel: 'text-embedding-3-small', // Has embedding model + }, + embedding: { + provider: 'openai', + model: 'text-embedding-3-small', }, modelParameters: { temperature: 0.7, @@ -241,14 +290,11 @@ describe('ExternalAPI Component', () => { if (clearButton) { await user.click(clearButton as HTMLElement); - // Should only delete model, NOT provider (because embedding model uses the provider) + // Should delete default field await waitFor(() => { - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.model'); + expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('default'); }); - // Should NOT delete provider when embedding model exists - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).not.toHaveBeenCalledWith('api.provider'); - // Verify that handleConfigChange was called await waitFor(() => { expect(window.service.externalAPI.updateDefaultAIConfig).toHaveBeenCalled(); @@ -262,10 +308,8 @@ describe('ExternalAPI Component', () => { await user.keyboard('{Escape}'); await waitFor(() => { - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.model'); + expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('default'); }); - - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).not.toHaveBeenCalledWith('api.provider'); } } }); @@ -285,7 +329,7 @@ describe('ExternalAPI Component', () => { // Verify the delete API was called await waitFor(() => { - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.embeddingModel'); + expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('embedding'); }); // Also verify that handleConfigChange was called to update local state @@ -303,7 +347,7 @@ describe('ExternalAPI Component', () => { // Verify the delete API was called await waitFor(() => { - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.embeddingModel'); + expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('embedding'); }); } } @@ -316,19 +360,15 @@ describe('ExternalAPI Component', () => { // Create a simple test for ModelSelector clear functionality const { ModelSelector } = await import('../components/ModelSelector'); - const testConfig = { - api: { - provider: 'openai', - model: 'text-embedding-3-small', - embeddingModel: 'text-embedding-3-small', - }, - modelParameters: {}, + const testModel = { + provider: 'openai', + model: 'text-embedding-3-small', }; render( { } }); + it('should display default models from backend config on initial load', async () => { + await renderExternalAPI(); + + // Wait for all comboboxes to be rendered + const comboboxes = screen.getAllByRole('combobox'); + + // We have 6 model selectors (default, embedding, speech, imageGeneration, transcriptions, free) + expect(comboboxes).toHaveLength(6); + + // Check that default model is displayed (first combobox) + expect(comboboxes[0]).toHaveValue('gpt-4'); + + // Check that embedding model is displayed (second combobox) + expect(comboboxes[1]).toHaveValue('text-embedding-3-small'); + + // Check that speech model is displayed (third combobox) + expect(comboboxes[2]).toHaveValue('gpt-speech'); + + // Check that image generation model is displayed (fourth combobox) + expect(comboboxes[3]).toHaveValue('dall-e'); + + // Check that transcriptions model is displayed (fifth combobox) + expect(comboboxes[4]).toHaveValue('whisper'); + + // Check that free model is displayed (sixth combobox) + expect(comboboxes[5]).toHaveValue('gpt-free'); + }); + it('should render provider configuration section', async () => { await renderExternalAPI(); diff --git a/src/windows/Preferences/sections/ExternalAPI/__tests__/useAIConfigManagement.test.ts b/src/windows/Preferences/sections/ExternalAPI/__tests__/useAIConfigManagement.test.ts index 4fa583a0..511e2a61 100644 --- a/src/windows/Preferences/sections/ExternalAPI/__tests__/useAIConfigManagement.test.ts +++ b/src/windows/Preferences/sections/ExternalAPI/__tests__/useAIConfigManagement.test.ts @@ -7,7 +7,7 @@ import { useAIConfigManagement } from '../useAIConfigManagement'; describe('useAIConfigManagement', () => { const mockAIConfig: AiAPIConfig = { - api: { + default: { provider: 'openai', model: 'gpt-4', }, diff --git a/src/windows/Preferences/sections/ExternalAPI/components/ModelSelector.tsx b/src/windows/Preferences/sections/ExternalAPI/components/ModelSelector.tsx index 089a93d2..757d2313 100644 --- a/src/windows/Preferences/sections/ExternalAPI/components/ModelSelector.tsx +++ b/src/windows/Preferences/sections/ExternalAPI/components/ModelSelector.tsx @@ -1,32 +1,24 @@ import { Autocomplete } from '@mui/material'; -import { AiAPIConfig } from '@services/agentInstance/promptConcat/promptConcatSchema'; +import { ModelSelection } from '@services/agentInstance/promptConcat/promptConcatSchema'; import { AIProviderConfig, ModelInfo } from '@services/externalAPI/interface'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { TextField } from '../../../PreferenceComponents'; interface ModelSelectorProps { - selectedConfig: AiAPIConfig | null; + selectedModel: ModelSelection | undefined; modelOptions: Array<[AIProviderConfig, ModelInfo]>; onChange: (provider: string, model: string) => void; onClear?: () => void; onlyShowEnabled?: boolean; } -/** - * Type guard to check if config has api field - */ -const hasApiField = (config: AiAPIConfig | null): config is AiAPIConfig & { api: { provider: string; model: string } } => { - return config !== null && 'api' in config && typeof config.api === 'object' && config.api !== null && - 'provider' in config.api && 'model' in config.api; -}; - -export function ModelSelector({ selectedConfig, modelOptions, onChange, onClear, onlyShowEnabled }: ModelSelectorProps) { +export function ModelSelector({ selectedModel, modelOptions, onChange, onClear, onlyShowEnabled }: ModelSelectorProps) { const { t } = useTranslation('agent'); - const selectedValue = hasApiField(selectedConfig) && selectedConfig.api.model && selectedConfig.api.provider && - selectedConfig.api.model !== '' && selectedConfig.api.provider !== '' - ? modelOptions.find(m => m[0].provider === selectedConfig.api.provider && m[1].name === selectedConfig.api.model) || null + const selectedValue = selectedModel && selectedModel.model && selectedModel.provider && + selectedModel.model !== '' && selectedModel.provider !== '' + ? modelOptions.find(m => m[0].provider === selectedModel.provider && m[1].name === selectedModel.model) || null : null; const filteredModelOptions = onlyShowEnabled diff --git a/src/windows/Preferences/sections/ExternalAPI/components/__tests__/ProviderConfig.test.tsx b/src/windows/Preferences/sections/ExternalAPI/components/__tests__/ProviderConfig.test.tsx index af786cbc..bc39f72b 100644 --- a/src/windows/Preferences/sections/ExternalAPI/components/__tests__/ProviderConfig.test.tsx +++ b/src/windows/Preferences/sections/ExternalAPI/components/__tests__/ProviderConfig.test.tsx @@ -62,7 +62,7 @@ describe('ProviderConfig Component', () => { Object.defineProperty(window.service.externalAPI, 'getAIConfig', { value: vi.fn().mockResolvedValue({ - api: { + default: { provider: 'openai', model: 'gpt-4', }, @@ -194,7 +194,7 @@ describe('ProviderConfig Component', () => { // Mock AI config to simulate no existing embedding model Object.defineProperty(window.service.externalAPI, 'getAIConfig', { value: vi.fn().mockResolvedValue({ - api: { + default: { provider: '', model: '', }, diff --git a/src/windows/Preferences/sections/ExternalAPI/index.tsx b/src/windows/Preferences/sections/ExternalAPI/index.tsx index 964aa9d8..aba5252a 100644 --- a/src/windows/Preferences/sections/ExternalAPI/index.tsx +++ b/src/windows/Preferences/sections/ExternalAPI/index.tsx @@ -107,72 +107,13 @@ export function ExternalAPI(props: Partial): React.JSX.Element { await handleConfigChange(updatedConfig); }; - // Create default model config for ModelSelector - const defaultModelConfig = config && config.default - ? { - api: { - provider: config.default.provider, - model: config.default.model, - }, - modelParameters: config.modelParameters, - } - : null; - - // Create embedding config from current AI config - // Use the provider that actually has the embedding model - const embeddingConfig = config && config.embedding - ? { - api: { - provider: config.embedding.provider, - model: config.embedding.model, - }, - modelParameters: config.modelParameters, - } - : null; - - // Create speech config from current AI config - const speechConfig = config && config.speech - ? { - api: { - provider: config.speech.provider, - model: config.speech.model, - }, - modelParameters: config.modelParameters, - } - : null; - - // Create image generation config from current AI config - const imageGenerationConfig = config && config.imageGeneration - ? { - api: { - provider: config.imageGeneration.provider, - model: config.imageGeneration.model, - }, - modelParameters: config.modelParameters, - } - : null; - - // Create transcriptions config from current AI config - const transcriptionsConfig = config && config.transcriptions - ? { - api: { - provider: config.transcriptions.provider, - model: config.transcriptions.model, - }, - modelParameters: config.modelParameters, - } - : null; - - // Create free model config from current AI config - const freeModelConfig = config && config.free - ? { - api: { - provider: config.free.provider, - model: config.free.model, - }, - modelParameters: config.modelParameters, - } - : null; + // Extract model selections directly from config + const defaultModelConfig = config?.default; + const embeddingConfig = config?.embedding; + const speechConfig = config?.speech; + const imageGenerationConfig = config?.imageGeneration; + const transcriptionsConfig = config?.transcriptions; + const freeModelConfig = config?.free; const handleFreeModelClear = async () => { if (!config) return; @@ -201,7 +142,7 @@ export function ExternalAPI(props: Partial): React.JSX.Element { secondary={t('Preference.DefaultAIModelSelectionDescription')} /> provider.models .filter(model => Array.isArray(model.features) && model.features.includes('language')) @@ -218,7 +159,7 @@ export function ExternalAPI(props: Partial): React.JSX.Element { secondary={t('Preference.DefaultEmbeddingModelSelectionDescription')} /> provider.models .filter(model => Array.isArray(model.features) && model.features.includes('embedding')) @@ -235,7 +176,7 @@ export function ExternalAPI(props: Partial): React.JSX.Element { secondary={t('Preference.DefaultSpeechModelSelectionDescription')} /> provider.models .filter(model => Array.isArray(model.features) && model.features.includes('speech')) @@ -252,7 +193,7 @@ export function ExternalAPI(props: Partial): React.JSX.Element { secondary={t('Preference.DefaultImageGenerationModelSelectionDescription')} /> provider.models .filter(model => Array.isArray(model.features) && model.features.includes('imageGeneration')) @@ -269,7 +210,7 @@ export function ExternalAPI(props: Partial): React.JSX.Element { secondary={t('Preference.DefaultTranscriptionsModelSelectionDescription')} /> provider.models .filter(model => Array.isArray(model.features) && model.features.includes('transcriptions')) @@ -286,7 +227,7 @@ export function ExternalAPI(props: Partial): React.JSX.Element { secondary={t('Preference.DefaultFreeModelSelectionDescription')} /> provider.models .filter(model => Array.isArray(model.features) && model.features.includes('free'))