From 2685bf1cbc8b780a144ba80e3a0e182752831344 Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 21 Aug 2026 11:11:51 +0800 Subject: [PATCH] fix(ai): preserve reasoning projection when applying presets Keep the resolved reasoning projection and its catalog binding together with the model draft when applying reasoning preset changes. Reuse the matching projection for summaries, subsequent edits, and save validation so models.dev defaults are not rejected as unknown presets. Extend the reasoning panel test to cover applying an updated models.dev projection. --- .../config/components/AIModelConfig.tsx | 64 +++++++++++-------- .../components/ReasoningConfigPanel.test.tsx | 41 ++++++++---- .../components/ReasoningConfigPanel.tsx | 20 +++++- 3 files changed, 85 insertions(+), 40 deletions(-) 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 = ({ -