feat: 添加 Antigravity (Cloud AI Companion) OAuth 授权支持

This commit is contained in:
song
2025-12-28 15:54:42 +08:00
parent 9bbe468c91
commit 6648e6506c
22 changed files with 1249 additions and 167 deletions

View File

@@ -125,6 +125,31 @@
</svg>
Gemini
</button>
<button
type="button"
@click="form.platform = 'antigravity'"
:class="[
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
form.platform === 'antigravity'
? 'bg-white text-purple-600 shadow-sm dark:bg-dark-600 dark:text-purple-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
]"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z"
/>
</svg>
Antigravity
</button>
</div>
</div>
@@ -477,6 +502,36 @@
</div>
</div>
<!-- Account Type Selection (Antigravity - OAuth only) -->
<div v-if="form.platform === 'antigravity'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
<div class="mt-2">
<div
class="flex items-center gap-3 rounded-lg border-2 border-purple-500 bg-purple-50 p-3 dark:bg-purple-900/20"
>
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-purple-500 text-white">
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.antigravityOauth') }}</span>
</div>
</div>
</div>
</div>
<!-- Add Method (only for Anthropic OAuth-based type) -->
<div v-if="form.platform === 'anthropic' && isOAuthFlow">
<label class="input-label">{{ t('admin.accounts.addMethod') }}</label>
@@ -1072,6 +1127,7 @@ import {
} from '@/composables/useAccountOAuth'
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
import type { Proxy, Group, AccountPlatform, AccountType } from '@/types'
import Modal from '@/components/common/Modal.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
@@ -1094,6 +1150,7 @@ const { t } = useI18n()
const oauthStepTitle = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title')
if (form.platform === 'gemini') return t('admin.accounts.oauth.gemini.title')
if (form.platform === 'antigravity') return t('admin.accounts.oauth.antigravity.title')
return t('admin.accounts.oauth.title')
})
@@ -1115,29 +1172,34 @@ const appStore = useAppStore()
const oauth = useAccountOAuth() // For Anthropic OAuth
const openaiOAuth = useOpenAIOAuth() // For OpenAI OAuth
const geminiOAuth = useGeminiOAuth() // For Gemini OAuth
const antigravityOAuth = useAntigravityOAuth() // For Antigravity OAuth
// Computed: current OAuth state for template binding
const currentAuthUrl = computed(() => {
if (form.platform === 'openai') return openaiOAuth.authUrl.value
if (form.platform === 'gemini') return geminiOAuth.authUrl.value
if (form.platform === 'antigravity') return antigravityOAuth.authUrl.value
return oauth.authUrl.value
})
const currentSessionId = computed(() => {
if (form.platform === 'openai') return openaiOAuth.sessionId.value
if (form.platform === 'gemini') return geminiOAuth.sessionId.value
if (form.platform === 'antigravity') return antigravityOAuth.sessionId.value
return oauth.sessionId.value
})
const currentOAuthLoading = computed(() => {
if (form.platform === 'openai') return openaiOAuth.loading.value
if (form.platform === 'gemini') return geminiOAuth.loading.value
if (form.platform === 'antigravity') return antigravityOAuth.loading.value
return oauth.loading.value
})
const currentOAuthError = computed(() => {
if (form.platform === 'openai') return openaiOAuth.error.value
if (form.platform === 'gemini') return geminiOAuth.error.value
if (form.platform === 'antigravity') return antigravityOAuth.error.value
return oauth.error.value
})
@@ -1366,6 +1428,9 @@ const canExchangeCode = computed(() => {
if (form.platform === 'gemini') {
return authCode.trim() && geminiOAuth.sessionId.value && !geminiOAuth.loading.value
}
if (form.platform === 'antigravity') {
return authCode.trim() && antigravityOAuth.sessionId.value && !antigravityOAuth.loading.value
}
return authCode.trim() && oauth.sessionId.value && !oauth.loading.value
})
@@ -1410,10 +1475,15 @@ watch(
if (newPlatform !== 'anthropic') {
interceptWarmupRequests.value = false
}
// Antigravity only supports OAuth
if (newPlatform === 'antigravity') {
accountCategory.value = 'oauth-based'
}
// Reset OAuth states
oauth.resetState()
openaiOAuth.resetState()
geminiOAuth.resetState()
antigravityOAuth.resetState()
}
)
@@ -1542,6 +1612,7 @@ const resetForm = () => {
oauth.resetState()
openaiOAuth.resetState()
geminiOAuth.resetState()
antigravityOAuth.resetState()
oauthFlowRef.value?.reset()
}
@@ -1620,6 +1691,7 @@ const goBackToBasicInfo = () => {
oauth.resetState()
openaiOAuth.resetState()
geminiOAuth.resetState()
antigravityOAuth.resetState()
oauthFlowRef.value?.reset()
}
@@ -1628,114 +1700,133 @@ const handleGenerateUrl = async () => {
await openaiOAuth.generateAuthUrl(form.proxy_id)
} else if (form.platform === 'gemini') {
await geminiOAuth.generateAuthUrl(form.proxy_id, oauthFlowRef.value?.projectId, geminiOAuthType.value)
} else if (form.platform === 'antigravity') {
await antigravityOAuth.generateAuthUrl(form.proxy_id)
} else {
await oauth.generateAuthUrl(addMethod.value, form.proxy_id)
}
}
const handleExchangeCode = async () => {
const authCode = oauthFlowRef.value?.authCode || ''
// Create account and handle success/failure
const createAccountAndFinish = async (
platform: AccountPlatform,
type: AccountType,
credentials: Record<string, unknown>,
extra?: Record<string, string>
) => {
await adminAPI.accounts.create({
name: form.name,
platform,
type,
credentials,
extra,
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority,
group_ids: form.group_ids
})
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
}
// For OpenAI
if (form.platform === 'openai') {
if (!authCode.trim() || !openaiOAuth.sessionId.value) return
// OpenAI OAuth 授权码兑换
const handleOpenAIExchange = async (authCode: string) => {
if (!authCode.trim() || !openaiOAuth.sessionId.value) return
openaiOAuth.loading.value = true
openaiOAuth.error.value = ''
openaiOAuth.loading.value = true
openaiOAuth.error.value = ''
try {
const tokenInfo = await openaiOAuth.exchangeAuthCode(
authCode.trim(),
openaiOAuth.sessionId.value,
form.proxy_id
)
try {
const tokenInfo = await openaiOAuth.exchangeAuthCode(
authCode.trim(),
openaiOAuth.sessionId.value,
form.proxy_id
)
if (!tokenInfo) return
if (!tokenInfo) {
return // Error already handled by composable
}
const credentials = openaiOAuth.buildCredentials(tokenInfo)
const extra = openaiOAuth.buildExtraInfo(tokenInfo)
// Note: intercept_warmup_requests is Anthropic-only, not applicable to OpenAI
await adminAPI.accounts.create({
name: form.name,
platform: 'openai',
type: 'oauth',
credentials,
extra,
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority,
group_ids: form.group_ids
})
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
} catch (error: any) {
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(openaiOAuth.error.value)
} finally {
openaiOAuth.loading.value = false
}
return
const credentials = openaiOAuth.buildCredentials(tokenInfo)
const extra = openaiOAuth.buildExtraInfo(tokenInfo)
await createAccountAndFinish('openai', 'oauth', credentials, extra)
} catch (error: any) {
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(openaiOAuth.error.value)
} finally {
openaiOAuth.loading.value = false
}
}
// For Gemini
if (form.platform === 'gemini') {
if (!authCode.trim() || !geminiOAuth.sessionId.value) return
// Gemini OAuth 授权码兑换
const handleGeminiExchange = async (authCode: string) => {
if (!authCode.trim() || !geminiOAuth.sessionId.value) return
geminiOAuth.loading.value = true
geminiOAuth.error.value = ''
geminiOAuth.loading.value = true
geminiOAuth.error.value = ''
try {
const stateFromInput = oauthFlowRef.value?.oauthState || ''
const stateToUse = stateFromInput || geminiOAuth.state.value
if (!stateToUse) {
geminiOAuth.error.value = t('admin.accounts.oauth.authFailed')
appStore.showError(geminiOAuth.error.value)
return
}
const tokenInfo = await geminiOAuth.exchangeAuthCode({
code: authCode.trim(),
sessionId: geminiOAuth.sessionId.value,
state: stateToUse,
proxyId: form.proxy_id,
oauthType: geminiOAuthType.value
})
if (!tokenInfo) return
const credentials = geminiOAuth.buildCredentials(tokenInfo)
// Note: intercept_warmup_requests is Anthropic-only, not applicable to Gemini
await adminAPI.accounts.create({
name: form.name,
platform: 'gemini',
type: 'oauth',
credentials,
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority,
group_ids: form.group_ids
})
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
} catch (error: any) {
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
try {
const stateFromInput = oauthFlowRef.value?.oauthState || ''
const stateToUse = stateFromInput || geminiOAuth.state.value
if (!stateToUse) {
geminiOAuth.error.value = t('admin.accounts.oauth.authFailed')
appStore.showError(geminiOAuth.error.value)
} finally {
geminiOAuth.loading.value = false
return
}
return
}
// For Anthropic
const tokenInfo = await geminiOAuth.exchangeAuthCode({
code: authCode.trim(),
sessionId: geminiOAuth.sessionId.value,
state: stateToUse,
proxyId: form.proxy_id,
oauthType: geminiOAuthType.value
})
if (!tokenInfo) return
const credentials = geminiOAuth.buildCredentials(tokenInfo)
await createAccountAndFinish('gemini', 'oauth', credentials)
} catch (error: any) {
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(geminiOAuth.error.value)
} finally {
geminiOAuth.loading.value = false
}
}
// Antigravity OAuth 授权码兑换
const handleAntigravityExchange = async (authCode: string) => {
if (!authCode.trim() || !antigravityOAuth.sessionId.value) return
antigravityOAuth.loading.value = true
antigravityOAuth.error.value = ''
try {
const stateFromInput = oauthFlowRef.value?.oauthState || ''
const stateToUse = stateFromInput || antigravityOAuth.state.value
if (!stateToUse) {
antigravityOAuth.error.value = t('admin.accounts.oauth.authFailed')
appStore.showError(antigravityOAuth.error.value)
return
}
const tokenInfo = await antigravityOAuth.exchangeAuthCode({
code: authCode.trim(),
sessionId: antigravityOAuth.sessionId.value,
state: stateToUse,
proxyId: form.proxy_id
})
if (!tokenInfo) return
const credentials = antigravityOAuth.buildCredentials(tokenInfo)
await createAccountAndFinish('antigravity', 'oauth', credentials)
} catch (error: any) {
antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(antigravityOAuth.error.value)
} finally {
antigravityOAuth.loading.value = false
}
}
// Anthropic OAuth 授权码兑换
const handleAnthropicExchange = async (authCode: string) => {
if (!authCode.trim() || !oauth.sessionId.value) return
oauth.loading.value = true
@@ -1755,28 +1846,11 @@ const handleExchangeCode = async () => {
})
const extra = oauth.buildExtraInfo(tokenInfo)
// Merge interceptWarmupRequests into credentials
const credentials = {
...tokenInfo,
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
}
await adminAPI.accounts.create({
name: form.name,
platform: form.platform,
type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token'
credentials,
extra,
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority,
group_ids: form.group_ids
})
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
await createAccountAndFinish(form.platform, addMethod.value as AccountType, credentials, extra)
} catch (error: any) {
oauth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(oauth.error.value)
@@ -1785,6 +1859,22 @@ const handleExchangeCode = async () => {
}
}
// 主入口:根据平台路由到对应处理函数
const handleExchangeCode = async () => {
const authCode = oauthFlowRef.value?.authCode || ''
switch (form.platform) {
case 'openai':
return handleOpenAIExchange(authCode)
case 'gemini':
return handleGeminiExchange(authCode)
case 'antigravity':
return handleAntigravityExchange(authCode)
default:
return handleAnthropicExchange(authCode)
}
}
const handleCookieAuth = async (sessionKey: string) => {
oauth.loading.value = true
oauth.error.value = ''

View File

@@ -527,7 +527,7 @@ interface Props {
allowMultiple?: boolean
methodLabel?: string
showCookieOption?: boolean // Whether to show cookie auto-auth option
platform?: 'anthropic' | 'openai' | 'gemini' // Platform type for different UI/text
platform?: 'anthropic' | 'openai' | 'gemini' | 'antigravity' // Platform type for different UI/text
showProjectId?: boolean // New prop to control project ID visibility
}
@@ -560,6 +560,7 @@ const isOpenAI = computed(() => props.platform === 'openai')
const getOAuthKey = (key: string) => {
if (props.platform === 'openai') return `admin.accounts.oauth.openai.${key}`
if (props.platform === 'gemini') return `admin.accounts.oauth.gemini.${key}`
if (props.platform === 'antigravity') return `admin.accounts.oauth.antigravity.${key}`
return `admin.accounts.oauth.${key}`
}
@@ -575,9 +576,11 @@ const oauthAuthCodeDesc = computed(() => t(getOAuthKey('authCodeDesc')))
const oauthAuthCode = computed(() => t(getOAuthKey('authCode')))
const oauthAuthCodePlaceholder = computed(() => t(getOAuthKey('authCodePlaceholder')))
const oauthAuthCodeHint = computed(() => t(getOAuthKey('authCodeHint')))
const oauthImportantNotice = computed(() =>
props.platform === 'openai' ? t('admin.accounts.oauth.openai.importantNotice') : ''
)
const oauthImportantNotice = computed(() => {
if (props.platform === 'openai') return t('admin.accounts.oauth.openai.importantNotice')
if (props.platform === 'antigravity') return t('admin.accounts.oauth.antigravity.importantNotice')
return ''
})
// Local state
const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'manual')
@@ -603,10 +606,10 @@ watch(inputMethod, (newVal) => {
emit('update:inputMethod', newVal)
})
// Auto-extract code from OpenAI callback URL
// e.g., http://localhost:1455/auth/callback?code=ac_xxx...&scope=...&state=...
// Auto-extract code from callback URL (OpenAI/Gemini/Antigravity)
// e.g., http://localhost:8085/callback?code=xxx...&state=...
watch(authCodeInput, (newVal) => {
if (props.platform !== 'openai' && props.platform !== 'gemini') return
if (props.platform !== 'openai' && props.platform !== 'gemini' && props.platform !== 'antigravity') return
const trimmed = newVal.trim()
// Check if it looks like a URL with code parameter
@@ -616,7 +619,7 @@ watch(authCodeInput, (newVal) => {
const url = new URL(trimmed)
const code = url.searchParams.get('code')
const stateParam = url.searchParams.get('state')
if (props.platform === 'gemini' && stateParam) {
if ((props.platform === 'gemini' || props.platform === 'antigravity') && stateParam) {
oauthState.value = stateParam
}
if (code && code !== trimmed) {
@@ -627,7 +630,7 @@ watch(authCodeInput, (newVal) => {
// If URL parsing fails, try regex extraction
const match = trimmed.match(/[?&]code=([^&]+)/)
const stateMatch = trimmed.match(/[?&]state=([^&]+)/)
if (props.platform === 'gemini' && stateMatch && stateMatch[1]) {
if ((props.platform === 'gemini' || props.platform === 'antigravity') && stateMatch && stateMatch[1]) {
oauthState.value = stateMatch[1]
}
if (match && match[1] && match[1] !== trimmed) {

View File

@@ -15,6 +15,10 @@
<svg v-else-if="platform === 'gemini'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2l1.89 7.2L21 12l-7.11 2.8L12 22l-1.89-7.2L3 12l7.11-2.8L12 2z" />
</svg>
<!-- Antigravity logo (cloud) -->
<svg v-else-if="platform === 'antigravity'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z" />
</svg>
<!-- Fallback: generic platform icon -->
<svg v-else :class="sizeClass" fill="currentColor" viewBox="0 0 24 24">
<path

View File

@@ -72,6 +72,7 @@ const props = defineProps<Props>()
const platformLabel = computed(() => {
if (props.platform === 'anthropic') return 'Anthropic'
if (props.platform === 'openai') return 'OpenAI'
if (props.platform === 'antigravity') return 'Antigravity'
return 'Gemini'
})
@@ -95,6 +96,9 @@ const platformClass = computed(() => {
if (props.platform === 'openai') {
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
}
if (props.platform === 'antigravity') {
return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
}
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
})
@@ -105,6 +109,9 @@ const typeClass = computed(() => {
if (props.platform === 'openai') {
return 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400'
}
if (props.platform === 'antigravity') {
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400'
}
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
})
</script>