fix: Preserve ChatGPT Official after desktop restart

The WebSocket runtime treated every active provider id as stale unless it existed in the saved custom-provider list. ChatGPT Official is an in-memory built-in provider, so restarting the desktop could activate Claude Official and rewrite the provider index back to null before a new session launched. Keep built-in OpenAI provider ids valid in runtime validation, while preserving the stale custom-provider cleanup path.

This also keeps the General network timeout UI aligned with the authoritative timeout behavior by allowing precise typed values and updating the user-facing hint.

Constraint: ChatGPT Official is a built-in provider id and is not stored in the custom providers array.
Rejected: Persist ChatGPT Official as a synthetic saved provider | it would mix token-backed built-ins with user-managed providers and complicate secret persistence.
Confidence: high
Scope-risk: moderate
Directive: Runtime provider validation must include built-in provider ids as well as saved custom provider ids.
Tested: bun test src/server/__tests__/conversations.test.ts -t "preserve ChatGPT Official"
Tested: bun test src/server/__tests__/conversations.test.ts -t "stale persisted|preserve ChatGPT Official"
Tested: bun run check:server
Tested: bun run check:desktop
Not-tested: Full bun run verify after the final interruption; user explicitly asked to stop verification and commit directly.
Not-tested: Manual desktop restart with a real ChatGPT OAuth account in the packaged app.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-20 18:00:22 +08:00
parent 4343b3f039
commit 02bf65ce07
6 changed files with 214 additions and 28 deletions

View File

