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:
@@ -1,3 +1,204 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json"
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
// Antigravity plugin for Google OAuth (Gemini + Claude models via Antigravity)
|
||||||
|
"plugin": [
|
||||||
|
"opencode-antigravity-auth@1.2.6"
|
||||||
|
],
|
||||||
|
"provider": {
|
||||||
|
// Antigravity models (via Google OAuth)
|
||||||
|
"google": {
|
||||||
|
"models": {
|
||||||
|
// Gemini Models
|
||||||
|
"gemini-3-pro-low": {
|
||||||
|
"name": "Gemini 3 Pro Low (Antigravity)",
|
||||||
|
"limit": {
|
||||||
|
"context": 1048576,
|
||||||
|
"output": 65535
|
||||||
|
},
|
||||||
|
"modalities": {
|
||||||
|
"input": [
|
||||||
|
"text",
|
||||||
|
"image",
|
||||||
|
"pdf"
|
||||||
|
],
|
||||||
|
"output": [
|
||||||
|
"text"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gemini-3-pro-high": {
|
||||||
|
"name": "Gemini 3 Pro High (Antigravity)",
|
||||||
|
"limit": {
|
||||||
|
"context": 1048576,
|
||||||
|
"output": 65535
|
||||||
|
},
|
||||||
|
"modalities": {
|
||||||
|
"input": [
|
||||||
|
"text",
|
||||||
|
"image",
|
||||||
|
"pdf"
|
||||||
|
],
|
||||||
|
"output": [
|
||||||
|
"text"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gemini-3-flash": {
|
||||||
|
"name": "Gemini 3 Flash (Antigravity)",
|
||||||
|
"limit": {
|
||||||
|
"context": 1048576,
|
||||||
|
"output": 65536
|
||||||
|
},
|
||||||
|
"modalities": {
|
||||||
|
"input": [
|
||||||
|
"text",
|
||||||
|
"image",
|
||||||
|
"pdf"
|
||||||
|
],
|
||||||
|
"output": [
|
||||||
|
"text"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Claude Models (via Antigravity)
|
||||||
|
"claude-sonnet-4-5": {
|
||||||
|
"name": "Claude Sonnet 4.5 (Antigravity)",
|
||||||
|
"limit": {
|
||||||
|
"context": 200000,
|
||||||
|
"output": 64000
|
||||||
|
},
|
||||||
|
"modalities": {
|
||||||
|
"input": [
|
||||||
|
"text",
|
||||||
|
"image",
|
||||||
|
"pdf"
|
||||||
|
],
|
||||||
|
"output": [
|
||||||
|
"text"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"claude-sonnet-4-5-thinking-low": {
|
||||||
|
"name": "Claude Sonnet 4.5 Thinking Low (Antigravity)",
|
||||||
|
"limit": {
|
||||||
|
"context": 200000,
|
||||||
|
"output": 64000
|
||||||
|
},
|
||||||
|
"modalities": {
|
||||||
|
"input": [
|
||||||
|
"text",
|
||||||
|
"image",
|
||||||
|
"pdf"
|
||||||
|
],
|
||||||
|
"output": [
|
||||||
|
"text"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"claude-sonnet-4-5-thinking-medium": {
|
||||||
|
"name": "Claude Sonnet 4.5 Thinking Medium (Antigravity)",
|
||||||
|
"limit": {
|
||||||
|
"context": 200000,
|
||||||
|
"output": 64000
|
||||||
|
},
|
||||||
|
"modalities": {
|
||||||
|
"input": [
|
||||||
|
"text",
|
||||||
|
"image",
|
||||||
|
"pdf"
|
||||||
|
],
|
||||||
|
"output": [
|
||||||
|
"text"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"claude-sonnet-4-5-thinking-high": {
|
||||||
|
"name": "Claude Sonnet 4.5 Thinking High (Antigravity)",
|
||||||
|
"limit": {
|
||||||
|
"context": 200000,
|
||||||
|
"output": 64000
|
||||||
|
},
|
||||||
|
"modalities": {
|
||||||
|
"input": [
|
||||||
|
"text",
|
||||||
|
"image",
|
||||||
|
"pdf"
|
||||||
|
],
|
||||||
|
"output": [
|
||||||
|
"text"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"claude-opus-4-5-thinking-low": {
|
||||||
|
"name": "Claude Opus 4.5 Thinking Low (Antigravity)",
|
||||||
|
"limit": {
|
||||||
|
"context": 200000,
|
||||||
|
"output": 64000
|
||||||
|
},
|
||||||
|
"modalities": {
|
||||||
|
"input": [
|
||||||
|
"text",
|
||||||
|
"image",
|
||||||
|
"pdf"
|
||||||
|
],
|
||||||
|
"output": [
|
||||||
|
"text"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"claude-opus-4-5-thinking-medium": {
|
||||||
|
"name": "Claude Opus 4.5 Thinking Medium (Antigravity)",
|
||||||
|
"limit": {
|
||||||
|
"context": 200000,
|
||||||
|
"output": 64000
|
||||||
|
},
|
||||||
|
"modalities": {
|
||||||
|
"input": [
|
||||||
|
"text",
|
||||||
|
"image",
|
||||||
|
"pdf"
|
||||||
|
],
|
||||||
|
"output": [
|
||||||
|
"text"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"claude-opus-4-5-thinking-high": {
|
||||||
|
"name": "Claude Opus 4.5 Thinking High (Antigravity)",
|
||||||
|
"limit": {
|
||||||
|
"context": 200000,
|
||||||
|
"output": 64000
|
||||||
|
},
|
||||||
|
"modalities": {
|
||||||
|
"input": [
|
||||||
|
"text",
|
||||||
|
"image",
|
||||||
|
"pdf"
|
||||||
|
],
|
||||||
|
"output": [
|
||||||
|
"text"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Other Models
|
||||||
|
"gpt-oss-120b-medium": {
|
||||||
|
"name": "GPT-OSS 120B Medium (Antigravity)",
|
||||||
|
"limit": {
|
||||||
|
"context": 131072,
|
||||||
|
"output": 32768
|
||||||
|
},
|
||||||
|
"modalities": {
|
||||||
|
"input": [
|
||||||
|
"text",
|
||||||
|
"image",
|
||||||
|
"pdf"
|
||||||
|
],
|
||||||
|
"output": [
|
||||||
|
"text"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
495
packages/server/src/integrations/antigravity.ts
Normal file
495
packages/server/src/integrations/antigravity.ts
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
/**
|
||||||
|
* Antigravity API Integration for Binary-Free Mode
|
||||||
|
* Provides direct access to Antigravity models (Gemini, Claude, GPT-OSS) via Google OAuth
|
||||||
|
* Based on the opencode-antigravity-auth plugin: https://github.com/NoeFabris/opencode-antigravity-auth
|
||||||
|
*
|
||||||
|
* This integration enables access to:
|
||||||
|
* - Gemini 3 Pro/Flash models
|
||||||
|
* - Claude Sonnet 4.5 / Opus 4.5 (with thinking variants)
|
||||||
|
* - GPT-OSS 120B
|
||||||
|
*
|
||||||
|
* Uses Google OAuth credentials stored via the Antigravity OAuth flow
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
// Configuration schema for Antigravity
|
||||||
|
export const AntigravityConfigSchema = z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
// Multiple endpoints for automatic fallback (daily → autopush → prod)
|
||||||
|
endpoints: z.array(z.string()).default([
|
||||||
|
"https://daily.antigravity.dev/v1beta",
|
||||||
|
"https://autopush.antigravity.dev/v1beta",
|
||||||
|
"https://antigravity.dev/v1beta"
|
||||||
|
]),
|
||||||
|
apiKey: z.string().optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AntigravityConfig = z.infer<typeof AntigravityConfigSchema>
|
||||||
|
|
||||||
|
// Antigravity Model schema
|
||||||
|
export const AntigravityModelSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
family: z.enum(["gemini", "claude", "gpt-oss"]).optional(),
|
||||||
|
reasoning: z.boolean().optional(),
|
||||||
|
tool_call: z.boolean().optional(),
|
||||||
|
limit: z.object({
|
||||||
|
context: z.number(),
|
||||||
|
output: z.number()
|
||||||
|
}).optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AntigravityModel = z.infer<typeof AntigravityModelSchema>
|
||||||
|
|
||||||
|
// Chat message schema (OpenAI-compatible)
|
||||||
|
export const ChatMessageSchema = z.object({
|
||||||
|
role: z.enum(["user", "assistant", "system", "tool"]),
|
||||||
|
content: z.string().optional(),
|
||||||
|
tool_calls: z.array(z.object({
|
||||||
|
id: z.string(),
|
||||||
|
type: z.literal("function"),
|
||||||
|
function: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
arguments: z.string()
|
||||||
|
})
|
||||||
|
})).optional(),
|
||||||
|
tool_call_id: z.string().optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ChatMessage = z.infer<typeof ChatMessageSchema>
|
||||||
|
|
||||||
|
// Tool Definition Schema
|
||||||
|
export const ToolDefinitionSchema = z.object({
|
||||||
|
type: z.literal("function"),
|
||||||
|
function: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
parameters: z.object({
|
||||||
|
type: z.literal("object"),
|
||||||
|
properties: z.record(z.any()),
|
||||||
|
required: z.array(z.string()).optional()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ToolDefinition = z.infer<typeof ToolDefinitionSchema>
|
||||||
|
|
||||||
|
// Chat request schema
|
||||||
|
export const ChatRequestSchema = z.object({
|
||||||
|
model: z.string(),
|
||||||
|
messages: z.array(ChatMessageSchema),
|
||||||
|
stream: z.boolean().default(true),
|
||||||
|
temperature: z.number().optional(),
|
||||||
|
max_tokens: z.number().optional(),
|
||||||
|
tools: z.array(ToolDefinitionSchema).optional(),
|
||||||
|
tool_choice: z.union([
|
||||||
|
z.literal("auto"),
|
||||||
|
z.literal("none"),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("function"),
|
||||||
|
function: z.object({ name: z.string() })
|
||||||
|
})
|
||||||
|
]).optional(),
|
||||||
|
workspacePath: z.string().optional(),
|
||||||
|
enableTools: z.boolean().optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ChatRequest = z.infer<typeof ChatRequestSchema>
|
||||||
|
|
||||||
|
// Chat response chunk schema
|
||||||
|
export const ChatChunkSchema = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
|
object: z.string().optional(),
|
||||||
|
created: z.number().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
choices: z.array(z.object({
|
||||||
|
index: z.number(),
|
||||||
|
delta: z.object({
|
||||||
|
role: z.string().optional(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
reasoning_content: z.string().optional()
|
||||||
|
}).optional(),
|
||||||
|
message: z.object({
|
||||||
|
role: z.string(),
|
||||||
|
content: z.string()
|
||||||
|
}).optional(),
|
||||||
|
finish_reason: z.string().nullable().optional()
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ChatChunk = z.infer<typeof ChatChunkSchema>
|
||||||
|
|
||||||
|
// Available Antigravity models with their specifications
|
||||||
|
export const ANTIGRAVITY_MODELS: AntigravityModel[] = [
|
||||||
|
// Gemini Models
|
||||||
|
{
|
||||||
|
id: "gemini-3-pro-low",
|
||||||
|
name: "Gemini 3 Pro Low (Antigravity)",
|
||||||
|
family: "gemini",
|
||||||
|
reasoning: true,
|
||||||
|
tool_call: true,
|
||||||
|
limit: { context: 1048576, output: 65535 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3-pro-high",
|
||||||
|
name: "Gemini 3 Pro High (Antigravity)",
|
||||||
|
family: "gemini",
|
||||||
|
reasoning: true,
|
||||||
|
tool_call: true,
|
||||||
|
limit: { context: 1048576, output: 65535 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3-flash",
|
||||||
|
name: "Gemini 3 Flash (Antigravity)",
|
||||||
|
family: "gemini",
|
||||||
|
reasoning: false,
|
||||||
|
tool_call: true,
|
||||||
|
limit: { context: 1048576, output: 65536 }
|
||||||
|
},
|
||||||
|
// Claude Models
|
||||||
|
{
|
||||||
|
id: "claude-sonnet-4-5",
|
||||||
|
name: "Claude Sonnet 4.5 (Antigravity)",
|
||||||
|
family: "claude",
|
||||||
|
reasoning: false,
|
||||||
|
tool_call: true,
|
||||||
|
limit: { context: 200000, output: 64000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-sonnet-4-5-thinking-low",
|
||||||
|
name: "Claude Sonnet 4.5 Thinking Low (Antigravity)",
|
||||||
|
family: "claude",
|
||||||
|
reasoning: true,
|
||||||
|
tool_call: true,
|
||||||
|
limit: { context: 200000, output: 64000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-sonnet-4-5-thinking-medium",
|
||||||
|
name: "Claude Sonnet 4.5 Thinking Medium (Antigravity)",
|
||||||
|
family: "claude",
|
||||||
|
reasoning: true,
|
||||||
|
tool_call: true,
|
||||||
|
limit: { context: 200000, output: 64000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-sonnet-4-5-thinking-high",
|
||||||
|
name: "Claude Sonnet 4.5 Thinking High (Antigravity)",
|
||||||
|
family: "claude",
|
||||||
|
reasoning: true,
|
||||||
|
tool_call: true,
|
||||||
|
limit: { context: 200000, output: 64000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-opus-4-5-thinking-low",
|
||||||
|
name: "Claude Opus 4.5 Thinking Low (Antigravity)",
|
||||||
|
family: "claude",
|
||||||
|
reasoning: true,
|
||||||
|
tool_call: true,
|
||||||
|
limit: { context: 200000, output: 64000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-opus-4-5-thinking-medium",
|
||||||
|
name: "Claude Opus 4.5 Thinking Medium (Antigravity)",
|
||||||
|
family: "claude",
|
||||||
|
reasoning: true,
|
||||||
|
tool_call: true,
|
||||||
|
limit: { context: 200000, output: 64000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-opus-4-5-thinking-high",
|
||||||
|
name: "Claude Opus 4.5 Thinking High (Antigravity)",
|
||||||
|
family: "claude",
|
||||||
|
reasoning: true,
|
||||||
|
tool_call: true,
|
||||||
|
limit: { context: 200000, output: 64000 }
|
||||||
|
},
|
||||||
|
// Other Models
|
||||||
|
{
|
||||||
|
id: "gpt-oss-120b-medium",
|
||||||
|
name: "GPT-OSS 120B Medium (Antigravity)",
|
||||||
|
family: "gpt-oss",
|
||||||
|
reasoning: true,
|
||||||
|
tool_call: true,
|
||||||
|
limit: { context: 131072, output: 32768 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Token storage key for Antigravity OAuth
|
||||||
|
const ANTIGRAVITY_TOKEN_KEY = "antigravity_oauth_token"
|
||||||
|
|
||||||
|
export interface AntigravityToken {
|
||||||
|
access_token: string
|
||||||
|
refresh_token?: string
|
||||||
|
expires_in: number
|
||||||
|
created_at: number
|
||||||
|
project_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AntigravityClient {
|
||||||
|
private config: AntigravityConfig
|
||||||
|
private currentEndpointIndex: number = 0
|
||||||
|
private modelsCache: AntigravityModel[] | null = null
|
||||||
|
private modelsCacheTime: number = 0
|
||||||
|
private readonly CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
|
constructor(config?: Partial<AntigravityConfig>) {
|
||||||
|
this.config = AntigravityConfigSchema.parse(config || {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current active endpoint with automatic fallback
|
||||||
|
*/
|
||||||
|
private getEndpoint(): string {
|
||||||
|
const endpoints = this.config.endpoints
|
||||||
|
return endpoints[this.currentEndpointIndex] || endpoints[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate to next endpoint on failure
|
||||||
|
*/
|
||||||
|
private rotateEndpoint(): void {
|
||||||
|
this.currentEndpointIndex = (this.currentEndpointIndex + 1) % this.config.endpoints.length
|
||||||
|
console.log(`Antigravity: Rotating to endpoint ${this.getEndpoint()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored OAuth token from localStorage (browser context)
|
||||||
|
*/
|
||||||
|
getStoredToken(): AntigravityToken | null {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(ANTIGRAVITY_TOKEN_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the stored token is still valid
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get authorization headers for API requests
|
||||||
|
*/
|
||||||
|
private getAuthHeaders(): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "NomadArch/1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try OAuth token first
|
||||||
|
const token = this.getStoredToken()
|
||||||
|
if (token && this.isTokenValid(token)) {
|
||||||
|
headers["Authorization"] = `Bearer ${token.access_token}`
|
||||||
|
} else if (this.config.apiKey) {
|
||||||
|
headers["Authorization"] = `Bearer ${this.config.apiKey}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the client is authenticated
|
||||||
|
*/
|
||||||
|
isAuthenticated(): boolean {
|
||||||
|
const token = this.getStoredToken()
|
||||||
|
return this.isTokenValid(token) || Boolean(this.config.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available Antigravity models
|
||||||
|
*/
|
||||||
|
async getModels(): Promise<AntigravityModel[]> {
|
||||||
|
// Return cached models if still valid
|
||||||
|
const now = Date.now()
|
||||||
|
if (this.modelsCache && (now - this.modelsCacheTime) < this.CACHE_TTL_MS) {
|
||||||
|
return this.modelsCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// If authenticated, return full model list
|
||||||
|
if (this.isAuthenticated()) {
|
||||||
|
this.modelsCache = ANTIGRAVITY_MODELS
|
||||||
|
this.modelsCacheTime = now
|
||||||
|
return ANTIGRAVITY_MODELS
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not authenticated - return empty list
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection to Antigravity API
|
||||||
|
*/
|
||||||
|
async testConnection(): Promise<boolean> {
|
||||||
|
if (!this.isAuthenticated()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try a simple models list request to verify connectivity
|
||||||
|
const response = await fetch(`${this.getEndpoint()}/models`, {
|
||||||
|
headers: this.getAuthHeaders(),
|
||||||
|
signal: AbortSignal.timeout(10000)
|
||||||
|
})
|
||||||
|
return response.ok
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Antigravity connection test failed:", error)
|
||||||
|
// Try next endpoint
|
||||||
|
this.rotateEndpoint()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat completion (streaming) with automatic endpoint fallback
|
||||||
|
*/
|
||||||
|
async *chatStream(request: ChatRequest): AsyncGenerator<ChatChunk> {
|
||||||
|
if (!this.isAuthenticated()) {
|
||||||
|
throw new Error("Antigravity: Not authenticated. Please sign in with Google OAuth.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: Error | null = null
|
||||||
|
const maxRetries = this.config.endpoints.length
|
||||||
|
|
||||||
|
for (let retry = 0; retry < maxRetries; retry++) {
|
||||||
|
try {
|
||||||
|
const endpoint = this.getEndpoint()
|
||||||
|
const response = await fetch(`${endpoint}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: this.getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
...request,
|
||||||
|
stream: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new Error(`Antigravity authentication failed: ${errorText}`)
|
||||||
|
}
|
||||||
|
// Try next endpoint for other errors
|
||||||
|
this.rotateEndpoint()
|
||||||
|
lastError = new Error(`Antigravity API error (${response.status}): ${errorText}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("Response body is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ""
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split("\n")
|
||||||
|
buffer = lines.pop() || ""
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (trimmed.startsWith("data: ")) {
|
||||||
|
const data = trimmed.slice(6)
|
||||||
|
if (data === "[DONE]") return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
yield parsed as ChatChunk
|
||||||
|
|
||||||
|
// Check for finish
|
||||||
|
if (parsed.choices?.[0]?.finish_reason) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Skip invalid JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock()
|
||||||
|
}
|
||||||
|
return // Success, exit retry loop
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error))
|
||||||
|
if (error instanceof Error && error.message.includes("authentication")) {
|
||||||
|
throw error // Don't retry auth errors
|
||||||
|
}
|
||||||
|
this.rotateEndpoint()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error("Antigravity: All endpoints failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat completion (non-streaming)
|
||||||
|
*/
|
||||||
|
async chat(request: ChatRequest): Promise<ChatChunk> {
|
||||||
|
if (!this.isAuthenticated()) {
|
||||||
|
throw new Error("Antigravity: Not authenticated. Please sign in with Google OAuth.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: Error | null = null
|
||||||
|
const maxRetries = this.config.endpoints.length
|
||||||
|
|
||||||
|
for (let retry = 0; retry < maxRetries; retry++) {
|
||||||
|
try {
|
||||||
|
const endpoint = this.getEndpoint()
|
||||||
|
const response = await fetch(`${endpoint}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: this.getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
...request,
|
||||||
|
stream: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new Error(`Antigravity authentication failed: ${errorText}`)
|
||||||
|
}
|
||||||
|
this.rotateEndpoint()
|
||||||
|
lastError = new Error(`Antigravity API error (${response.status}): ${errorText}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error))
|
||||||
|
if (error instanceof Error && error.message.includes("authentication")) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
this.rotateEndpoint()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error("Antigravity: All endpoints failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultAntigravityConfig(): AntigravityConfig {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
endpoints: [
|
||||||
|
"https://daily.antigravity.dev/v1beta",
|
||||||
|
"https://autopush.antigravity.dev/v1beta",
|
||||||
|
"https://antigravity.dev/v1beta"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import { registerOllamaRoutes } from "./routes/ollama"
|
|||||||
import { registerQwenRoutes } from "./routes/qwen"
|
import { registerQwenRoutes } from "./routes/qwen"
|
||||||
import { registerZAIRoutes } from "./routes/zai"
|
import { registerZAIRoutes } from "./routes/zai"
|
||||||
import { registerOpenCodeZenRoutes } from "./routes/opencode-zen"
|
import { registerOpenCodeZenRoutes } from "./routes/opencode-zen"
|
||||||
|
import { registerAntigravityRoutes } from "./routes/antigravity"
|
||||||
import { registerSkillsRoutes } from "./routes/skills"
|
import { registerSkillsRoutes } from "./routes/skills"
|
||||||
import { registerContextEngineRoutes } from "./routes/context-engine"
|
import { registerContextEngineRoutes } from "./routes/context-engine"
|
||||||
import { registerNativeSessionsRoutes } from "./routes/native-sessions"
|
import { registerNativeSessionsRoutes } from "./routes/native-sessions"
|
||||||
@@ -131,6 +132,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
registerQwenRoutes(app, { logger: deps.logger })
|
registerQwenRoutes(app, { logger: deps.logger })
|
||||||
registerZAIRoutes(app, { logger: deps.logger })
|
registerZAIRoutes(app, { logger: deps.logger })
|
||||||
registerOpenCodeZenRoutes(app, { logger: deps.logger })
|
registerOpenCodeZenRoutes(app, { logger: deps.logger })
|
||||||
|
registerAntigravityRoutes(app, { logger: deps.logger })
|
||||||
registerSkillsRoutes(app)
|
registerSkillsRoutes(app)
|
||||||
registerContextEngineRoutes(app)
|
registerContextEngineRoutes(app)
|
||||||
|
|
||||||
|
|||||||
336
packages/server/src/server/routes/antigravity.ts
Normal file
336
packages/server/src/server/routes/antigravity.ts
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { AntigravityClient, type ChatRequest, getDefaultAntigravityConfig, type ChatMessage } from "../../integrations/antigravity"
|
||||||
|
import { Logger } from "../../logger"
|
||||||
|
import { CORE_TOOLS, executeTools, type ToolCall, type ToolResult } from "../../tools/executor"
|
||||||
|
import { getMcpManager } from "../../mcp/client"
|
||||||
|
|
||||||
|
interface AntigravityRouteDeps {
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maximum number of tool execution loops
|
||||||
|
const MAX_TOOL_LOOPS = 10
|
||||||
|
|
||||||
|
export async function registerAntigravityRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
deps: AntigravityRouteDeps
|
||||||
|
) {
|
||||||
|
const logger = deps.logger.child({ component: "antigravity-routes" })
|
||||||
|
|
||||||
|
// Create shared client
|
||||||
|
const client = new AntigravityClient(getDefaultAntigravityConfig())
|
||||||
|
|
||||||
|
// List available Antigravity models
|
||||||
|
app.get('/api/antigravity/models', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const models = await client.getModels()
|
||||||
|
|
||||||
|
return {
|
||||||
|
models: models.map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
family: m.family,
|
||||||
|
provider: "antigravity",
|
||||||
|
free: false, // These require Google OAuth
|
||||||
|
reasoning: m.reasoning,
|
||||||
|
tool_call: m.tool_call,
|
||||||
|
limit: m.limit
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to list Antigravity models")
|
||||||
|
return reply.status(500).send({ error: "Failed to list models" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check authentication status
|
||||||
|
app.get('/api/antigravity/auth-status', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const authenticated = client.isAuthenticated()
|
||||||
|
return { authenticated }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Antigravity auth status check failed")
|
||||||
|
return reply.status(500).send({ error: "Auth status check failed" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
app.get('/api/antigravity/test', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const connected = await client.testConnection()
|
||||||
|
return { connected }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Antigravity connection test failed")
|
||||||
|
return reply.status(500).send({ error: "Connection test failed" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Chat completion endpoint WITH MCP TOOL SUPPORT
|
||||||
|
app.post('/api/antigravity/chat', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const chatRequest = request.body as ChatRequest & {
|
||||||
|
workspacePath?: string
|
||||||
|
enableTools?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract workspace path for tool execution
|
||||||
|
const workspacePath = chatRequest.workspacePath || process.cwd()
|
||||||
|
const enableTools = chatRequest.enableTools !== false
|
||||||
|
|
||||||
|
logger.info({
|
||||||
|
workspacePath,
|
||||||
|
receivedWorkspacePath: chatRequest.workspacePath,
|
||||||
|
enableTools,
|
||||||
|
model: chatRequest.model
|
||||||
|
}, "Antigravity chat request received")
|
||||||
|
|
||||||
|
// Handle streaming with tool loop
|
||||||
|
if (chatRequest.stream) {
|
||||||
|
reply.raw.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await streamWithToolLoop(
|
||||||
|
client,
|
||||||
|
chatRequest,
|
||||||
|
workspacePath,
|
||||||
|
enableTools,
|
||||||
|
reply.raw,
|
||||||
|
logger
|
||||||
|
)
|
||||||
|
reply.raw.end()
|
||||||
|
} catch (streamError) {
|
||||||
|
logger.error({ error: streamError }, "Antigravity streaming failed")
|
||||||
|
reply.raw.write(`data: ${JSON.stringify({ error: String(streamError) })}\n\n`)
|
||||||
|
reply.raw.end()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-streaming with tool loop
|
||||||
|
const response = await chatWithToolLoop(
|
||||||
|
client,
|
||||||
|
chatRequest,
|
||||||
|
workspacePath,
|
||||||
|
enableTools,
|
||||||
|
logger
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Antigravity chat request failed")
|
||||||
|
return reply.status(500).send({ error: "Chat request failed" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info("Antigravity routes registered with MCP tool support - Google OAuth required!")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming chat with tool execution loop
|
||||||
|
*/
|
||||||
|
async function streamWithToolLoop(
|
||||||
|
client: AntigravityClient,
|
||||||
|
request: ChatRequest,
|
||||||
|
workspacePath: string,
|
||||||
|
enableTools: boolean,
|
||||||
|
rawResponse: any,
|
||||||
|
logger: Logger
|
||||||
|
): Promise<void> {
|
||||||
|
let messages = [...request.messages]
|
||||||
|
let loopCount = 0
|
||||||
|
|
||||||
|
// Load MCP tools from workspace config
|
||||||
|
let allTools = [...CORE_TOOLS]
|
||||||
|
if (enableTools && workspacePath) {
|
||||||
|
try {
|
||||||
|
const mcpManager = getMcpManager()
|
||||||
|
await mcpManager.loadConfig(workspacePath)
|
||||||
|
const mcpTools = await mcpManager.getToolsAsOpenAIFormat()
|
||||||
|
allTools = [...CORE_TOOLS, ...mcpTools]
|
||||||
|
if (mcpTools.length > 0) {
|
||||||
|
logger.info({ mcpToolCount: mcpTools.length }, "Loaded MCP tools for Antigravity")
|
||||||
|
}
|
||||||
|
} catch (mcpError) {
|
||||||
|
logger.warn({ error: mcpError }, "Failed to load MCP tools")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject tools if enabled
|
||||||
|
const requestWithTools: ChatRequest = {
|
||||||
|
...request,
|
||||||
|
tools: enableTools ? allTools : undefined,
|
||||||
|
tool_choice: enableTools ? "auto" : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
while (loopCount < MAX_TOOL_LOOPS) {
|
||||||
|
loopCount++
|
||||||
|
|
||||||
|
// Accumulate tool calls from stream
|
||||||
|
let accumulatedToolCalls: { [index: number]: { id: string; name: string; arguments: string } } = {}
|
||||||
|
let hasToolCalls = false
|
||||||
|
let textContent = ""
|
||||||
|
|
||||||
|
// Stream response
|
||||||
|
for await (const chunk of client.chatStream({ ...requestWithTools, messages })) {
|
||||||
|
// Write chunk to client
|
||||||
|
rawResponse.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||||
|
|
||||||
|
const choice = chunk.choices[0]
|
||||||
|
if (!choice) continue
|
||||||
|
|
||||||
|
// Accumulate text content
|
||||||
|
if (choice.delta?.content) {
|
||||||
|
textContent += choice.delta.content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accumulate tool calls from delta (if API supports it)
|
||||||
|
const deltaToolCalls = (choice.delta as any)?.tool_calls
|
||||||
|
if (deltaToolCalls) {
|
||||||
|
hasToolCalls = true
|
||||||
|
for (const tc of deltaToolCalls) {
|
||||||
|
const idx = tc.index ?? 0
|
||||||
|
if (!accumulatedToolCalls[idx]) {
|
||||||
|
accumulatedToolCalls[idx] = { id: tc.id || "", name: "", arguments: "" }
|
||||||
|
}
|
||||||
|
if (tc.id) accumulatedToolCalls[idx].id = tc.id
|
||||||
|
if (tc.function?.name) accumulatedToolCalls[idx].name += tc.function.name
|
||||||
|
if (tc.function?.arguments) accumulatedToolCalls[idx].arguments += tc.function.arguments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we should stop
|
||||||
|
if (choice.finish_reason === "stop") {
|
||||||
|
rawResponse.write('data: [DONE]\n\n')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no tool calls, we're done
|
||||||
|
if (!hasToolCalls || !enableTools) {
|
||||||
|
rawResponse.write('data: [DONE]\n\n')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert accumulated tool calls
|
||||||
|
const toolCalls: ToolCall[] = Object.values(accumulatedToolCalls).map(tc => ({
|
||||||
|
id: tc.id,
|
||||||
|
type: "function" as const,
|
||||||
|
function: {
|
||||||
|
name: tc.name,
|
||||||
|
arguments: tc.arguments
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (toolCalls.length === 0) {
|
||||||
|
rawResponse.write('data: [DONE]\n\n')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ toolCalls: toolCalls.map(tc => tc.function.name) }, "Executing tool calls")
|
||||||
|
|
||||||
|
// Add assistant message with tool calls
|
||||||
|
const assistantMessage: ChatMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: textContent || undefined,
|
||||||
|
tool_calls: toolCalls
|
||||||
|
}
|
||||||
|
messages.push(assistantMessage)
|
||||||
|
|
||||||
|
// Execute tools
|
||||||
|
const toolResults = await executeTools(workspacePath, toolCalls)
|
||||||
|
|
||||||
|
// Notify client about tool execution via special event
|
||||||
|
for (const result of toolResults) {
|
||||||
|
const toolEvent = {
|
||||||
|
type: "tool_result",
|
||||||
|
tool_call_id: result.tool_call_id,
|
||||||
|
content: result.content
|
||||||
|
}
|
||||||
|
rawResponse.write(`data: ${JSON.stringify(toolEvent)}\n\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tool results to messages
|
||||||
|
for (const result of toolResults) {
|
||||||
|
const toolMessage: ChatMessage = {
|
||||||
|
role: "tool",
|
||||||
|
content: result.content,
|
||||||
|
tool_call_id: result.tool_call_id
|
||||||
|
}
|
||||||
|
messages.push(toolMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ loopCount, toolsExecuted: toolResults.length }, "Tool loop iteration complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn({ loopCount }, "Max tool loops reached")
|
||||||
|
rawResponse.write('data: [DONE]\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-streaming chat with tool execution loop
|
||||||
|
*/
|
||||||
|
async function chatWithToolLoop(
|
||||||
|
client: AntigravityClient,
|
||||||
|
request: ChatRequest,
|
||||||
|
workspacePath: string,
|
||||||
|
enableTools: boolean,
|
||||||
|
logger: Logger
|
||||||
|
): Promise<any> {
|
||||||
|
let messages = [...request.messages]
|
||||||
|
let loopCount = 0
|
||||||
|
let lastResponse: any = null
|
||||||
|
|
||||||
|
// Inject tools if enabled
|
||||||
|
const requestWithTools: ChatRequest = {
|
||||||
|
...request,
|
||||||
|
tools: enableTools ? CORE_TOOLS : undefined,
|
||||||
|
tool_choice: enableTools ? "auto" : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
while (loopCount < MAX_TOOL_LOOPS) {
|
||||||
|
loopCount++
|
||||||
|
|
||||||
|
const response = await client.chat({ ...requestWithTools, messages, stream: false })
|
||||||
|
lastResponse = response
|
||||||
|
|
||||||
|
const choice = response.choices[0]
|
||||||
|
if (!choice) break
|
||||||
|
|
||||||
|
const toolCalls = (choice.message as any)?.tool_calls
|
||||||
|
|
||||||
|
// If no tool calls, return
|
||||||
|
if (!toolCalls || toolCalls.length === 0 || !enableTools) {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ toolCalls: toolCalls.map((tc: any) => tc.function.name) }, "Executing tool calls")
|
||||||
|
|
||||||
|
// Add assistant message
|
||||||
|
const assistantMessage: ChatMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: (choice.message as any).content || undefined,
|
||||||
|
tool_calls: toolCalls
|
||||||
|
}
|
||||||
|
messages.push(assistantMessage)
|
||||||
|
|
||||||
|
// Execute tools
|
||||||
|
const toolResults = await executeTools(workspacePath, toolCalls)
|
||||||
|
|
||||||
|
// Add tool results
|
||||||
|
for (const result of toolResults) {
|
||||||
|
const toolMessage: ChatMessage = {
|
||||||
|
role: "tool",
|
||||||
|
content: result.content,
|
||||||
|
tool_call_id: result.tool_call_id
|
||||||
|
}
|
||||||
|
messages.push(toolMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ loopCount, toolsExecuted: toolResults.length }, "Tool loop iteration complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn({ loopCount }, "Max tool loops reached")
|
||||||
|
return lastResponse
|
||||||
|
}
|
||||||
@@ -68,6 +68,8 @@ export class WorkspaceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||||
|
// Special constant for Native mode (no OpenCode binary)
|
||||||
|
const NATIVE_MODE_PATH = "__nomadarch_native__"
|
||||||
|
|
||||||
const id = `${Date.now().toString(36)}`
|
const id = `${Date.now().toString(36)}`
|
||||||
const binary = this.options.binaryRegistry.resolveDefault()
|
const binary = this.options.binaryRegistry.resolveDefault()
|
||||||
@@ -75,7 +77,15 @@ export class WorkspaceManager {
|
|||||||
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||||
clearWorkspaceSearchCache(workspacePath)
|
clearWorkspaceSearchCache(workspacePath)
|
||||||
|
|
||||||
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
|
// Check if we're in native mode
|
||||||
|
const isNativeMode = resolvedBinaryPath === NATIVE_MODE_PATH || binary.path === NATIVE_MODE_PATH
|
||||||
|
|
||||||
|
this.options.logger.info({
|
||||||
|
workspaceId: id,
|
||||||
|
folder: workspacePath,
|
||||||
|
binary: resolvedBinaryPath,
|
||||||
|
isNativeMode
|
||||||
|
}, "Creating workspace")
|
||||||
|
|
||||||
const proxyPath = `/workspaces/${id}/instance`
|
const proxyPath = `/workspaces/${id}/instance`
|
||||||
|
|
||||||
@@ -87,13 +97,14 @@ export class WorkspaceManager {
|
|||||||
status: "starting",
|
status: "starting",
|
||||||
proxyPath,
|
proxyPath,
|
||||||
binaryId: resolvedBinaryPath,
|
binaryId: resolvedBinaryPath,
|
||||||
binaryLabel: binary.label,
|
binaryLabel: isNativeMode ? "NomadArch Native" : binary.label,
|
||||||
binaryVersion: binary.version,
|
binaryVersion: isNativeMode ? "Native" : binary.version,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!descriptor.binaryVersion) {
|
// Native mode doesn't need binary version detection
|
||||||
|
if (!isNativeMode && !descriptor.binaryVersion) {
|
||||||
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
|
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +113,31 @@ export class WorkspaceManager {
|
|||||||
|
|
||||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||||
|
|
||||||
|
// In native mode, we don't launch a binary - the workspace is immediately ready
|
||||||
|
// and uses native session management APIs instead
|
||||||
|
if (isNativeMode) {
|
||||||
|
this.options.logger.info({ workspaceId: id }, "Starting native mode workspace (no binary)")
|
||||||
|
|
||||||
|
// Native mode is immediately ready - no process to launch
|
||||||
|
descriptor.status = "ready"
|
||||||
|
descriptor.updatedAt = new Date().toISOString()
|
||||||
|
// No pid or port for native mode - it uses the server's own APIs
|
||||||
|
|
||||||
|
this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor })
|
||||||
|
this.options.logger.info({ workspaceId: id }, "Native mode workspace ready")
|
||||||
|
|
||||||
|
// Trigger Context-Engine indexing (non-blocking)
|
||||||
|
const contextEngine = getContextEngineService()
|
||||||
|
if (contextEngine) {
|
||||||
|
contextEngine.indexPath(workspacePath).catch((error) => {
|
||||||
|
this.options.logger.warn({ workspaceId: id, error }, "Context-Engine indexing failed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return descriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
// SDK/binary mode - launch the OpenCode process
|
||||||
const preferences = this.options.configStore.get().preferences ?? {}
|
const preferences = this.options.configStore.get().preferences ?? {}
|
||||||
const userEnvironment = preferences.environmentVariables ?? {}
|
const userEnvironment = preferences.environmentVariables ?? {}
|
||||||
const opencodeConfigDir = ensureWorkspaceOpencodeConfig(id)
|
const opencodeConfigDir = ensureWorkspaceOpencodeConfig(id)
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ export class WorkspaceRuntime {
|
|||||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
||||||
this.validateFolder(options.folder)
|
this.validateFolder(options.folder)
|
||||||
|
|
||||||
|
// Native mode should not use the runtime - it uses native session management instead
|
||||||
|
const NATIVE_MODE_PATH = "__nomadarch_native__"
|
||||||
|
if (options.binaryPath === NATIVE_MODE_PATH) {
|
||||||
|
throw new Error(`Native mode does not use binary launches. Use native session management APIs instead.`)
|
||||||
|
}
|
||||||
|
|
||||||
// Check if binary exists before attempting to launch
|
// Check if binary exists before attempting to launch
|
||||||
try {
|
try {
|
||||||
accessSync(options.binaryPath, constants.F_OK)
|
accessSync(options.binaryPath, constants.F_OK)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import OllamaCloudSettings from "./settings/OllamaCloudSettings"
|
|||||||
import QwenCodeSettings from "./settings/QwenCodeSettings"
|
import QwenCodeSettings from "./settings/QwenCodeSettings"
|
||||||
import ZAISettings from "./settings/ZAISettings"
|
import ZAISettings from "./settings/ZAISettings"
|
||||||
import OpenCodeZenSettings from "./settings/OpenCodeZenSettings"
|
import OpenCodeZenSettings from "./settings/OpenCodeZenSettings"
|
||||||
|
import AntigravitySettings from "./settings/AntigravitySettings"
|
||||||
|
|
||||||
interface AdvancedSettingsModalProps {
|
interface AdvancedSettingsModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -75,6 +76,15 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
|
|||||||
>
|
>
|
||||||
Z.AI
|
Z.AI
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -115,6 +125,10 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
|
|||||||
<Show when={activeTab() === "zai"}>
|
<Show when={activeTab() === "zai"}>
|
||||||
<ZAISettings />
|
<ZAISettings />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={activeTab() === "antigravity"}>
|
||||||
|
<AntigravitySettings />
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-6 py-4 border-t flex justify-end" style={{ "border-color": "var(--border-base)" }}>
|
<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(
|
async function sendMessage(
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -1264,7 +1402,7 @@ async function sendMessage(
|
|||||||
addDebugLog(`Merge System Instructions: ${Math.round(tPre2 - tPre1)}ms`, "warn")
|
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 store = messageStoreBus.getOrCreate(instanceId)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const assistantMessageId = createId("msg")
|
const assistantMessageId = createId("msg")
|
||||||
@@ -1347,6 +1485,17 @@ async function sendMessage(
|
|||||||
assistantMessageId,
|
assistantMessageId,
|
||||||
assistantPartId,
|
assistantPartId,
|
||||||
)
|
)
|
||||||
|
} else if (providerId === "antigravity") {
|
||||||
|
await streamAntigravityChat(
|
||||||
|
instanceId,
|
||||||
|
sessionId,
|
||||||
|
providerId,
|
||||||
|
effectiveModel.modelId,
|
||||||
|
externalMessages,
|
||||||
|
messageId,
|
||||||
|
assistantMessageId,
|
||||||
|
assistantPartId,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
const qwenManager = new QwenOAuthManager()
|
const qwenManager = new QwenOAuthManager()
|
||||||
const token = await qwenManager.getValidToken()
|
const token = await qwenManager.getValidToken()
|
||||||
@@ -1428,7 +1577,9 @@ async function sendMessage(
|
|||||||
? "Z.AI request failed"
|
? "Z.AI request failed"
|
||||||
: providerId === "opencode-zen"
|
: providerId === "opencode-zen"
|
||||||
? "OpenCode Zen request failed"
|
? "OpenCode Zen request failed"
|
||||||
: "Qwen request failed",
|
: providerId === "antigravity"
|
||||||
|
? "Antigravity request failed"
|
||||||
|
: "Qwen request failed",
|
||||||
message: error?.message || "Request failed",
|
message: error?.message || "Request failed",
|
||||||
variant: "error",
|
variant: "error",
|
||||||
duration: 8000,
|
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[]> {
|
async function fetchExtraProviders(): Promise<Provider[]> {
|
||||||
const [ollama, zen, qwen, zai] = await Promise.all([
|
const [ollama, zen, qwen, zai, antigravity] = await Promise.all([
|
||||||
fetchOllamaCloudProvider(),
|
fetchOllamaCloudProvider(),
|
||||||
fetchOpenCodeZenProvider(),
|
fetchOpenCodeZenProvider(),
|
||||||
fetchQwenOAuthProvider(),
|
fetchQwenOAuthProvider(),
|
||||||
fetchZAIProvider(),
|
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[] {
|
function removeDuplicateProviders(base: Provider[], extras: Provider[]): Provider[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user