mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
4343b3f039
commit
02bf65ce07
@ -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(<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', () => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -911,7 +911,13 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'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 预检',
|
||||
|
||||
@ -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<string | null>(null)
|
||||
const [isSavingNetwork, setIsSavingNetwork] = useState(false)
|
||||
const [notificationPermission, setNotificationPermission] = useState<DesktopNotificationPermission>('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) })}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="network-timeout-seconds"
|
||||
type="range"
|
||||
min={5}
|
||||
max={600}
|
||||
step={5}
|
||||
value={timeoutSeconds}
|
||||
aria-label={t('settings.general.networkTimeout')}
|
||||
onChange={(event) => {
|
||||
const seconds = Number(event.currentTarget.value)
|
||||
setNetworkDraft((current) => ({
|
||||
...current,
|
||||
aiRequestTimeoutMs: seconds * 1000,
|
||||
}))
|
||||
setNetworkSaveError(null)
|
||||
}}
|
||||
className="settings-zoom-range w-full"
|
||||
/>
|
||||
<p className="mt-2 text-xs leading-5 text-[var(--color-text-tertiary)]">
|
||||
{t('settings.general.networkTimeoutHint')}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-10 w-10 px-0"
|
||||
aria-label={t('settings.general.networkTimeoutDecrease')}
|
||||
onClick={() => setNetworkTimeoutSeconds((parsedNetworkTimeoutSeconds ?? timeoutSeconds) - NETWORK_TIMEOUT_STEP_SECONDS)}
|
||||
>
|
||||
-30
|
||||
</Button>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<input
|
||||
id="network-timeout-seconds"
|
||||
type="number"
|
||||
min={NETWORK_TIMEOUT_MIN_SECONDS}
|
||||
max={NETWORK_TIMEOUT_MAX_SECONDS}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
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>
|
||||
</div>
|
||||
|
||||
@ -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()}
|
||||
>
|
||||
|
||||
@ -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()}`
|
||||
|
||||
@ -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<RuntimeSettings> {
|
||||
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<RuntimeSettings> {
|
||||
// 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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user