From 6e1ba2753e09a665ac0a156f55dd772381fc3fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 18 May 2026 23:45:53 +0800 Subject: [PATCH] Represent ChatGPT Official as a desktop built-in provider ChatGPT Official uses a real provider id, so desktop UI consumers cannot keep treating only null as the official lane. The model selector now exposes the GPT catalog for that built-in provider, and Settings renders a separate active card instead of folding it into Claude Official state. Constraint: Claude Official remains represented by activeId null. Rejected: Reuse the Claude Official card for ChatGPT Official | it hides the active ChatGPT provider and shows the wrong login surface. Confidence: high Scope-risk: narrow Tested: cd desktop && bun run test -- src/components/controls/ModelSelector.test.tsx Tested: cd desktop && bun run test -- src/__tests__/generalSettings.test.tsx --testNamePattern "Providers tab" Tested: git diff --check --- .../src/__tests__/generalSettings.test.tsx | 14 +++++ .../controls/ModelSelector.test.tsx | 51 +++++++++++++++++++ .../src/components/controls/ModelSelector.tsx | 41 ++++++++++++--- .../src/constants/openaiOfficialProvider.ts | 30 +++++++++++ desktop/src/i18n/locales/en.ts | 2 + desktop/src/i18n/locales/zh.ts | 2 + desktop/src/pages/Settings.tsx | 40 ++++++++++++--- 7 files changed, 168 insertions(+), 12 deletions(-) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 2cba509e..f59e7447 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -668,6 +668,7 @@ describe('Settings > Providers tab', () => { MOCK_DELETE_PROVIDER.mockReset() MOCK_GET_SETTINGS.mockResolvedValue({}) MOCK_UPDATE_SETTINGS.mockResolvedValue({}) + useSettingsStore.setState({ locale: 'en' }) providerStoreState.providers = [ { id: 'provider-1', @@ -709,6 +710,19 @@ describe('Settings > Providers tab', () => { expect(screen.getByTestId('claude-official-login')).toBeInTheDocument() }) + it('shows ChatGPT Official as the active built-in provider', () => { + providerStoreState.providers = [] + providerStoreState.activeId = 'openai-official' + providerStoreState.hasLoadedProviders = true + + render() + + const openAIProvider = screen.getByTestId('openai-official-provider') + expect(within(openAIProvider).getByText('ChatGPT Official')).toBeInTheDocument() + expect(within(openAIProvider).getByText('Default')).toBeInTheDocument() + expect(screen.queryByTestId('claude-official-login')).not.toBeInTheDocument() + }) + it('requires confirmation before deleting a provider', async () => { render() diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx index 7c98d126..59f9e0ea 100644 --- a/desktop/src/components/controls/ModelSelector.test.tsx +++ b/desktop/src/components/controls/ModelSelector.test.tsx @@ -7,6 +7,7 @@ import { useChatStore } from '../../stores/chatStore' import { useProviderStore } from '../../stores/providerStore' import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore' import { useSettingsStore } from '../../stores/settingsStore' +import { OPENAI_OFFICIAL_PROVIDER_ID } from '../../constants/openaiOfficialProvider' import type { ModelInfo } from '../../types/settings' const MODELS: ModelInfo[] = [ @@ -118,6 +119,56 @@ describe('ModelSelector', () => { }) }) + it('uses the ChatGPT Official catalog when that built-in provider is active', async () => { + const openAIModels: ModelInfo[] = [ + { + id: 'gpt-5.3-codex', + name: 'GPT-5.3 Codex', + description: 'Best for coding and agentic work', + context: '', + }, + { + id: 'gpt-5.5', + name: 'GPT-5.5', + description: 'Latest general-purpose model', + context: '', + }, + ] + const setSessionRuntime = vi.fn() + useSettingsStore.setState({ + locale: 'en', + availableModels: openAIModels, + currentModel: openAIModels[0], + activeProviderName: 'ChatGPT Official', + }) + useProviderStore.setState({ + providers: [], + activeId: OPENAI_OFFICIAL_PROVIDER_ID, + hasLoadedProviders: true, + isLoading: true, + }) + useChatStore.setState({ + setSessionRuntime, + } as Partial>) + + render() + + await clickByRole(/GPT-5\.3 Codex/i) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /GPT-5\.5/ })) + await Promise.resolve() + }) + + expect(useSessionRuntimeStore.getState().selections['session-openai']).toEqual({ + providerId: OPENAI_OFFICIAL_PROVIDER_ID, + modelId: 'gpt-5.5', + }) + expect(setSessionRuntime).toHaveBeenCalledWith('session-openai', { + providerId: OPENAI_OFFICIAL_PROVIDER_ID, + modelId: 'gpt-5.5', + }) + }) + it('portals the dropdown outside clipping containers and positions it below the trigger', async () => { useSettingsStore.setState({ locale: 'en', diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index a1a88014..b39ed9a7 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -1,6 +1,11 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { OFFICIAL_DEFAULT_MODEL_ID, OFFICIAL_MODELS } from '../../constants/modelCatalog' +import { + OPENAI_OFFICIAL_DEFAULT_MODEL_ID, + OPENAI_OFFICIAL_MODELS, + OPENAI_OFFICIAL_PROVIDER_ID, +} from '../../constants/openaiOfficialProvider' import { useTranslation } from '../../i18n' import { useChatStore } from '../../stores/chatStore' import { useProviderStore } from '../../stores/providerStore' @@ -43,12 +48,17 @@ const VIEWPORT_MARGIN = 16 const DROPDOWN_MAX_HEIGHT = 420 const DROPDOWN_MIN_HEIGHT = 180 -function officialChoices(availableModels: ModelInfo[], isDefault: boolean, officialName: string): ProviderChoice { +function officialChoices( + providerId: string | null, + models: ModelInfo[], + isDefault: boolean, + officialName: string, +): ProviderChoice { return { - providerId: null, + providerId, providerName: officialName, isDefault, - models: availableModels.length > 0 ? availableModels : OFFICIAL_MODELS, + models, } } @@ -89,10 +99,24 @@ function buildProviderChoices( activeId: string | null, availableModels: ModelInfo[], officialName: string, + openAIOfficialName: string, labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>, ): ProviderChoice[] { + const claudeOfficialModels = activeId === null && availableModels.length > 0 + ? availableModels + : OFFICIAL_MODELS + const openAIOfficialModels = activeId === OPENAI_OFFICIAL_PROVIDER_ID && availableModels.length > 0 + ? availableModels + : OPENAI_OFFICIAL_MODELS + return [ - officialChoices(availableModels, activeId === null, officialName), + officialChoices(null, claudeOfficialModels, activeId === null, officialName), + officialChoices( + OPENAI_OFFICIAL_PROVIDER_ID, + openAIOfficialModels, + activeId === OPENAI_OFFICIAL_PROVIDER_ID, + openAIOfficialName, + ), ...providers.map((provider) => ({ providerId: provider.id, providerName: provider.name, @@ -116,7 +140,11 @@ function resolveDefaultRuntimeSelection( return { providerId: inferredProviderId, - modelId: currentModelId ?? OFFICIAL_DEFAULT_MODEL_ID, + modelId: currentModelId ?? ( + inferredProviderId === OPENAI_OFFICIAL_PROVIDER_ID + ? OPENAI_OFFICIAL_DEFAULT_MODEL_ID + : OFFICIAL_DEFAULT_MODEL_ID + ), } } @@ -258,8 +286,9 @@ export function ModelSelector({ () => buildProviderChoices( providers, activeId, - activeId === null ? availableModels : OFFICIAL_MODELS, + availableModels, t('settings.providers.officialName'), + t('settings.providers.openaiOfficialName'), roleLabels, ), [activeId, availableModels, providers, roleLabels, t], diff --git a/desktop/src/constants/openaiOfficialProvider.ts b/desktop/src/constants/openaiOfficialProvider.ts index fce7d9e8..d38947e1 100644 --- a/desktop/src/constants/openaiOfficialProvider.ts +++ b/desktop/src/constants/openaiOfficialProvider.ts @@ -1,2 +1,32 @@ +import type { ModelInfo } from '../types/settings' + export const OPENAI_OFFICIAL_PROVIDER_ID = 'openai-official' export const OPENAI_OFFICIAL_DEFAULT_MODEL_ID = 'gpt-5.3-codex' +export const OPENAI_OFFICIAL_PROVIDER_NAME = 'ChatGPT Official' + +export const OPENAI_OFFICIAL_MODELS: ModelInfo[] = [ + { + id: OPENAI_OFFICIAL_DEFAULT_MODEL_ID, + name: 'GPT-5.3 Codex', + description: 'Best for coding and agentic work', + context: '', + }, + { + id: 'gpt-5.4', + name: 'GPT-5.4', + description: 'Strong general-purpose model', + context: '', + }, + { + id: 'gpt-5.5', + name: 'GPT-5.5', + description: 'Latest general-purpose model', + context: '', + }, + { + id: 'gpt-5.4-mini', + name: 'GPT-5.4 Mini', + description: 'Fastest for quick tasks', + context: '', + }, +] diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 76ff2c90..ad1f0d1f 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -277,6 +277,8 @@ export const en = { 'settings.providers.addProvider': 'Add Provider', 'settings.providers.officialName': 'Claude Official', 'settings.providers.officialDesc': 'Anthropic native — no API key required', + 'settings.providers.openaiOfficialName': 'ChatGPT Official', + 'settings.providers.openaiOfficialDesc': 'OpenAI OAuth via your ChatGPT account — no API key required', 'settings.providers.connected': 'Connected ({latency}ms)', 'settings.providers.failed': 'Failed: {error}', 'settings.providers.connectivityOk': '① Connectivity ({latency}ms)', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index f2ad0c1b..3bed1c29 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -279,6 +279,8 @@ export const zh: Record = { 'settings.providers.addProvider': '添加服务商', 'settings.providers.officialName': 'Claude 官方', 'settings.providers.officialDesc': 'Anthropic 原生接入 — 无需 API 密钥', + 'settings.providers.openaiOfficialName': 'ChatGPT 官方', + 'settings.providers.openaiOfficialDesc': '通过 ChatGPT 账号完成 OpenAI OAuth — 无需 API 密钥', 'settings.providers.connected': '已连接 ({latency}ms)', 'settings.providers.failed': '失败: {error}', 'settings.providers.connectivityOk': '① 连通 ({latency}ms)', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 1bdbbef6..68675b38 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -32,6 +32,7 @@ import { ActivitySettings } from './ActivitySettings' import { MemorySettings } from './MemorySettings' import { useUIStore, type SettingsTab } from '../stores/uiStore' import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin' +import { OPENAI_OFFICIAL_PROVIDER_ID } from '../constants/openaiOfficialProvider' import { useUpdateStore } from '../stores/updateStore' import { formatBytes } from '../lib/formatBytes' import { isTauriRuntime } from '../lib/desktopRuntime' @@ -216,7 +217,8 @@ function ProviderSettings() { await fetchSettings() } - const isOfficialActive = hasLoadedProviders && activeId === null + const isClaudeOfficialActive = hasLoadedProviders && activeId === null + const isOpenAIOfficialActive = hasLoadedProviders && activeId === OPENAI_OFFICIAL_PROVIDER_ID return (
@@ -233,21 +235,22 @@ function ProviderSettings() { {/* Official provider — always visible at top */}
!isOfficialActive && handleActivateOfficial()} + onClick={() => !isClaudeOfficialActive && handleActivateOfficial()} > - +
{t('settings.providers.officialName')} - {isOfficialActive && ( + {isClaudeOfficialActive && ( {t('settings.providers.default')} )}
@@ -255,13 +258,38 @@ function ProviderSettings() {
- {isOfficialActive && ( + {isClaudeOfficialActive && (
)}
+
+
!isOpenAIOfficialActive && handleActivate(OPENAI_OFFICIAL_PROVIDER_ID)} + > + +
+
+ {t('settings.providers.openaiOfficialName')} + {isOpenAIOfficialActive && ( + {t('settings.providers.default')} + )} +
+
{t('settings.providers.openaiOfficialDesc')}
+
+
+
+ {/* Saved providers */} {isLoading && providers.length === 0 ? (