mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
1ff563d4b0
commit
6e1ba2753e
@ -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 />)
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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: '',
|
||||
},
|
||||
]
|
||||
|
||||
@ -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)',
|
||||
|
||||
@ -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)',
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user