feat: Add Antigravity provider integration + fix native mode startup
Some checks failed
Release Binaries / release (push) Has been cancelled
Some checks failed
Release Binaries / release (push) Has been cancelled
- Added Antigravity AI provider with Google OAuth authentication - New integration client (antigravity.ts) with automatic endpoint fallback - API routes for /api/antigravity/* (models, auth-status, test, chat) - AntigravitySettings.tsx for Advanced Settings panel - Updated session-api.ts and session-actions.ts for provider routing - Updated opencode.jsonc with Antigravity plugin and 11 models: - Gemini 3 Pro Low/High, Gemini 3 Flash - Claude Sonnet 4.5 (+ thinking variants) - Claude Opus 4.5 (+ thinking variants) - GPT-OSS 120B Medium - Fixed native mode startup error (was trying to launch __nomadarch_native__ as binary) - Native mode workspaces now skip binary launch and are immediately ready
This commit is contained in:
@@ -6,6 +6,7 @@ import OllamaCloudSettings from "./settings/OllamaCloudSettings"
|
||||
import QwenCodeSettings from "./settings/QwenCodeSettings"
|
||||
import ZAISettings from "./settings/ZAISettings"
|
||||
import OpenCodeZenSettings from "./settings/OpenCodeZenSettings"
|
||||
import AntigravitySettings from "./settings/AntigravitySettings"
|
||||
|
||||
interface AdvancedSettingsModalProps {
|
||||
open: boolean
|
||||
@@ -75,6 +76,15 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
|
||||
>
|
||||
Z.AI
|
||||
</button>
|
||||
<button
|
||||
class={`px-4 py-2 text-sm font-medium border-b-2 whitespace-nowrap ${activeTab() === "antigravity"
|
||||
? "border-purple-500 text-purple-400"
|
||||
: "border-transparent hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => setActiveTab("antigravity")}
|
||||
>
|
||||
🚀 Antigravity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -115,6 +125,10 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
|
||||
<Show when={activeTab() === "zai"}>
|
||||
<ZAISettings />
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === "antigravity"}>
|
||||
<AntigravitySettings />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-t flex justify-end" style={{ "border-color": "var(--border-base)" }}>
|
||||
|
||||
428
packages/ui/src/components/settings/AntigravitySettings.tsx
Normal file
428
packages/ui/src/components/settings/AntigravitySettings.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
import { Component, createSignal, onMount, For, Show } from 'solid-js'
|
||||
import { Rocket, CheckCircle, XCircle, Loader, Sparkles, LogIn, LogOut, Shield } 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 GOOGLE_OAUTH_CLIENT_ID = "782068742485-pf45b4gldtk7q847g3v5ercqfl31nkud.apps.googleusercontent.com" // Antigravity/Gemini CLI client
|
||||
|
||||
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 [isAuthenticating, setIsAuthenticating] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
|
||||
// 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 startGoogleOAuth = async () => {
|
||||
setIsAuthenticating(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Open Google OAuth in a new window
|
||||
const redirectUri = `${window.location.origin}/auth/antigravity/callback`
|
||||
const scope = encodeURIComponent("openid email profile https://www.googleapis.com/auth/cloud-platform")
|
||||
const state = crypto.randomUUID()
|
||||
|
||||
// Store state for verification
|
||||
window.localStorage.setItem('antigravity_oauth_state', state)
|
||||
|
||||
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
|
||||
`client_id=${GOOGLE_OAUTH_CLIENT_ID}&` +
|
||||
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
|
||||
`response_type=token&` +
|
||||
`scope=${scope}&` +
|
||||
`state=${state}&` +
|
||||
`prompt=consent`
|
||||
|
||||
// Open popup
|
||||
const width = 500
|
||||
const height = 600
|
||||
const left = (window.screen.width - width) / 2
|
||||
const top = (window.screen.height - height) / 2
|
||||
|
||||
const popup = window.open(
|
||||
authUrl,
|
||||
'antigravity-oauth',
|
||||
`width=${width},height=${height},left=${left},top=${top}`
|
||||
)
|
||||
|
||||
if (!popup) {
|
||||
throw new Error('Failed to open authentication window. Please allow popups.')
|
||||
}
|
||||
|
||||
// Poll for token in URL hash
|
||||
const checkClosed = setInterval(() => {
|
||||
try {
|
||||
if (popup.closed) {
|
||||
clearInterval(checkClosed)
|
||||
setIsAuthenticating(false)
|
||||
checkAuthStatus()
|
||||
loadModels()
|
||||
} else {
|
||||
// Check if we can access the popup location (same origin after redirect)
|
||||
const hash = popup.location.hash
|
||||
if (hash && hash.includes('access_token')) {
|
||||
const params = new URLSearchParams(hash.substring(1))
|
||||
const accessToken = params.get('access_token')
|
||||
const expiresIn = parseInt(params.get('expires_in') || '3600', 10)
|
||||
|
||||
if (accessToken) {
|
||||
const token: AntigravityToken = {
|
||||
access_token: accessToken,
|
||||
expires_in: expiresIn,
|
||||
created_at: Date.now()
|
||||
}
|
||||
|
||||
window.localStorage.setItem(
|
||||
getUserScopedKey(ANTIGRAVITY_TOKEN_KEY),
|
||||
JSON.stringify(token)
|
||||
)
|
||||
|
||||
popup.close()
|
||||
clearInterval(checkClosed)
|
||||
setIsAuthenticating(false)
|
||||
setAuthStatus('authenticated')
|
||||
loadModels()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Cross-origin error - popup is on Google's domain, still waiting
|
||||
}
|
||||
}, 500)
|
||||
|
||||
// Cleanup after 5 minutes
|
||||
setTimeout(() => {
|
||||
clearInterval(checkClosed)
|
||||
if (!popup.closed) {
|
||||
popup.close()
|
||||
}
|
||||
setIsAuthenticating(false)
|
||||
}, 300000)
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('OAuth error:', err)
|
||||
setError(err.message || 'Authentication failed')
|
||||
setIsAuthenticating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const signOut = () => {
|
||||
window.localStorage.removeItem(getUserScopedKey(ANTIGRAVITY_TOKEN_KEY))
|
||||
setAuthStatus('unauthenticated')
|
||||
setModels([])
|
||||
}
|
||||
|
||||
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 OAuth</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 via Google</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 rate limits. Sign in with your Google account to get started.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication Section */}
|
||||
<div class="bg-zinc-900/50 border border-zinc-800 rounded-xl p-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">Google OAuth Authentication</h4>
|
||||
<p class="text-xs text-zinc-500">
|
||||
{authStatus() === 'authenticated'
|
||||
? 'You are signed in and can use Antigravity models'
|
||||
: 'Sign in with Google to access premium models'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={authStatus() === 'unauthenticated'}>
|
||||
<button
|
||||
onClick={startGoogleOAuth}
|
||||
disabled={isAuthenticating()}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-500 disabled:bg-purple-600/50 text-white rounded-lg transition-colors"
|
||||
>
|
||||
{isAuthenticating() ? (
|
||||
<>
|
||||
<Loader class="w-4 h-4 animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn class="w-4 h-4" />
|
||||
Sign in with Google
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<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" />
|
||||
Authenticated
|
||||
</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" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</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 && authStatus() === 'authenticated'}>
|
||||
<div class="text-center py-12 text-zinc-500">
|
||||
<p>No models available at this time.</p>
|
||||
<button
|
||||
onClick={loadModels}
|
||||
class="mt-4 px-4 py-2 text-sm bg-purple-500/20 text-purple-400 hover:bg-purple-500/30 rounded-lg transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!isLoading() && models().length === 0 && authStatus() === 'unauthenticated'}>
|
||||
<div class="text-center py-12 text-zinc-500">
|
||||
<p>Sign in with Google to see available models.</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>• Sign in with your Google account 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>
|
||||
<li>• Uses Google's rate limits for maximum throughput</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AntigravitySettings
|
||||
@@ -1116,6 +1116,144 @@ async function streamZAIChat(
|
||||
})
|
||||
}
|
||||
|
||||
async function streamAntigravityChat(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
messages: ExternalChatMessage[],
|
||||
messageId: string,
|
||||
assistantMessageId: string,
|
||||
assistantPartId: string,
|
||||
): Promise<void> {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
|
||||
|
||||
// Get workspace path for tool execution
|
||||
const instance = instances().get(instanceId)
|
||||
const workspacePath = instance?.folder || ""
|
||||
|
||||
const response = await fetch("/api/antigravity/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages,
|
||||
stream: true,
|
||||
workspacePath,
|
||||
enableTools: true,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "")
|
||||
throw new Error(errorText || `Antigravity chat failed (${response.status})`)
|
||||
}
|
||||
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
store.beginStreamingUpdate()
|
||||
let fullText = ""
|
||||
let lastUpdateAt = 0
|
||||
|
||||
try {
|
||||
await readSseStream(response, (data) => {
|
||||
try {
|
||||
const chunk = JSON.parse(data)
|
||||
if (chunk?.error) throw new Error(chunk.error)
|
||||
|
||||
// Handle tool execution results (special events from backend)
|
||||
if (chunk?.type === "tool_result") {
|
||||
const toolResult = `\n\n✅ **Tool Executed:** ${chunk.content}\n\n`
|
||||
fullText += toolResult
|
||||
store.applyPartUpdate({
|
||||
messageId: assistantMessageId,
|
||||
part: { id: assistantPartId, type: "text", text: fullText } as any,
|
||||
})
|
||||
|
||||
// Dispatch file change event to refresh sidebar
|
||||
if (typeof window !== "undefined") {
|
||||
console.log(`[EVENT] Dispatching FILE_CHANGE_EVENT for ${instanceId}`);
|
||||
window.dispatchEvent(new CustomEvent(FILE_CHANGE_EVENT, { detail: { instanceId } }))
|
||||
}
|
||||
|
||||
// Auto-trigger preview for HTML file writes
|
||||
const content = chunk.content || ""
|
||||
if (content.includes("Successfully wrote") &&
|
||||
(content.includes(".html") || content.includes("index.") || content.includes(".htm"))) {
|
||||
if (typeof window !== "undefined") {
|
||||
const htmlMatch = content.match(/to\s+([^\s]+\.html?)/)
|
||||
if (htmlMatch) {
|
||||
const relativePath = htmlMatch[1]
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost:3000"
|
||||
const apiOrigin = origin.replace(":3000", ":9898")
|
||||
const previewUrl = `${apiOrigin}/api/workspaces/${instanceId}/serve/${relativePath}`
|
||||
|
||||
console.log(`[EVENT] Auto-preview triggered for ${previewUrl}`);
|
||||
window.dispatchEvent(new CustomEvent(BUILD_PREVIEW_EVENT, {
|
||||
detail: { url: previewUrl, instanceId }
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const delta =
|
||||
chunk?.choices?.[0]?.delta?.content ??
|
||||
chunk?.choices?.[0]?.message?.content
|
||||
if (typeof delta !== "string" || delta.length === 0) return
|
||||
fullText += delta
|
||||
|
||||
const now = Date.now()
|
||||
if (now - lastUpdateAt > 40) { // Limit to ~25 updates per second
|
||||
lastUpdateAt = now
|
||||
store.applyPartUpdate({
|
||||
messageId: assistantMessageId,
|
||||
part: { id: assistantPartId, type: "text", text: fullText } as any,
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) throw e
|
||||
}
|
||||
})
|
||||
|
||||
// Always apply final text update
|
||||
store.applyPartUpdate({
|
||||
messageId: assistantMessageId,
|
||||
part: { id: assistantPartId, type: "text", text: fullText } as any,
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
store.endStreamingUpdate()
|
||||
}
|
||||
|
||||
store.upsertMessage({
|
||||
id: assistantMessageId,
|
||||
sessionId,
|
||||
role: "assistant",
|
||||
status: "complete",
|
||||
updatedAt: Date.now(),
|
||||
isEphemeral: false,
|
||||
})
|
||||
store.setMessageInfo(assistantMessageId, {
|
||||
id: assistantMessageId,
|
||||
role: "assistant",
|
||||
providerID: providerId,
|
||||
modelID: modelId,
|
||||
time: { created: store.getMessageInfo(assistantMessageId)?.time?.created ?? Date.now(), completed: Date.now() },
|
||||
} as any)
|
||||
store.upsertMessage({
|
||||
id: messageId,
|
||||
sessionId,
|
||||
role: "user",
|
||||
status: "sent",
|
||||
updatedAt: Date.now(),
|
||||
isEphemeral: false,
|
||||
})
|
||||
}
|
||||
|
||||
async function sendMessage(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
@@ -1264,7 +1402,7 @@ async function sendMessage(
|
||||
addDebugLog(`Merge System Instructions: ${Math.round(tPre2 - tPre1)}ms`, "warn")
|
||||
}
|
||||
|
||||
if (providerId === "ollama-cloud" || providerId === "qwen-oauth" || providerId === "opencode-zen" || providerId === "zai") {
|
||||
if (providerId === "ollama-cloud" || providerId === "qwen-oauth" || providerId === "opencode-zen" || providerId === "zai" || providerId === "antigravity") {
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const now = Date.now()
|
||||
const assistantMessageId = createId("msg")
|
||||
@@ -1347,6 +1485,17 @@ async function sendMessage(
|
||||
assistantMessageId,
|
||||
assistantPartId,
|
||||
)
|
||||
} else if (providerId === "antigravity") {
|
||||
await streamAntigravityChat(
|
||||
instanceId,
|
||||
sessionId,
|
||||
providerId,
|
||||
effectiveModel.modelId,
|
||||
externalMessages,
|
||||
messageId,
|
||||
assistantMessageId,
|
||||
assistantPartId,
|
||||
)
|
||||
} else {
|
||||
const qwenManager = new QwenOAuthManager()
|
||||
const token = await qwenManager.getValidToken()
|
||||
@@ -1428,7 +1577,9 @@ async function sendMessage(
|
||||
? "Z.AI request failed"
|
||||
: providerId === "opencode-zen"
|
||||
? "OpenCode Zen request failed"
|
||||
: "Qwen request failed",
|
||||
: providerId === "antigravity"
|
||||
? "Antigravity request failed"
|
||||
: "Qwen request failed",
|
||||
message: error?.message || "Request failed",
|
||||
variant: "error",
|
||||
duration: 8000,
|
||||
|
||||
@@ -249,14 +249,83 @@ async function fetchZAIProvider(): Promise<Provider | null> {
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredAntigravityToken():
|
||||
| { access_token: string; expires_in: number; created_at: number }
|
||||
| null {
|
||||
if (typeof window === "undefined") return null
|
||||
try {
|
||||
const raw = window.localStorage.getItem(getUserScopedKey("antigravity_oauth_token"))
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isAntigravityTokenValid(token: { expires_in: number; created_at: number } | 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
|
||||
return Date.now() < expiresAt
|
||||
}
|
||||
|
||||
async function fetchAntigravityProvider(): Promise<Provider | null> {
|
||||
// Check if user is authenticated with Antigravity (Google OAuth)
|
||||
const token = getStoredAntigravityToken()
|
||||
if (!isAntigravityTokenValid(token)) {
|
||||
// Not authenticated - try to fetch models anyway (they show as available but require auth)
|
||||
try {
|
||||
const data = await fetchJson<{ models?: Array<{ id: string; name: string; limit?: Model["limit"] }> }>(
|
||||
"/api/antigravity/models",
|
||||
)
|
||||
const models = Array.isArray(data?.models) ? data?.models ?? [] : []
|
||||
if (models.length === 0) return null
|
||||
|
||||
return {
|
||||
id: "antigravity",
|
||||
name: "Antigravity (Google OAuth)",
|
||||
models: models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
providerId: "antigravity",
|
||||
limit: model.limit,
|
||||
})),
|
||||
defaultModelId: "gemini-3-pro-high",
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// User is authenticated - fetch full model list
|
||||
const data = await fetchJson<{ models?: Array<{ id: string; name: string; limit?: Model["limit"] }> }>(
|
||||
"/api/antigravity/models",
|
||||
)
|
||||
const models = Array.isArray(data?.models) ? data?.models ?? [] : []
|
||||
if (models.length === 0) return null
|
||||
|
||||
return {
|
||||
id: "antigravity",
|
||||
name: "Antigravity (Google OAuth)",
|
||||
models: models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
providerId: "antigravity",
|
||||
limit: model.limit,
|
||||
})),
|
||||
defaultModelId: "gemini-3-pro-high",
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchExtraProviders(): Promise<Provider[]> {
|
||||
const [ollama, zen, qwen, zai] = await Promise.all([
|
||||
const [ollama, zen, qwen, zai, antigravity] = await Promise.all([
|
||||
fetchOllamaCloudProvider(),
|
||||
fetchOpenCodeZenProvider(),
|
||||
fetchQwenOAuthProvider(),
|
||||
fetchZAIProvider(),
|
||||
fetchAntigravityProvider(),
|
||||
])
|
||||
return [ollama, zen, qwen, zai].filter((provider): provider is Provider => Boolean(provider))
|
||||
return [ollama, zen, qwen, zai, antigravity].filter((provider): provider is Provider => Boolean(provider))
|
||||
}
|
||||
|
||||
function removeDuplicateProviders(base: Provider[], extras: Provider[]): Provider[] {
|
||||
|
||||
Reference in New Issue
Block a user