Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 38 additions & 26 deletions src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -61,6 +61,10 @@ interface SelectedModelDraft {
maxTokens?: number;
reasoning: ReasoningConfig;
reasoningProjectionCatalog?: ReasoningCatalogBinding;
reasoningProjectionSnapshot?: {
catalog: ReasoningCatalogBinding;
projection?: ReasoningCatalogProjection | null;
};
}

interface ProviderGroup {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 (
<div
Expand Down Expand Up @@ -3112,11 +3123,7 @@ const AIModelConfig: React.FC = () => {
? 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
? {
Expand Down Expand Up @@ -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);
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down Expand Up @@ -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,
});
});

Expand All @@ -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(
<ReasoningConfigPanel
Expand All @@ -154,7 +160,7 @@ describe('ReasoningConfigPanel', () => {
baseUrl: 'https://gateway.example.com/v1',
}}
onCancel={vi.fn()}
onApply={vi.fn()}
onApply={onApply}
/>,
));

Expand All @@ -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,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,7 +20,13 @@ interface ReasoningConfigPanelProps {
projectionRequest?: Omit<ReasoningCatalogProjectionRequest, 'reasoning'>;
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<ReasoningConfigPanelProps> = ({
Expand Down Expand Up @@ -134,7 +140,15 @@ export const ReasoningConfigPanel: React.FC<ReasoningConfigPanelProps> = ({
<Button variant="secondary" onClick={onCancel}>
{t('actions.cancel')}
</Button>
<Button variant="primary" disabled={invalid} onClick={() => onApply(draft)}>
<Button
variant="primary"
disabled={invalid}
onClick={() => onApply({
reasoning: draft,
projectionCatalog: draft.catalog ?? { source: 'auto' },
projection: activeGeneratedProjection,
})}
>
{t('reasoningPresets.apply')}
</Button>
</div>
Expand Down