feat(antigravity): 对齐 AM 客户端标识,前端 OAuth 增加 TOTP 支持

- User-Agent 改为 Electron 浏览器格式,新增 x-client-name/x-client-version/x-vscode-sessionid/x-goog-api-client header
- 移除 MODEL_ALIAS_MAP 别名映射,改为仅剥离 -online 后缀
- thinking 自动注入关键字新增 gemini-3.1-pro
- 新增定时 Antigravity UA 版本刷新任务(每 6 小时 + 启动时)
- 前端 OAuth 设备授权新增 TOTP 验证码生成(otpauth),Region 改为按钮选择
- SSO OIDC 轮询日志 authorization_pending/slow_down 降级为 debug
This commit is contained in:
fawney19
2026-02-24 01:52:53 +08:00
parent cd70c9148e
commit 7e447fe54e
11 changed files with 290 additions and 92 deletions

View File

@@ -22,6 +22,7 @@
"highlight.js": "^11.11.1",
"lucide-vue-next": "^0.544.0",
"marked": "^16.0.0",
"otpauth": "^9.5.0",
"pinia": "^3.0.3",
"radix-vue": "^1.9.17",
"tailwind-merge": "^3.3.1",
@@ -262,7 +263,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -306,7 +306,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1142,6 +1141,18 @@
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1600,7 +1611,6 @@
"integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.10.0"
}
@@ -1679,7 +1689,6 @@
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.49.0",
"@typescript-eslint/types": "8.49.0",
@@ -2008,7 +2017,6 @@
"integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/utils": "4.0.10",
"fflate": "^0.8.2",
@@ -2306,7 +2314,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2608,7 +2615,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.2",
"caniuse-lite": "^1.0.30001741",
@@ -2725,7 +2731,6 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -2948,7 +2953,6 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@@ -3201,7 +3205,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4101,7 +4104,6 @@
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.23",
"@asamuzakjp/dom-selector": "^6.7.4",
@@ -4512,6 +4514,18 @@
"node": ">= 0.8.0"
}
},
"node_modules/otpauth": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.0.tgz",
"integrity": "sha512-Ldhc6UYl4baR5toGr8nfKC+L/b8/RgHKoIixAebgoNGzUUCET02g04rMEZ2ZsPfeVQhMHcuaOgb28nwMr81zCA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1"
},
"funding": {
"url": "https://github.com/hectorm/otpauth?sponsor=1"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -4666,7 +4680,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4734,7 +4747,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -5765,7 +5777,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5859,7 +5870,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -5935,7 +5945,6 @@
"integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.10",
"@vitest/mocker": "4.0.10",
@@ -6020,7 +6029,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz",
"integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.21",
"@vue/compiler-sfc": "3.5.21",
@@ -6053,6 +6061,7 @@
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"eslint-scope": "^8.2.0",
@@ -6077,6 +6086,7 @@
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},

View File

@@ -30,6 +30,7 @@
"highlight.js": "^11.11.1",
"lucide-vue-next": "^0.544.0",
"marked": "^16.0.0",
"otpauth": "^9.5.0",
"pinia": "^3.0.3",
"radix-vue": "^1.9.17",
"tailwind-merge": "^3.3.1",

View File

@@ -0,0 +1,79 @@
import { ref, computed, onBeforeUnmount } from 'vue'
import { TOTP, Secret } from 'otpauth'
/**
* TOTP composable: 给定 secret 持续生成验证码并显示剩余秒数。
*/
export function useTotp() {
const secret = ref('')
const code = ref('')
const remaining = ref(0)
const error = ref('')
let timer: ReturnType<typeof setInterval> | null = null
let totp: TOTP | null = null
function parseSecret(raw: string): Secret | null {
const cleaned = raw.replace(/[\s-]/g, '')
if (!cleaned) return null
try {
return Secret.fromBase32(cleaned.toUpperCase())
} catch {
// 尝试 hex
}
try {
return Secret.fromHex(cleaned)
} catch {
return null
}
}
function generate(): void {
if (!totp) {
code.value = ''
remaining.value = 0
return
}
code.value = totp.generate()
const epoch = Math.floor(Date.now() / 1000)
remaining.value = totp.period - (epoch % totp.period)
}
function start(rawSecret: string): void {
stop()
error.value = ''
secret.value = rawSecret
const parsed = parseSecret(rawSecret)
if (!parsed) {
error.value = rawSecret.trim() ? 'Secret 格式无效' : ''
return
}
totp = new TOTP({ secret: parsed, digits: 6, period: 30 })
generate()
timer = setInterval(generate, 1000)
}
function stop(): void {
if (timer) {
clearInterval(timer)
timer = null
}
totp = null
code.value = ''
remaining.value = 0
error.value = ''
}
onBeforeUnmount(stop)
return {
secret,
code: computed(() => code.value),
remaining: computed(() => remaining.value),
error: computed(() => error.value),
start,
stop,
}
}

