diff --git a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx index 6c768b68ef..883c3d1ff8 100644 --- a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx @@ -29,7 +29,7 @@ import { ConfigPageHeader, ConfigPageLayout, ConfigPageContent, ConfigPageSectio import DefaultModelConfig from './DefaultModelConfig'; import SubagentModelConfig from './SubagentModelConfig'; import SessionTitleConfig from './SessionTitleConfig'; -import ReasoningConfigPanel from './ReasoningConfigPanel'; +import ReasoningConfigPanel, { type ReasoningConfigApplyResult } from './ReasoningConfigPanel'; import { createLogger } from '@/shared/utils/logger'; import { translateConnectionTestMessage } from '@/shared/utils/aiConnectionTestMessages'; import { i18nService } from '@/infrastructure/i18n'; @@ -61,6 +61,10 @@ interface SelectedModelDraft { maxTokens?: number; reasoning: ReasoningConfig; reasoningProjectionCatalog?: ReasoningCatalogBinding; + reasoningProjectionSnapshot?: { + catalog: ReasoningCatalogBinding; + projection?: ReasoningCatalogProjection | null; + }; } interface ProviderGroup { @@ -796,6 +800,17 @@ const AIModelConfig: React.FC = () => { )) ); + const resolveDraftReasoningProjection = (draft: SelectedModelDraft) => { + const snapshot = draft.reasoningProjectionSnapshot; + if (snapshot && reasoningCatalogBindingsEqual(draft.reasoning.catalog, snapshot.catalog)) { + return snapshot.projection ?? undefined; + } + if (reasoningCatalogBindingsEqual(draft.reasoning.catalog, draft.reasoningProjectionCatalog)) { + return resolveDraftCatalogEntry(draft)?.reasoning; + } + return undefined; + }; + const toggleSelectedModelCardExpanded = useCallback((draftKey: string) => { setExpandedModelCards(prev => { const next = new Set(prev); @@ -1523,19 +1538,19 @@ const AIModelConfig: React.FC = () => { notification.warning(t('messages.contextWindowTooSmall')); return; } - if (draftsToSave.some(draft => ( - validateReasoningConfig( - draft.reasoning, - reasoningCatalogBindingsEqual( - draft.reasoning.catalog, - draft.reasoningProjectionCatalog, - ) - ? resolveDraftCatalogEntry(draft)?.reasoning?.presets - ?.filter(preset => preset.source !== 'model_config') - .map(preset => preset.id) - : [], - ) !== null - ))) { + const reasoningValidationResults = draftsToSave.map(draft => ({ + modelName: draft.modelName, + reasoning: draft.reasoning, + projectionCatalog: draft.reasoningProjectionCatalog, + snapshotCatalog: draft.reasoningProjectionSnapshot?.catalog, + generatedPresetIds: resolveDraftReasoningProjection(draft)?.presets + ?.filter(preset => preset.source !== 'model_config') + .map(preset => preset.id) ?? [], + })).map(entry => ({ + ...entry, + validationError: validateReasoningConfig(entry.reasoning, entry.generatedPresetIds), + })); + if (reasoningValidationResults.some(entry => entry.validationError !== null)) { notification.warning(t('messages.invalidReasoningPresets')); return; } @@ -2220,11 +2235,7 @@ const AIModelConfig: React.FC = () => { const categoryLabel = categoryCompactLabels[draft.category] ?? draft.category; const canToggleExpand = selectedModelDrafts.length > 1; const modelDisplayName = draft.modelName; - const catalogEntry = resolveDraftCatalogEntry(draft); - const reasoningProjection = reasoningCatalogBindingsEqual( - draft.reasoning.catalog, - draft.reasoningProjectionCatalog, - ) ? catalogEntry?.reasoning : undefined; + const reasoningProjection = resolveDraftReasoningProjection(draft); return (
{ ? selectedModelDrafts.find(draft => draft.key === reasoningPanelDraftKey) : undefined; const reasoningPanelProjection = reasoningPanelDraft - && reasoningCatalogBindingsEqual( - reasoningPanelDraft.reasoning.catalog, - reasoningPanelDraft.reasoningProjectionCatalog, - ) - ? resolveDraftCatalogEntry(reasoningPanelDraft)?.reasoning + ? resolveDraftReasoningProjection(reasoningPanelDraft) : undefined; const reasoningPanelProjectionRequest = reasoningPanelDraft && editingConfig ? { @@ -3772,9 +3779,14 @@ const AIModelConfig: React.FC = () => { || reasoningPanelProjectionRequest.provider : undefined} onCancel={() => setReasoningPanelDraftKey(null)} - onApply={(reasoning) => { + onApply={(result: ReasoningConfigApplyResult) => { updateModelDraft(reasoningPanelDraft.modelName, { - reasoning, + reasoning: result.reasoning, + reasoningProjectionCatalog: result.projectionCatalog, + reasoningProjectionSnapshot: { + catalog: result.projectionCatalog, + projection: result.projection, + }, }); setReasoningPanelDraftKey(null); }} diff --git a/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.test.tsx b/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.test.tsx index 353dfd71e9..250ef1628c 100644 --- a/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.test.tsx @@ -3,7 +3,7 @@ import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ReasoningConfig } from '../types'; +import type { ReasoningCatalogProjection, ReasoningConfig } from '../types'; import ReasoningConfigPanel from './ReasoningConfigPanel'; const { projectReasoningCatalog } = vi.hoisted(() => ({ @@ -107,8 +107,12 @@ describe('ReasoningConfigPanel', () => { act(() => apply?.click()); expect(onApply).toHaveBeenCalledWith({ - catalog: { source: 'auto' }, - presets: [{ id: 'custom', actions: [{ type: 'effort', value: 'high' }] }], + reasoning: { + catalog: { source: 'auto' }, + presets: [{ id: 'custom', actions: [{ type: 'effort', value: 'high' }] }], + }, + projectionCatalog: { source: 'auto' }, + projection: undefined, }); }); @@ -135,15 +139,17 @@ describe('ReasoningConfigPanel', () => { }); it('refreshes generated presets after an explicit models.dev binding change', async () => { + const onApply = vi.fn(); + const modelsDevProjection: ReasoningCatalogProjection = { + status: 'known', + presets: [ + { id: 'low', label: 'Low', order: 0, source: 'models_dev', actions: [] }, + { id: 'high', label: 'High', order: 1, source: 'models_dev', actions: [] }, + ], + }; projectReasoningCatalog .mockResolvedValueOnce({ status: 'unknown', presets: [] }) - .mockResolvedValueOnce({ - status: 'known', - presets: [ - { id: 'low', label: 'Low', order: 0, source: 'models_dev', actions: [] }, - { id: 'high', label: 'High', order: 1, source: 'models_dev', actions: [] }, - ], - }); + .mockResolvedValueOnce(modelsDevProjection); await act(async () => root.render( { baseUrl: 'https://gateway.example.com/v1', }} onCancel={vi.fn()} - onApply={vi.fn()} + onApply={onApply} />, )); @@ -174,5 +180,18 @@ describe('ReasoningConfigPanel', () => { }); expect(container.querySelector('[data-testid="generated-presets"]')?.textContent) .toBe('low,high'); + + const apply = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent === 'reasoningPresets.apply'); + act(() => apply?.click()); + + expect(onApply).toHaveBeenCalledWith({ + reasoning: { + catalog: { source: 'models_dev', provider: 'openai', model: 'gpt-test' }, + presets: [], + }, + projectionCatalog: { source: 'models_dev', provider: 'openai', model: 'gpt-test' }, + projection: modelsDevProjection, + }); }); }); diff --git a/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.tsx b/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.tsx index e10bb21d42..25fb897013 100644 --- a/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.tsx +++ b/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { AlertTriangle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/component-library'; -import type { ReasoningCatalogProjection, ReasoningConfig } from '../types'; +import type { ReasoningCatalogBinding, ReasoningCatalogProjection, ReasoningConfig } from '../types'; import type { ModelsDevReasoningCatalog } from '@/infrastructure/api/service-api/AIApi'; import { aiApi } from '@/infrastructure/api'; import type { ReasoningCatalogProjectionRequest } from '@/infrastructure/api/service-api/AIApi'; @@ -20,7 +20,13 @@ interface ReasoningConfigPanelProps { projectionRequest?: Omit; requestFormatLabel?: string; onCancel: () => void; - onApply: (value: ReasoningConfig) => void; + onApply: (value: ReasoningConfigApplyResult) => void; +} + +export interface ReasoningConfigApplyResult { + reasoning: ReasoningConfig; + projectionCatalog: ReasoningCatalogBinding; + projection?: ReasoningCatalogProjection | null; } export const ReasoningConfigPanel: React.FC = ({ @@ -134,7 +140,15 @@ export const ReasoningConfigPanel: React.FC = ({ -