feat(frontend): add account Codex image bridge control

This commit is contained in:
shaw
2026-05-07 11:07:33 +08:00
parent 45b1e6ae41
commit 7a9c1d7edd
5 changed files with 220 additions and 38 deletions

View File

@@ -1317,6 +1317,66 @@
</div>
</div>
<!-- OpenAI Codex 图片生成桥接账号级覆盖 -->
<div
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'apikey')"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="overflow-hidden rounded-lg border border-sky-100 bg-sky-50/60 shadow-sm dark:border-sky-900/50 dark:bg-sky-950/20">
<div class="flex items-start gap-3 px-4 py-3">
<div class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-white text-sky-600 shadow-sm ring-1 ring-sky-100 dark:bg-dark-800 dark:text-sky-300 dark:ring-sky-900/60">
<Icon name="sparkles" size="sm" />
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<label class="input-label mb-0">{{ t('admin.accounts.openai.codexImageGenerationBridge') }}</label>
<span
class="rounded-full px-2 py-0.5 text-[11px] font-medium"
:class="codexImageGenerationBridgeBadgeClass"
>
{{ codexImageGenerationBridgeBadgeLabel }}
</span>
</div>
<p class="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">
{{ t('admin.accounts.openai.codexImageGenerationBridgeDesc') }}
</p>
</div>
</div>
<div class="border-t border-sky-100 bg-white/70 p-2 dark:border-sky-900/50 dark:bg-dark-800/70">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
<button
v-for="option in codexImageGenerationBridgeOptions"
:key="option.value"
type="button"
:data-testid="`codex-image-bridge-${option.value}`"
@click="codexImageGenerationBridgeMode = option.value"
:class="[
'group flex min-h-[68px] items-start gap-2 rounded-md border px-3 py-2 text-left transition-all',
codexImageGenerationBridgeMode === option.value
? 'border-sky-300 bg-sky-50 text-sky-900 shadow-sm ring-1 ring-sky-200 dark:border-sky-700 dark:bg-sky-900/25 dark:text-sky-100 dark:ring-sky-800'
: 'border-transparent bg-transparent text-slate-600 hover:border-gray-200 hover:bg-gray-50 dark:text-slate-300 dark:hover:border-dark-500 dark:hover:bg-dark-700'
]"
>
<span
:class="[
'mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border transition-colors',
codexImageGenerationBridgeMode === option.value
? 'border-sky-500 bg-sky-500 text-white'
: 'border-gray-300 text-transparent group-hover:border-gray-400 dark:border-dark-500'
]"
>
<Icon name="check" size="xs" :stroke-width="2" />
</span>
<span class="min-w-0">
<span class="block text-sm font-medium">{{ option.label }}</span>
<span class="mt-0.5 block text-xs leading-4 text-slate-500 dark:text-slate-400">{{ option.description }}</span>
</span>
</button>
</div>
</div>
</div>
</div>
<!-- OpenAI WS Mode 三态off/ctx_pool/passthrough -->
<div
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'apikey')"
@@ -2275,6 +2335,8 @@ const openAICompactMode = ref<OpenAICompactMode>('auto')
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false)
type CodexImageGenerationBridgeMode = 'inherit' | 'enabled' | 'disabled'
const codexImageGenerationBridgeMode = ref<CodexImageGenerationBridgeMode>('inherit')
const anthropicPassthroughEnabled = ref(false)
const webSearchEmulationMode = ref('default')
const webSearchGlobalEnabled = ref(false)
@@ -2325,6 +2387,47 @@ const openaiResponsesWebSocketV2Mode = computed({
const openAIWSModeConcurrencyHintKey = computed(() =>
resolveOpenAIWSModeConcurrencyHintKey(openaiResponsesWebSocketV2Mode.value)
)
const codexImageGenerationBridgeOptions = computed<Array<{
value: CodexImageGenerationBridgeMode
label: string
description: string
}>>(() => [
{
value: 'inherit',
label: t('admin.accounts.openai.codexImageGenerationBridgeInherit'),
description: t('admin.accounts.openai.codexImageGenerationBridgeInheritDesc')
},
{
value: 'enabled',
label: t('admin.accounts.openai.codexImageGenerationBridgeEnabled'),
description: t('admin.accounts.openai.codexImageGenerationBridgeEnabledDesc')
},
{
value: 'disabled',
label: t('admin.accounts.openai.codexImageGenerationBridgeDisabled'),
description: t('admin.accounts.openai.codexImageGenerationBridgeDisabledDesc')
}
])
const codexImageGenerationBridgeBadgeLabel = computed(() => {
switch (codexImageGenerationBridgeMode.value) {
case 'enabled':
return t('admin.accounts.openai.codexImageGenerationBridgeBadgeEnabled')
case 'disabled':
return t('admin.accounts.openai.codexImageGenerationBridgeBadgeDisabled')
default:
return t('admin.accounts.openai.codexImageGenerationBridgeBadgeInherit')
}
})
const codexImageGenerationBridgeBadgeClass = computed(() => {
switch (codexImageGenerationBridgeMode.value) {
case 'enabled':
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
case 'disabled':
return 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300'
default:
return 'bg-slate-100 text-slate-600 dark:bg-dark-600 dark:text-slate-300'
}
})
const openAICompactModeOptions = computed(() => [
{ value: 'auto', label: t('admin.accounts.openai.compactModeAuto') },
{ value: 'force_on', label: t('admin.accounts.openai.compactModeForceOn') },
@@ -2344,7 +2447,7 @@ const openAICompactStatusKey = computed(() => {
? 'admin.accounts.openai.compactSupported'
: 'admin.accounts.openai.compactUnsupported'
}
return 'admin.accounts.openai.compactUnknown'
return 'admin.accounts.openai.compactAuto'
})
// Computed: current preset mappings based on platform
@@ -2483,11 +2586,20 @@ const syncFormFromAccount = (newAccount: Account | null) => {
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false
codexImageGenerationBridgeMode.value = 'inherit'
anthropicPassthroughEnabled.value = false
webSearchEmulationMode.value = 'default'
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) {
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
openAICompactMode.value = (extra?.openai_compact_mode as OpenAICompactMode) || 'auto'
const codexImageGenerationBridgeValue = typeof extra?.codex_image_generation_bridge === 'boolean'
? extra.codex_image_generation_bridge
: extra?.codex_image_generation_bridge_enabled
if (codexImageGenerationBridgeValue === true) {
codexImageGenerationBridgeMode.value = 'enabled'
} else if (codexImageGenerationBridgeValue === false) {
codexImageGenerationBridgeMode.value = 'disabled'
}
openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, {
modeKey: 'openai_oauth_responses_websockets_v2_mode',
enabledKey: 'openai_oauth_responses_websockets_v2_enabled',
@@ -3610,6 +3722,13 @@ const handleSubmit = async () => {
newExtra.openai_compact_mode = openAICompactMode.value
}
delete newExtra.codex_image_generation_bridge_enabled
if (codexImageGenerationBridgeMode.value === 'inherit') {
delete newExtra.codex_image_generation_bridge
} else {
newExtra.codex_image_generation_bridge = codexImageGenerationBridgeMode.value === 'enabled'
}
if (props.account.type === 'oauth') {
if (codexCLIOnlyEnabled.value) {
newExtra.codex_cli_only = true

View File

@@ -216,4 +216,25 @@ describe('EditAccountModal', () => {
'gpt-5.4': 'gpt-5.4-openai-compact'
})
})
it('submits account-level Codex image generation bridge override', async () => {
const account = buildAccount()
account.extra = {
codex_image_generation_bridge: false,
codex_image_generation_bridge_enabled: true
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('button[data-testid="codex-image-bridge-enabled"]').trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.codex_image_generation_bridge).toBe(true)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_bridge_enabled')
})
})