View File

@@ -144,10 +144,26 @@
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium">Region</label>
<div class="grid grid-cols-2 gap-1.5">
<button
v-for="r in (['eu-north-1', 'us-east-1'] as const)"
:key="r"
class="h-8 text-xs font-medium font-mono rounded-md border transition-colors"
:class="device.region === r
? 'border-primary bg-primary/5 text-foreground'
: 'border-border text-muted-foreground hover:text-foreground hover:border-foreground/20'"
@click="device.region = r"
>
{{ r }}
</button>
</div>
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium text-muted-foreground">TOTP Secret (可选)</label>
<input
v-model="device.region"
v-model="device.totp_secret"
type="text"
placeholder="us-east-1"
placeholder="Base32 secret, 如 JBSWY3DPEHPK3PXP"
class="w-full h-8 px-2 text-xs rounded-md border border-border bg-background font-mono"
spellcheck="false"
>
@@ -205,6 +221,44 @@
<span>剩余 {{ deviceCountdownFormatted }}</span>
</div>
<!-- TOTP 验证码 -->
<div
v-if="totp.code.value"
class="w-full rounded-lg border border-border bg-background p-3"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<ShieldCheck class="w-3.5 h-3.5 text-primary" />
<span class="text-[10px] text-muted-foreground">MFA 验证码</span>
</div>
<div class="flex items-center gap-1.5">
<span
class="text-lg font-mono font-bold tracking-[0.25em]"
>{{ totp.code.value }}</span>
<button
class="p-1 rounded hover:bg-muted transition-colors"
title="复制验证码"
@click="copyToClipboard(totp.code.value)"
>
<Copy class="w-3 h-3 text-muted-foreground" />
</button>
</div>
</div>
<div class="mt-2 flex items-center gap-2">
<div class="flex-1 h-1 rounded-full bg-muted overflow-hidden">
<div
class="h-full rounded-full transition-all duration-1000 ease-linear"
:class="totp.remaining.value <= 5 ? 'bg-red-500' : 'bg-primary'"
:style="{ width: `${(totp.remaining.value / 30) * 100}%` }"
/>
</div>
<span
class="text-[10px] font-mono tabular-nums shrink-0"
:class="totp.remaining.value <= 5 ? 'text-red-500' : 'text-muted-foreground'"
>{{ totp.remaining.value }}s</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-2 w-full">
<Button
@@ -447,9 +501,10 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { Dialog, Button, Textarea, Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
import { UserPlus, Copy, ExternalLink, Upload, Globe, AlertCircle } from 'lucide-vue-next'
import { UserPlus, Copy, ExternalLink, Upload, Globe, AlertCircle, ShieldCheck } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { useTotp } from '@/composables/useTotp'
import { parseApiError } from '@/utils/errorParser'
import {
startProviderLevelOAuth,
@@ -476,6 +531,7 @@ const emit = defineEmits<{
const { success, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
const proxyNodesStore = useProxyNodesStore()
const totp = useTotp()
// 代理节点选择
const proxyPopoverOpen = ref(false)
@@ -524,6 +580,7 @@ interface DeviceAuthState {
auth_type: DeviceAuthType
start_url: string
region: string
totp_secret: string
starting: boolean
session_id: string
user_code: string
@@ -542,7 +599,8 @@ function createInitialDeviceState(): DeviceAuthState {
return {
auth_type: 'builder_id',
start_url: '',
region: 'us-east-1',
region: 'eu-north-1',
totp_secret: '',
starting: false,
session_id: '',
user_code: '',
@@ -608,16 +666,19 @@ function stopDevicePolling() {
function resetDevice() {
stopDevicePolling()
const { auth_type, start_url, region } = device.value
totp.stop()
const { auth_type, start_url, region, totp_secret } = device.value
device.value = createInitialDeviceState()
device.value.auth_type = auth_type
device.value.start_url = start_url
device.value.region = region
device.value.totp_secret = totp_secret
}
function resetForm() {
oauth.value = createInitialOAuthState()
stopDevicePolling()
totp.stop()
device.value = createInitialDeviceState()
importText.value = ''
importFileName.value = ''
@@ -883,6 +944,10 @@ async function startDeviceAuth() {
device.value.status = 'pending'
startCountdown()
scheduleDevicePoll()
// 如果配置了 TOTP secret启动验证码生成
if (device.value.totp_secret.trim()) {
totp.start(device.value.totp_secret.trim())
}
} catch (err: unknown) {
const errorMessage = parseApiError(err, '发起设备授权失败')
showError(errorMessage, '错误')
@@ -909,6 +974,7 @@ async function pollDevice() {
switch (result.status) {
case 'authorized':
stopDevicePolling()
totp.stop()
device.value.status = 'authorized'
success(result.email ? `授权成功: ${result.email}` : '授权成功,账号已添加')
emit('saved')