@ -260,7 +260,7 @@ describe('Settings > General tab', () => {
updateH5AccessSettings: vi.fn(), updateH5AccessSettings: vi.fn(),
}) })
useUIStore.setState({ pendingSettingsTab: null }) useUIStore.setState({ pendingSettingsTab: null, toasts: [] })
useUpdateStore.setState({ useUpdateStore.setState({
status: 'idle', status: 'idle',
availableVersion: null, availableVersion: null,
@ -345,7 +345,11 @@ describe('Settings > General tab', () => {
expect(saveButton).toBeDisabled() expect(saveButton).toBeDisabled()
fireEvent.change(proxyInput, { target: { value: ' http://127.0.0.1:7890 ' } }) fireEvent.change(proxyInput, { target: { value: ' http://127.0.0.1:7890 ' } })
fireEvent.change(screen.getByLabelText('AI request timeout'), { target: { value: '180' } }) const timeoutInput = screen.getByLabelText('AI request timeout')
expect(timeoutInput).toHaveAttribute('type', 'number')
expect(screen.queryByRole('slider', { name: 'AI request timeout' })).not.toBeInTheDocument()
fireEvent.change(timeoutInput, { target: { value: '180' } })
await act(async () => { await act(async () => {
fireEvent.click(saveButton) fireEvent.click(saveButton)
@ -358,6 +362,31 @@ describe('Settings > General tab', () => {
url: 'http://127.0.0.1:7890', url: 'http://127.0.0.1:7890',
}, },
}) })
expect(useUIStore.getState().toasts[useUIStore.getState().toasts.length - 1]).toMatchObject({
type: 'success',
message: 'Network settings saved.',
})
})
it('validates typed provider network timeout and supports precise step controls', () => {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
const timeoutInput = screen.getByLabelText('AI request timeout')
const saveButton = screen.getAllByRole('button', { name: 'Save' })[0]!
fireEvent.change(timeoutInput, { target: { value: '700' } })
expect(screen.getByText('Enter a whole number from 5 to 600 seconds.')).toBeInTheDocument()
expect(saveButton).toBeDisabled()
fireEvent.change(timeoutInput, { target: { value: '90' } })
fireEvent.click(screen.getByRole('button', { name: 'Increase by 30 seconds' }))
expect(timeoutInput).toHaveValue(120)
fireEvent.click(screen.getByRole('button', { name: 'Decrease by 30 seconds' }))
fireEvent.click(screen.getByRole('button', { name: 'Decrease by 30 seconds' }))
expect(timeoutInput).toHaveValue(60)
expect(saveButton).not.toBeDisabled()
}) })
it('keeps data storage at the bottom of General settings', () => { it('keeps data storage at the bottom of General settings', () => {

View File

@ -909,7 +909,13 @@ export const en = {
'settings.general.networkProxyUrlRequired': 'Enter a proxy URL.', 'settings.general.networkProxyUrlRequired': 'Enter a proxy URL.',
'settings.general.networkTimeout': 'AI request timeout', 'settings.general.networkTimeout': 'AI request timeout',
'settings.general.networkTimeoutValue': '{seconds}s', 'settings.general.networkTimeoutValue': '{seconds}s',
'settings.general.networkTimeoutHint': 'Applies to the first response from streaming provider requests and provider connection tests.', 'settings.general.networkTimeoutHint': 'Applies to provider requests, streaming first responses, and provider connection tests. Supports 5-600 seconds.',
'settings.general.networkTimeoutUnit': 'sec',
'settings.general.networkTimeoutDecrease': 'Decrease by 30 seconds',
'settings.general.networkTimeoutIncrease': 'Increase by 30 seconds',
'settings.general.networkTimeoutRequired': 'Enter a timeout.',
'settings.general.networkTimeoutRange': 'Enter a whole number from {min} to {max} seconds.',
'settings.general.networkSaved': 'Network settings saved.',
'settings.general.networkScopeHint': 'Does not change the separate app update proxy.', 'settings.general.networkScopeHint': 'Does not change the separate app update proxy.',
'settings.general.networkSave': 'Save', 'settings.general.networkSave': 'Save',
'settings.general.webFetchPreflightTitle': 'WebFetch Preflight', 'settings.general.webFetchPreflightTitle': 'WebFetch Preflight',

View File

@ -911,7 +911,13 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.networkProxyUrlRequired': '请输入代理地址。', 'settings.general.networkProxyUrlRequired': '请输入代理地址。',
'settings.general.networkTimeout': 'AI 请求超时', 'settings.general.networkTimeout': 'AI 请求超时',
'settings.general.networkTimeoutValue': '{seconds} 秒', 'settings.general.networkTimeoutValue': '{seconds} 秒',
'settings.general.networkTimeoutHint': '用于流式服务商请求的首个响应,以及服务商连接测试。', 'settings.general.networkTimeoutHint': '用于服务商请求、流式首个响应,以及服务商连接测试。支持 5-600 秒。',
'settings.general.networkTimeoutUnit': '秒',
'settings.general.networkTimeoutDecrease': '减少 30 秒',
'settings.general.networkTimeoutIncrease': '增加 30 秒',
'settings.general.networkTimeoutRequired': '请输入超时时间。',
'settings.general.networkTimeoutRange': '请输入 {min}-{max} 秒之间的整数。',
'settings.general.networkSaved': '网络设置已保存。',
'settings.general.networkScopeHint': '不会影响单独的应用更新代理。', 'settings.general.networkScopeHint': '不会影响单独的应用更新代理。',
'settings.general.networkSave': '保存', 'settings.general.networkSave': '保存',
'settings.general.webFetchPreflightTitle': 'WebFetch 预检', 'settings.general.webFetchPreflightTitle': 'WebFetch 预检',

View File

@ -52,6 +52,10 @@ import {
} from '../lib/providerSettingsJson' } from '../lib/providerSettingsJson'
import { copyTextToClipboard } from '../components/chat/clipboard' import { copyTextToClipboard } from '../components/chat/clipboard'
const NETWORK_TIMEOUT_MIN_SECONDS = 5
const NETWORK_TIMEOUT_MAX_SECONDS = 600
const NETWORK_TIMEOUT_STEP_SECONDS = 30
function buildH5LaunchUrl(baseUrl: string | null, token: string | null): string | null { function buildH5LaunchUrl(baseUrl: string | null, token: string | null): string | null {
if (!baseUrl) return null if (!baseUrl) return null
@ -1444,6 +1448,7 @@ function GeneralSettings() {
const t = useTranslation() const t = useTranslation()
const [webSearchDraft, setWebSearchDraft] = useState(webSearch) const [webSearchDraft, setWebSearchDraft] = useState(webSearch)
const [networkDraft, setNetworkDraft] = useState(network) const [networkDraft, setNetworkDraft] = useState(network)
const [networkTimeoutInput, setNetworkTimeoutInput] = useState(String(Math.round(network.aiRequestTimeoutMs / 1000)))
const [networkSaveError, setNetworkSaveError] = useState<string | null>(null) const [networkSaveError, setNetworkSaveError] = useState<string | null>(null)
const [isSavingNetwork, setIsSavingNetwork] = useState(false) const [isSavingNetwork, setIsSavingNetwork] = useState(false)
const [notificationPermission, setNotificationPermission] = useState<DesktopNotificationPermission>('default') const [notificationPermission, setNotificationPermission] = useState<DesktopNotificationPermission>('default')
@ -1457,6 +1462,7 @@ function GeneralSettings() {
const [uiZoomDraft, setUiZoomDraft] = useState(uiZoom) const [uiZoomDraft, setUiZoomDraft] = useState(uiZoom)
const [isUiZoomDragging, setIsUiZoomDragging] = useState(false) const [isUiZoomDragging, setIsUiZoomDragging] = useState(false)
const isUiZoomDraggingRef = useRef(false) const isUiZoomDraggingRef = useRef(false)
const addToast = useUIStore((s) => s.addToast)
const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch) const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch)
const uiZoomPercent = Math.round(uiZoomDraft * 100) const uiZoomPercent = Math.round(uiZoomDraft * 100)
const uiZoomRangeProgress = `${Math.round(((uiZoomDraft - UI_ZOOM_MIN) / (UI_ZOOM_MAX - UI_ZOOM_MIN)) * 1000) / 10}%` const uiZoomRangeProgress = `${Math.round(((uiZoomDraft - UI_ZOOM_MIN) / (UI_ZOOM_MAX - UI_ZOOM_MIN)) * 1000) / 10}%`
@ -1470,6 +1476,7 @@ function GeneralSettings() {
useEffect(() => { useEffect(() => {
setNetworkDraft(network) setNetworkDraft(network)
setNetworkTimeoutInput(String(Math.round(network.aiRequestTimeoutMs / 1000)))
setNetworkSaveError(null) setNetworkSaveError(null)
}, [network]) }, [network])
@ -1624,27 +1631,64 @@ function GeneralSettings() {
? t('settings.general.networkProxyUrlInvalid') ? t('settings.general.networkProxyUrlInvalid')
: null : null
const timeoutSeconds = Math.round(networkDraft.aiRequestTimeoutMs / 1000) const timeoutSeconds = Math.round(networkDraft.aiRequestTimeoutMs / 1000)
const parsedNetworkTimeoutSeconds = (() => {
const trimmed = networkTimeoutInput.trim()
if (!/^\d+$/.test(trimmed)) return null
const seconds = Number(trimmed)
if (!Number.isFinite(seconds) || seconds < NETWORK_TIMEOUT_MIN_SECONDS || seconds > NETWORK_TIMEOUT_MAX_SECONDS) return null
return seconds
})()
const networkTimeoutError =
networkTimeoutInput.trim().length === 0
? t('settings.general.networkTimeoutRequired')
: parsedNetworkTimeoutSeconds === null
? t('settings.general.networkTimeoutRange', {
min: String(NETWORK_TIMEOUT_MIN_SECONDS),
max: String(NETWORK_TIMEOUT_MAX_SECONDS),
})
: null
const networkDirty = const networkDirty =
networkDraft.aiRequestTimeoutMs !== network.aiRequestTimeoutMs || networkDraft.aiRequestTimeoutMs !== network.aiRequestTimeoutMs ||
networkDraft.proxy.mode !== network.proxy.mode || networkDraft.proxy.mode !== network.proxy.mode ||
networkDraft.proxy.url.trim() !== network.proxy.url.trim() networkDraft.proxy.url.trim() !== network.proxy.url.trim()
const setNetworkTimeoutSeconds = (seconds: number) => {
const nextSeconds = Math.min(Math.max(Math.round(seconds), NETWORK_TIMEOUT_MIN_SECONDS), NETWORK_TIMEOUT_MAX_SECONDS)
setNetworkTimeoutInput(String(nextSeconds))
setNetworkDraft((current) => ({
...current,
aiRequestTimeoutMs: nextSeconds * 1000,
}))
setNetworkSaveError(null)
}
const saveNetworkSettings = async () => { const saveNetworkSettings = async () => {
if (networkProxyError) { if (networkProxyError) {
setNetworkSaveError(networkProxyError) setNetworkSaveError(networkProxyError)
return return
} }
if (networkTimeoutError || parsedNetworkTimeoutSeconds === null) {
setNetworkSaveError(networkTimeoutError ?? t('settings.general.networkTimeoutRange', {
min: String(NETWORK_TIMEOUT_MIN_SECONDS),
max: String(NETWORK_TIMEOUT_MAX_SECONDS),
}))
return
}
setIsSavingNetwork(true) setIsSavingNetwork(true)
setNetworkSaveError(null) setNetworkSaveError(null)
try { try {
await setNetwork({ await setNetwork({
aiRequestTimeoutMs: networkDraft.aiRequestTimeoutMs, aiRequestTimeoutMs: parsedNetworkTimeoutSeconds * 1000,
proxy: { proxy: {
mode: networkDraft.proxy.mode, mode: networkDraft.proxy.mode,
url: networkProxyUrl, url: networkProxyUrl,
}, },
}) })
addToast({
type: 'success',
message: t('settings.general.networkSaved'),
})
} catch (error) { } catch (error) {
setNetworkSaveError(error instanceof Error ? error.message : String(error)) setNetworkSaveError(error instanceof Error ? error.message : String(error))
} finally { } finally {
@ -2047,26 +2091,67 @@ function GeneralSettings() {
{t('settings.general.networkTimeoutValue', { seconds: String(timeoutSeconds) })} {t('settings.general.networkTimeoutValue', { seconds: String(timeoutSeconds) })}
</span> </span>
</div> </div>
<input <div className="flex items-center gap-2">
id="network-timeout-seconds" <Button
type="range" type="button"
min={5} size="sm"
max={600} variant="secondary"
step={5} className="h-10 w-10 px-0"
value={timeoutSeconds} aria-label={t('settings.general.networkTimeoutDecrease')}
aria-label={t('settings.general.networkTimeout')} onClick={() => setNetworkTimeoutSeconds((parsedNetworkTimeoutSeconds ?? timeoutSeconds) - NETWORK_TIMEOUT_STEP_SECONDS)}
onChange={(event) => { >
const seconds = Number(event.currentTarget.value) -30
setNetworkDraft((current) => ({ </Button>
...current, <div className="relative min-w-0 flex-1">
aiRequestTimeoutMs: seconds * 1000, <input
})) id="network-timeout-seconds"
setNetworkSaveError(null) type="number"
}} min={NETWORK_TIMEOUT_MIN_SECONDS}
className="settings-zoom-range w-full" max={NETWORK_TIMEOUT_MAX_SECONDS}
/> step={1}
<p className="mt-2 text-xs leading-5 text-[var(--color-text-tertiary)]"> inputMode="numeric"
{t('settings.general.networkTimeoutHint')} value={networkTimeoutInput}
aria-invalid={networkTimeoutError ? true : undefined}
aria-describedby="network-timeout-help"
onChange={(event) => {
const nextValue = event.currentTarget.value
if (!/^\d*$/.test(nextValue)) return
setNetworkTimeoutInput(nextValue)
const seconds = Number(nextValue)
if (nextValue.length > 0 && seconds >= NETWORK_TIMEOUT_MIN_SECONDS && seconds <= NETWORK_TIMEOUT_MAX_SECONDS) {
setNetworkDraft((current) => ({
...current,
aiRequestTimeoutMs: seconds * 1000,
}))
}
setNetworkSaveError(null)
}}
className={`h-10 w-full rounded-[var(--radius-md)] border bg-[var(--color-surface)] px-3 pr-12 text-sm text-[var(--color-text-primary)] outline-none transition-colors duration-150 placeholder:text-[var(--color-text-tertiary)] ${
networkTimeoutError
? 'border-[var(--color-error)] focus:shadow-[var(--shadow-error-ring)]'
: 'border-[var(--color-border)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]'
}`}
/>
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--color-text-tertiary)]">
{t('settings.general.networkTimeoutUnit')}
</span>
</div>
<Button
type="button"
size="sm"
variant="secondary"
className="h-10 w-10 px-0"
aria-label={t('settings.general.networkTimeoutIncrease')}
onClick={() => setNetworkTimeoutSeconds((parsedNetworkTimeoutSeconds ?? timeoutSeconds) + NETWORK_TIMEOUT_STEP_SECONDS)}
>
+30
</Button>
</div>
<p
id="network-timeout-help"
className={`mt-2 text-xs leading-5 ${networkTimeoutError ? 'text-[var(--color-error)]' : 'text-[var(--color-text-tertiary)]'}`}
>
{networkTimeoutError ?? t('settings.general.networkTimeoutHint')}
</p> </p>
</div> </div>
@ -2078,7 +2163,7 @@ function GeneralSettings() {
size="sm" size="sm"
variant="secondary" variant="secondary"
className="min-w-[72px] px-4 whitespace-nowrap" className="min-w-[72px] px-4 whitespace-nowrap"
disabled={!networkDirty || !!networkProxyError || isSavingNetwork} disabled={!networkDirty || !!networkProxyError || !!networkTimeoutError || isSavingNetwork}
loading={isSavingNetwork} loading={isSavingNetwork}
onClick={() => void saveNetworkSettings()} onClick={() => void saveNetworkSettings()}
> >

View File

@ -2411,6 +2411,55 @@ describe('WebSocket Chat Integration', () => {
} }
}, 20_000) }, 20_000)
it('should preserve ChatGPT Official as the active default runtime after restart', async () => {
const providerService = new ProviderService()
await providerService.activateProvider('openai-official')
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir: process.cwd() }),
})
expect(createRes.status).toBe(201)
const { sessionId } = await createRes.json() as { sessionId: string }
const originalStartSession = conversationService.startSession.bind(conversationService)
const startCalls: Array<{
sessionId: string
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
}> = []
conversationService.startSession = (async function patchedStartSession(
sid: string,
workDir: string,
sdkUrl: string,
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
) {
startCalls.push({ sessionId: sid, options })
return originalStartSession(sid, workDir, sdkUrl, options)
}) as typeof conversationService.startSession
try {
const messages = await runTurn(sessionId, 'default ChatGPT Official runtime')
expect(startCalls).toHaveLength(1)
expect(startCalls[0]).toMatchObject({
sessionId,
options: {
providerId: 'openai-official',
},
})
expect(messages.some((msg) => msg.type === 'message_complete')).toBe(true)
await expect(providerService.listProviders()).resolves.toMatchObject({
activeId: 'openai-official',
})
} finally {
conversationService.startSession = originalStartSession
conversationService.stopSession(sessionId)
await providerService.activateOfficial()
}
}, 20_000)
it('should resume streaming to a reconnected client during an active turn', async () => { it('should resume streaming to a reconnected client during an active turn', async () => {
await withMockStreamDelay(150, async () => { await withMockStreamDelay(150, async () => {
const sessionId = `chat-reconnect-${crypto.randomUUID()}` const sessionId = `chat-reconnect-${crypto.randomUUID()}`

View File

@ -17,6 +17,7 @@ import { computerUseApprovalService } from '../services/computerUseApprovalServi
import { sessionService } from '../services/sessionService.js' import { sessionService } from '../services/sessionService.js'
import { SettingsService } from '../services/settingsService.js' import { SettingsService } from '../services/settingsService.js'
import { ProviderService } from '../services/providerService.js' import { ProviderService } from '../services/providerService.js'
import { isOpenAIOfficialProviderId } from '../services/openaiOfficialProvider.js'
import { diagnosticsService } from '../services/diagnosticsService.js' import { diagnosticsService } from '../services/diagnosticsService.js'
import { deriveTitle, generateTitle, saveAiTitle } from '../services/titleService.js' import { deriveTitle, generateTitle, saveAiTitle } from '../services/titleService.js'
import { parseSlashCommand } from '../../utils/slashCommandParsing.js' import { parseSlashCommand } from '../../utils/slashCommandParsing.js'
@ -1638,12 +1639,22 @@ type RuntimeSettings = {
providerId?: string | null providerId?: string | null
} }
function isKnownRuntimeProviderId(
providerId: string,
providers: Array<{ id: string }>,
): boolean {
return (
isOpenAIOfficialProviderId(providerId) ||
providers.some((provider) => provider.id === providerId)
)
}
async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSettings> { async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSettings> {
const runtimeOverride = sessionId ? runtimeOverrides.get(sessionId) : undefined const runtimeOverride = sessionId ? runtimeOverrides.get(sessionId) : undefined
if (runtimeOverride) { if (runtimeOverride) {
if (typeof runtimeOverride.providerId === 'string') { if (typeof runtimeOverride.providerId === 'string') {
const { providers } = await providerService.listProviders() const { providers } = await providerService.listProviders()
const providerExists = providers.some((provider) => provider.id === runtimeOverride.providerId) const providerExists = isKnownRuntimeProviderId(runtimeOverride.providerId, providers)
if (!providerExists) { if (!providerExists) {
console.warn( console.warn(
`[WS] Ignoring stale runtime provider id for ${sessionId}: ${runtimeOverride.providerId}`, `[WS] Ignoring stale runtime provider id for ${sessionId}: ${runtimeOverride.providerId}`,
@ -1676,7 +1687,7 @@ async function getDefaultRuntimeSettings(): Promise<RuntimeSettings> {
// Check if a custom provider is active // Check if a custom provider is active
const { providers, activeId } = await providerService.listProviders() const { providers, activeId } = await providerService.listProviders()
let resolvedActiveId = activeId let resolvedActiveId = activeId
if (activeId && !providers.some((provider) => provider.id === activeId)) { if (activeId && !isKnownRuntimeProviderId(activeId, providers)) {
console.warn(`[WS] Active provider id is stale, falling back to official provider: ${activeId}`) console.warn(`[WS] Active provider id is stale, falling back to official provider: ${activeId}`)
resolvedActiveId = null resolvedActiveId = null
await providerService.activateOfficial() await providerService.activateOfficial()