Some checks failed
Release Binaries / release (push) Has been cancelled
- Removed Google OAuth popup flow (invalid_client error) - Added manual access token input option - Added instructions to use SDK mode with antigravity plugin (recommended) - Added copy button for CLI command
424 lines
19 KiB
TypeScript
424 lines
19 KiB
TypeScript
import { Component, createSignal, onMount, For, Show } from 'solid-js'
|
|
import { Rocket, CheckCircle, XCircle, Loader, Sparkles, Key, LogOut, Shield, Terminal, Copy, ExternalLink } from 'lucide-solid'
|
|
import { getUserScopedKey } from '../../lib/user-storage'
|
|
|
|
interface AntigravityModel {
|
|
id: string
|
|
name: string
|
|
family?: string
|
|
reasoning?: boolean
|
|
tool_call?: boolean
|
|
limit?: {
|
|
context: number
|
|
output: number
|
|
}
|
|
}
|
|
|
|
interface AntigravityToken {
|
|
access_token: string
|
|
refresh_token?: string
|
|
expires_in: number
|
|
created_at: number
|
|
}
|
|
|
|
const ANTIGRAVITY_TOKEN_KEY = "antigravity_oauth_token"
|
|
|
|
const AntigravitySettings: Component = () => {
|
|
const [models, setModels] = createSignal<AntigravityModel[]>([])
|
|
const [isLoading, setIsLoading] = createSignal(true)
|
|
const [connectionStatus, setConnectionStatus] = createSignal<'idle' | 'testing' | 'connected' | 'failed'>('idle')
|
|
const [authStatus, setAuthStatus] = createSignal<'unknown' | 'authenticated' | 'unauthenticated'>('unknown')
|
|
const [error, setError] = createSignal<string | null>(null)
|
|
const [showTokenInput, setShowTokenInput] = createSignal(false)
|
|
const [tokenInput, setTokenInput] = createSignal('')
|
|
const [copied, setCopied] = createSignal(false)
|
|
|
|
// Check stored token on mount
|
|
onMount(async () => {
|
|
checkAuthStatus()
|
|
await loadModels()
|
|
await testConnection()
|
|
})
|
|
|
|
const getStoredToken = (): AntigravityToken | null => {
|
|
if (typeof window === "undefined") return null
|
|
try {
|
|
const raw = window.localStorage.getItem(getUserScopedKey(ANTIGRAVITY_TOKEN_KEY))
|
|
if (!raw) return null
|
|
return JSON.parse(raw)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const isTokenValid = (token: AntigravityToken | null): boolean => {
|
|
if (!token) return false
|
|
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
|
|
const expiresAt = (createdAt + token.expires_in) * 1000 - 300000 // 5 min buffer
|
|
return Date.now() < expiresAt
|
|
}
|
|
|
|
const checkAuthStatus = () => {
|
|
const token = getStoredToken()
|
|
if (isTokenValid(token)) {
|
|
setAuthStatus('authenticated')
|
|
} else {
|
|
setAuthStatus('unauthenticated')
|
|
}
|
|
}
|
|
|
|
const loadModels = async () => {
|
|
setIsLoading(true)
|
|
try {
|
|
const response = await fetch('/api/antigravity/models')
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
setModels(data.models || [])
|
|
setError(null)
|
|
} else {
|
|
throw new Error('Failed to load models')
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load Antigravity models:', err)
|
|
setError('Failed to load models')
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const testConnection = async () => {
|
|
setConnectionStatus('testing')
|
|
try {
|
|
const response = await fetch('/api/antigravity/test')
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
setConnectionStatus(data.connected ? 'connected' : 'failed')
|
|
} else {
|
|
setConnectionStatus('failed')
|
|
}
|
|
} catch (err) {
|
|
setConnectionStatus('failed')
|
|
}
|
|
}
|
|
|
|
const handleTokenSubmit = () => {
|
|
const token = tokenInput().trim()
|
|
if (!token) {
|
|
setError('Please enter a valid token')
|
|
return
|
|
}
|
|
|
|
try {
|
|
const tokenData: AntigravityToken = {
|
|
access_token: token,
|
|
expires_in: 3600, // 1 hour default
|
|
created_at: Date.now()
|
|
}
|
|
|
|
window.localStorage.setItem(
|
|
getUserScopedKey(ANTIGRAVITY_TOKEN_KEY),
|
|
JSON.stringify(tokenData)
|
|
)
|
|
|
|
setAuthStatus('authenticated')
|
|
setShowTokenInput(false)
|
|
setTokenInput('')
|
|
setError(null)
|
|
loadModels()
|
|
} catch (err) {
|
|
setError('Failed to save token')
|
|
}
|
|
}
|
|
|
|
const signOut = () => {
|
|
window.localStorage.removeItem(getUserScopedKey(ANTIGRAVITY_TOKEN_KEY))
|
|
setAuthStatus('unauthenticated')
|
|
}
|
|
|
|
const copyCommand = async () => {
|
|
const command = 'npx opencode-antigravity-auth login'
|
|
await navigator.clipboard.writeText(command)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}
|
|
|
|
const formatNumber = (num: number): string => {
|
|
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
|
|
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`
|
|
return num.toString()
|
|
}
|
|
|
|
const getModelFamily = (model: AntigravityModel): { label: string; color: string } => {
|
|
if (model.id.startsWith('gemini')) return { label: 'Gemini', color: 'bg-blue-500/20 text-blue-400' }
|
|
if (model.id.startsWith('claude')) return { label: 'Claude', color: 'bg-orange-500/20 text-orange-400' }
|
|
if (model.id.startsWith('gpt')) return { label: 'GPT', color: 'bg-green-500/20 text-green-400' }
|
|
return { label: model.family || 'Other', color: 'bg-zinc-700 text-zinc-400' }
|
|
}
|
|
|
|
return (
|
|
<div class="space-y-6 p-6">
|
|
{/* Header */}
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<div class="p-2 bg-gradient-to-br from-purple-500/20 to-blue-500/20 rounded-lg">
|
|
<Rocket class="w-6 h-6 text-purple-400" />
|
|
</div>
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-white">Antigravity</h2>
|
|
<p class="text-sm text-zinc-400">Premium models via Google authentication</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
{connectionStatus() === 'testing' && (
|
|
<span class="flex items-center gap-2 text-sm text-zinc-400">
|
|
<Loader class="w-4 h-4 animate-spin" />
|
|
Testing...
|
|
</span>
|
|
)}
|
|
{connectionStatus() === 'connected' && (
|
|
<span class="flex items-center gap-2 text-sm text-emerald-400">
|
|
<CheckCircle class="w-4 h-4" />
|
|
Connected
|
|
</span>
|
|
)}
|
|
{connectionStatus() === 'failed' && (
|
|
<span class="flex items-center gap-2 text-sm text-red-400">
|
|
<XCircle class="w-4 h-4" />
|
|
Offline
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Info Banner */}
|
|
<div class="bg-gradient-to-r from-purple-500/10 via-blue-500/10 to-purple-500/10 border border-purple-500/20 rounded-xl p-4">
|
|
<div class="flex items-start gap-3">
|
|
<Sparkles class="w-5 h-5 text-purple-400 mt-0.5" />
|
|
<div>
|
|
<h3 class="font-semibold text-purple-300 mb-1">Premium AI Models</h3>
|
|
<p class="text-sm text-zinc-300">
|
|
Antigravity provides access to Gemini 3 Pro/Flash, Claude Sonnet 4.5, Claude Opus 4.5,
|
|
and GPT-OSS 120B through Google's infrastructure.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Authentication Section */}
|
|
<div class="bg-zinc-900/50 border border-zinc-800 rounded-xl p-4 space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<Shield class="w-5 h-5 text-zinc-400" />
|
|
<div>
|
|
<h4 class="font-medium text-white">Authentication</h4>
|
|
<p class="text-xs text-zinc-500">
|
|
{authStatus() === 'authenticated'
|
|
? 'Token configured - you can use Antigravity models'
|
|
: 'Configure authentication to access premium models'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Show when={authStatus() === 'authenticated'}>
|
|
<div class="flex items-center gap-3">
|
|
<span class="flex items-center gap-2 px-3 py-1.5 bg-emerald-500/20 text-emerald-400 rounded-lg text-sm">
|
|
<CheckCircle class="w-4 h-4" />
|
|
Configured
|
|
</span>
|
|
<button
|
|
onClick={signOut}
|
|
class="flex items-center gap-2 px-3 py-1.5 text-sm text-zinc-400 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors"
|
|
>
|
|
<LogOut class="w-4 h-4" />
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
|
|
<Show when={authStatus() === 'unauthenticated'}>
|
|
{/* SDK Mode Instructions */}
|
|
<div class="bg-blue-500/5 border border-blue-500/20 rounded-lg p-4 space-y-3">
|
|
<div class="flex items-center gap-2 text-blue-400">
|
|
<Terminal class="w-4 h-4" />
|
|
<span class="font-medium text-sm">Recommended: Use OpenCode SDK Mode</span>
|
|
</div>
|
|
<p class="text-xs text-zinc-400">
|
|
For the best experience, switch to OpenCode SDK mode in General settings.
|
|
The Antigravity plugin handles authentication automatically via Google OAuth.
|
|
</p>
|
|
<div class="flex items-center gap-2">
|
|
<code class="flex-1 px-3 py-2 bg-zinc-900 rounded text-xs font-mono text-zinc-300">
|
|
npx opencode-antigravity-auth login
|
|
</code>
|
|
<button
|
|
onClick={copyCommand}
|
|
class="p-2 text-zinc-400 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded transition-colors"
|
|
title="Copy command"
|
|
>
|
|
{copied() ? <CheckCircle class="w-4 h-4 text-emerald-400" /> : <Copy class="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Manual Token Entry */}
|
|
<div class="border-t border-zinc-800 pt-4">
|
|
<Show when={!showTokenInput()}>
|
|
<button
|
|
onClick={() => setShowTokenInput(true)}
|
|
class="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors"
|
|
>
|
|
<Key class="w-4 h-4" />
|
|
Or enter access token manually...
|
|
</button>
|
|
</Show>
|
|
|
|
<Show when={showTokenInput()}>
|
|
<div class="space-y-3">
|
|
<label class="block text-sm font-medium text-zinc-300">
|
|
Access Token
|
|
</label>
|
|
<div class="flex gap-2">
|
|
<input
|
|
type="password"
|
|
value={tokenInput()}
|
|
onInput={(e) => setTokenInput(e.currentTarget.value)}
|
|
placeholder="Paste your Google OAuth access token..."
|
|
class="flex-1 px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-purple-500"
|
|
/>
|
|
<button
|
|
onClick={handleTokenSubmit}
|
|
class="px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded-lg text-sm transition-colors"
|
|
>
|
|
Save
|
|
</button>
|
|
<button
|
|
onClick={() => { setShowTokenInput(false); setTokenInput(''); }}
|
|
class="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded-lg text-sm transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
<p class="text-xs text-zinc-500">
|
|
You can get an access token by running the Antigravity auth plugin in a terminal,
|
|
or by extracting it from an authenticated Google Cloud session.
|
|
</p>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
|
|
{/* Error Display */}
|
|
<Show when={error()}>
|
|
<div class="p-4 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
|
{error()}
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Models Grid */}
|
|
<div class="space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-medium text-white">Available Models</h3>
|
|
<button
|
|
onClick={loadModels}
|
|
disabled={isLoading()}
|
|
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors"
|
|
>
|
|
{isLoading() ? <Loader class="w-4 h-4 animate-spin" /> : null}
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
<Show when={isLoading()}>
|
|
<div class="flex items-center justify-center py-12">
|
|
<div class="flex items-center gap-3 text-zinc-400">
|
|
<Loader class="w-6 h-6 animate-spin" />
|
|
<span>Loading models...</span>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={!isLoading() && models().length > 0}>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<For each={models()}>
|
|
{(model) => {
|
|
const family = getModelFamily(model)
|
|
return (
|
|
<div class="group bg-zinc-900/50 border border-zinc-800 hover:border-purple-500/50 rounded-xl p-4 transition-all">
|
|
<div class="flex items-start justify-between mb-3">
|
|
<div>
|
|
<h4 class="font-semibold text-white group-hover:text-purple-300 transition-colors">
|
|
{model.name}
|
|
</h4>
|
|
<p class="text-xs text-zinc-500 font-mono">{model.id}</p>
|
|
</div>
|
|
<span class={`px-2 py-0.5 text-[10px] font-bold uppercase rounded ${family.color}`}>
|
|
{family.label}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-2 mb-3">
|
|
{model.reasoning && (
|
|
<span class="px-2 py-0.5 text-[10px] bg-purple-500/20 text-purple-400 rounded">
|
|
Thinking
|
|
</span>
|
|
)}
|
|
{model.tool_call && (
|
|
<span class="px-2 py-0.5 text-[10px] bg-blue-500/20 text-blue-400 rounded">
|
|
Tool Use
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{model.limit && (
|
|
<div class="flex items-center gap-4 text-xs text-zinc-500">
|
|
<span>Context: {formatNumber(model.limit.context)}</span>
|
|
<span>Output: {formatNumber(model.limit.output)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}}
|
|
</For>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={!isLoading() && models().length === 0}>
|
|
<div class="text-center py-12 text-zinc-500">
|
|
<p>Models will be available after configuration.</p>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
|
|
{/* Usage Info */}
|
|
<div class="bg-zinc-900/50 border border-zinc-800 rounded-xl p-4">
|
|
<h4 class="font-medium text-white mb-2">How to Use</h4>
|
|
<ul class="text-sm text-zinc-400 space-y-1">
|
|
<li>• <strong>SDK Mode (Recommended):</strong> Switch to OpenCode binary mode and install the antigravity auth plugin</li>
|
|
<li>• <strong>Native Mode:</strong> Enter your access token manually above</li>
|
|
<li>• Select any Antigravity model from the model picker in chat</li>
|
|
<li>• Models include Gemini 3, Claude Sonnet/Opus 4.5, and GPT-OSS</li>
|
|
<li>• Thinking-enabled models show step-by-step reasoning</li>
|
|
</ul>
|
|
</div>
|
|
|
|
{/* Plugin Link */}
|
|
<div class="flex justify-center">
|
|
<a
|
|
href="https://github.com/NoeFabris/opencode-antigravity-auth"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="flex items-center gap-2 text-sm text-purple-400 hover:text-purple-300 transition-colors"
|
|
>
|
|
<ExternalLink class="w-4 h-4" />
|
|
View Antigravity Auth Plugin on GitHub
|
|
</a>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default AntigravitySettings
|