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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-18 23:45:53 +08:00
parent 1ff563d4b0
commit 6e1ba2753e
7 changed files with 168 additions and 12 deletions

View File

@ -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(<Settings />)
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(<Settings />)

View File

@ -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<ReturnType<typeof useChatStore.getState>>)
render(<ModelSelector runtimeKey="session-openai" />)
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',

View File

@ -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],

View File

@ -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: '',
},
]

View File

@ -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)',

View File

@ -279,6 +279,8 @@ export const zh: Record<TranslationKey, string> = {
'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)',

View File

@ -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 (
<div className="max-w-2xl">
@ -233,21 +235,22 @@ function ProviderSettings() {
{/* Official provider — always visible at top */}
<div
data-testid="claude-official-provider"
className={`relative flex flex-col rounded-xl border transition-all mb-2 ${
isOfficialActive
isClaudeOfficialActive
? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] cursor-pointer'
}`}
>
<div
className="flex items-center gap-4 px-4 py-3.5"
onClick={() => !isOfficialActive && handleActivateOfficial()}
onClick={() => !isClaudeOfficialActive && handleActivateOfficial()}
>
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isOfficialActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isClaudeOfficialActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.providers.officialName')}</span>
{isOfficialActive && (
{isClaudeOfficialActive && (
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('settings.providers.default')}</span>
)}
</div>
@ -255,13 +258,38 @@ function ProviderSettings() {
</div>
</div>
{isOfficialActive && (
{isClaudeOfficialActive && (
<div className="px-4 pb-4 pt-3 border-t border-[var(--color-border-separator)]">
<ClaudeOfficialLogin />
</div>
)}
</div>
<div
data-testid="openai-official-provider"
className={`relative flex flex-col rounded-xl border transition-all mb-2 ${
isOpenAIOfficialActive
? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] cursor-pointer'
}`}
>
<div
className="flex items-center gap-4 px-4 py-3.5"
onClick={() => !isOpenAIOfficialActive && handleActivate(OPENAI_OFFICIAL_PROVIDER_ID)}
>
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isOpenAIOfficialActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.providers.openaiOfficialName')}</span>
{isOpenAIOfficialActive && (
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('settings.providers.default')}</span>
)}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.openaiOfficialDesc')}</div>
</div>
</div>
</div>
{/* Saved providers */}
{isLoading && providers.length === 0 ? (
<div className="flex justify-center py-8">