From 02bf65ce0768eef7accadd2f8f61b98ded785532 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: Wed, 20 May 2026 18:00:22 +0800 Subject: [PATCH] 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. --- .../src/__tests__/generalSettings.test.tsx | 33 ++++- desktop/src/i18n/locales/en.ts | 8 +- desktop/src/i18n/locales/zh.ts | 8 +- desktop/src/pages/Settings.tsx | 129 +++++++++++++++--- src/server/__tests__/conversations.test.ts | 49 +++++++ src/server/ws/handler.ts | 15 +- 6 files changed, 214 insertions(+), 28 deletions(-) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 12277322..529a8a62 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -260,7 +260,7 @@ describe('Settings > General tab', () => { updateH5AccessSettings: vi.fn(), }) - useUIStore.setState({ pendingSettingsTab: null }) + useUIStore.setState({ pendingSettingsTab: null, toasts: [] }) useUpdateStore.setState({ status: 'idle', availableVersion: null, @@ -345,7 +345,11 @@ describe('Settings > General tab', () => { expect(saveButton).toBeDisabled() 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 () => { fireEvent.click(saveButton) @@ -358,6 +362,31 @@ describe('Settings > General tab', () => { 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() + + 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', () => { diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 39790c08..cf360766 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -909,7 +909,13 @@ export const en = { 'settings.general.networkProxyUrlRequired': 'Enter a proxy URL.', 'settings.general.networkTimeout': 'AI request timeout', '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.networkSave': 'Save', 'settings.general.webFetchPreflightTitle': 'WebFetch Preflight', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 9b2be869..141f7355 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -911,7 +911,13 @@ export const zh: Record = { 'settings.general.networkProxyUrlRequired': '请输入代理地址。', 'settings.general.networkTimeout': 'AI 请求超时', '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.networkSave': '保存', 'settings.general.webFetchPreflightTitle': 'WebFetch 预检', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 7a070763..2be7029d 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -52,6 +52,10 @@ import { } from '../lib/providerSettingsJson' 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 { if (!baseUrl) return null @@ -1444,6 +1448,7 @@ function GeneralSettings() { const t = useTranslation() const [webSearchDraft, setWebSearchDraft] = useState(webSearch) const [networkDraft, setNetworkDraft] = useState(network) + const [networkTimeoutInput, setNetworkTimeoutInput] = useState(String(Math.round(network.aiRequestTimeoutMs / 1000))) const [networkSaveError, setNetworkSaveError] = useState(null) const [isSavingNetwork, setIsSavingNetwork] = useState(false) const [notificationPermission, setNotificationPermission] = useState('default') @@ -1457,6 +1462,7 @@ function GeneralSettings() { const [uiZoomDraft, setUiZoomDraft] = useState(uiZoom) const [isUiZoomDragging, setIsUiZoomDragging] = useState(false) const isUiZoomDraggingRef = useRef(false) + const addToast = useUIStore((s) => s.addToast) const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch) const uiZoomPercent = Math.round(uiZoomDraft * 100) const uiZoomRangeProgress = `${Math.round(((uiZoomDraft - UI_ZOOM_MIN) / (UI_ZOOM_MAX - UI_ZOOM_MIN)) * 1000) / 10}%` @@ -1470,6 +1476,7 @@ function GeneralSettings() { useEffect(() => { setNetworkDraft(network) + setNetworkTimeoutInput(String(Math.round(network.aiRequestTimeoutMs / 1000))) setNetworkSaveError(null) }, [network]) @@ -1624,27 +1631,64 @@ function GeneralSettings() { ? t('settings.general.networkProxyUrlInvalid') : null 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 = networkDraft.aiRequestTimeoutMs !== network.aiRequestTimeoutMs || networkDraft.proxy.mode !== network.proxy.mode || 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 () => { if (networkProxyError) { setNetworkSaveError(networkProxyError) 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) setNetworkSaveError(null) try { await setNetwork({ - aiRequestTimeoutMs: networkDraft.aiRequestTimeoutMs, + aiRequestTimeoutMs: parsedNetworkTimeoutSeconds * 1000, proxy: { mode: networkDraft.proxy.mode, url: networkProxyUrl, }, }) + addToast({ + type: 'success', + message: t('settings.general.networkSaved'), + }) } catch (error) { setNetworkSaveError(error instanceof Error ? error.message : String(error)) } finally { @@ -2047,26 +2091,67 @@ function GeneralSettings() { {t('settings.general.networkTimeoutValue', { seconds: String(timeoutSeconds) })} - { - const seconds = Number(event.currentTarget.value) - setNetworkDraft((current) => ({ - ...current, - aiRequestTimeoutMs: seconds * 1000, - })) - setNetworkSaveError(null) - }} - className="settings-zoom-range w-full" - /> -

- {t('settings.general.networkTimeoutHint')} +

+ +
+ { + 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)]' + }`} + /> + + {t('settings.general.networkTimeoutUnit')} + +
+ +
+

+ {networkTimeoutError ?? t('settings.general.networkTimeoutHint')}

@@ -2078,7 +2163,7 @@ function GeneralSettings() { size="sm" variant="secondary" className="min-w-[72px] px-4 whitespace-nowrap" - disabled={!networkDirty || !!networkProxyError || isSavingNetwork} + disabled={!networkDirty || !!networkProxyError || !!networkTimeoutError || isSavingNetwork} loading={isSavingNetwork} onClick={() => void saveNetworkSettings()} > diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 11251e8d..16cec1e0 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -2411,6 +2411,55 @@ describe('WebSocket Chat Integration', () => { } }, 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 () => { await withMockStreamDelay(150, async () => { const sessionId = `chat-reconnect-${crypto.randomUUID()}` diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 0592a06a..f674620a 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -17,6 +17,7 @@ import { computerUseApprovalService } from '../services/computerUseApprovalServi import { sessionService } from '../services/sessionService.js' import { SettingsService } from '../services/settingsService.js' import { ProviderService } from '../services/providerService.js' +import { isOpenAIOfficialProviderId } from '../services/openaiOfficialProvider.js' import { diagnosticsService } from '../services/diagnosticsService.js' import { deriveTitle, generateTitle, saveAiTitle } from '../services/titleService.js' import { parseSlashCommand } from '../../utils/slashCommandParsing.js' @@ -1638,12 +1639,22 @@ type RuntimeSettings = { 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 { const runtimeOverride = sessionId ? runtimeOverrides.get(sessionId) : undefined if (runtimeOverride) { if (typeof runtimeOverride.providerId === 'string') { const { providers } = await providerService.listProviders() - const providerExists = providers.some((provider) => provider.id === runtimeOverride.providerId) + const providerExists = isKnownRuntimeProviderId(runtimeOverride.providerId, providers) if (!providerExists) { console.warn( `[WS] Ignoring stale runtime provider id for ${sessionId}: ${runtimeOverride.providerId}`, @@ -1676,7 +1687,7 @@ async function getDefaultRuntimeSettings(): Promise { // Check if a custom provider is active const { providers, activeId } = await providerService.listProviders() 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}`) resolvedActiveId = null await providerService.activateOfficial()