Compare commits
4 Commits
bb1c0d81f2
...
eaf93e2924
@@ -11,6 +11,30 @@ interface AntigravityRouteDeps {
|
||||
// Maximum number of tool execution loops
|
||||
const MAX_TOOL_LOOPS = 10
|
||||
|
||||
// Google OAuth Device Flow configuration
|
||||
// Using the same client ID as gcloud CLI / Cloud SDK
|
||||
const GOOGLE_OAUTH_CONFIG = {
|
||||
clientId: "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com",
|
||||
clientSecret: "d-FL95Q19q7MQmFpd7hHD0Ty", // Public client secret for device flow
|
||||
deviceAuthEndpoint: "https://oauth2.googleapis.com/device/code",
|
||||
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
||||
scopes: [
|
||||
"openid",
|
||||
"email",
|
||||
"profile",
|
||||
"https://www.googleapis.com/auth/cloud-platform"
|
||||
]
|
||||
}
|
||||
|
||||
// Active device auth sessions (in-memory, per-server instance)
|
||||
const deviceAuthSessions = new Map<string, {
|
||||
deviceCode: string
|
||||
userCode: string
|
||||
verificationUrl: string
|
||||
expiresAt: number
|
||||
interval: number
|
||||
}>()
|
||||
|
||||
export async function registerAntigravityRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: AntigravityRouteDeps
|
||||
@@ -65,6 +89,193 @@ export async function registerAntigravityRoutes(
|
||||
}
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// Google Device Authorization Flow Endpoints
|
||||
// ==========================================
|
||||
|
||||
// Step 1: Start device authorization - returns user_code and verification URL
|
||||
app.post('/api/antigravity/device-auth/start', async (request, reply) => {
|
||||
try {
|
||||
logger.info("Starting Google Device Authorization flow for Antigravity")
|
||||
|
||||
const response = await fetch(GOOGLE_OAUTH_CONFIG.deviceAuthEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: GOOGLE_OAUTH_CONFIG.clientId,
|
||||
scope: GOOGLE_OAUTH_CONFIG.scopes.join(' ')
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
logger.error({ error, status: response.status }, "Device auth request failed")
|
||||
return reply.status(500).send({ error: "Failed to start device authorization" })
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_url: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
// Generate a session ID for tracking this auth flow
|
||||
const sessionId = crypto.randomUUID()
|
||||
|
||||
// Store the session
|
||||
deviceAuthSessions.set(sessionId, {
|
||||
deviceCode: data.device_code,
|
||||
userCode: data.user_code,
|
||||
verificationUrl: data.verification_url,
|
||||
expiresAt: Date.now() + (data.expires_in * 1000),
|
||||
interval: data.interval
|
||||
})
|
||||
|
||||
// Clean up expired sessions
|
||||
for (const [id, session] of deviceAuthSessions) {
|
||||
if (session.expiresAt < Date.now()) {
|
||||
deviceAuthSessions.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({ sessionId, userCode: data.user_code }, "Device auth session created")
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
userCode: data.user_code,
|
||||
verificationUrl: data.verification_url,
|
||||
expiresIn: data.expires_in,
|
||||
interval: data.interval
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to start device authorization")
|
||||
return reply.status(500).send({ error: "Failed to start device authorization" })
|
||||
}
|
||||
})
|
||||
|
||||
// Step 2: Poll for token (called by client after user enters code)
|
||||
app.post('/api/antigravity/device-auth/poll', async (request, reply) => {
|
||||
try {
|
||||
const { sessionId } = request.body as { sessionId: string }
|
||||
|
||||
if (!sessionId) {
|
||||
return reply.status(400).send({ error: "Missing sessionId" })
|
||||
}
|
||||
|
||||
const session = deviceAuthSessions.get(sessionId)
|
||||
if (!session) {
|
||||
return reply.status(404).send({ error: "Session not found or expired" })
|
||||
}
|
||||
|
||||
if (session.expiresAt < Date.now()) {
|
||||
deviceAuthSessions.delete(sessionId)
|
||||
return reply.status(410).send({ error: "Session expired" })
|
||||
}
|
||||
|
||||
// Poll Google's token endpoint
|
||||
const response = await fetch(GOOGLE_OAUTH_CONFIG.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: GOOGLE_OAUTH_CONFIG.clientId,
|
||||
client_secret: GOOGLE_OAUTH_CONFIG.clientSecret,
|
||||
device_code: session.deviceCode,
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json() as any
|
||||
|
||||
if (data.error) {
|
||||
// Still waiting for user
|
||||
if (data.error === 'authorization_pending') {
|
||||
return { status: 'pending', interval: session.interval }
|
||||
}
|
||||
// Slow down polling
|
||||
if (data.error === 'slow_down') {
|
||||
session.interval = Math.min(session.interval + 5, 60)
|
||||
return { status: 'pending', interval: session.interval }
|
||||
}
|
||||
// User denied or other error
|
||||
if (data.error === 'access_denied') {
|
||||
deviceAuthSessions.delete(sessionId)
|
||||
return { status: 'denied' }
|
||||
}
|
||||
if (data.error === 'expired_token') {
|
||||
deviceAuthSessions.delete(sessionId)
|
||||
return { status: 'expired' }
|
||||
}
|
||||
|
||||
logger.error({ error: data.error }, "Token poll error")
|
||||
return { status: 'error', error: data.error }
|
||||
}
|
||||
|
||||
// Success! We have tokens
|
||||
deviceAuthSessions.delete(sessionId)
|
||||
|
||||
logger.info("Device authorization successful")
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresIn: data.expires_in,
|
||||
tokenType: data.token_type,
|
||||
scope: data.scope
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to poll for token")
|
||||
return reply.status(500).send({ error: "Failed to poll for token" })
|
||||
}
|
||||
})
|
||||
|
||||
// Refresh an expired token
|
||||
app.post('/api/antigravity/device-auth/refresh', async (request, reply) => {
|
||||
try {
|
||||
const { refreshToken } = request.body as { refreshToken: string }
|
||||
|
||||
if (!refreshToken) {
|
||||
return reply.status(400).send({ error: "Missing refreshToken" })
|
||||
}
|
||||
|
||||
const response = await fetch(GOOGLE_OAUTH_CONFIG.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: GOOGLE_OAUTH_CONFIG.clientId,
|
||||
client_secret: GOOGLE_OAUTH_CONFIG.clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token'
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
logger.error({ error }, "Token refresh failed")
|
||||
return reply.status(401).send({ error: "Token refresh failed" })
|
||||
}
|
||||
|
||||
const data = await response.json() as any
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
expiresIn: data.expires_in,
|
||||
tokenType: data.token_type
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to refresh token")
|
||||
return reply.status(500).send({ error: "Failed to refresh token" })
|
||||
}
|
||||
})
|
||||
|
||||
// Chat completion endpoint WITH MCP TOOL SUPPORT
|
||||
app.post('/api/antigravity/chat', async (request, reply) => {
|
||||
try {
|
||||
@@ -124,7 +335,7 @@ export async function registerAntigravityRoutes(
|
||||
}
|
||||
})
|
||||
|
||||
logger.info("Antigravity routes registered with MCP tool support - Google OAuth required!")
|
||||
logger.info("Antigravity routes registered with Google Device Auth flow!")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -105,6 +105,42 @@ export function registerNativeSessionsRoutes(app: FastifyInstance, deps: NativeS
|
||||
}
|
||||
})
|
||||
|
||||
// Fork a session
|
||||
app.post<{
|
||||
Params: { workspaceId: string; sessionId: string }
|
||||
}>("/api/native/workspaces/:workspaceId/sessions/:sessionId/fork", async (request, reply) => {
|
||||
try {
|
||||
const session = await sessionManager.forkSession(
|
||||
request.params.workspaceId,
|
||||
request.params.sessionId
|
||||
)
|
||||
return { session }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to fork session")
|
||||
reply.code(500)
|
||||
return { error: "Failed to fork session" }
|
||||
}
|
||||
})
|
||||
|
||||
// Revert a session
|
||||
app.post<{
|
||||
Params: { workspaceId: string; sessionId: string }
|
||||
Body: { messageId?: string }
|
||||
}>("/api/native/workspaces/:workspaceId/sessions/:sessionId/revert", async (request, reply) => {
|
||||
try {
|
||||
const session = await sessionManager.revert(
|
||||
request.params.workspaceId,
|
||||
request.params.sessionId,
|
||||
request.body.messageId
|
||||
)
|
||||
return { session }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to revert session")
|
||||
reply.code(500)
|
||||
return { error: "Failed to revert session" }
|
||||
}
|
||||
})
|
||||
|
||||
// Delete a session
|
||||
app.delete<{ Params: { workspaceId: string; sessionId: string } }>("/api/native/workspaces/:workspaceId/sessions/:sessionId", async (request, reply) => {
|
||||
try {
|
||||
|
||||
@@ -200,6 +200,54 @@ export class NativeSessionManager {
|
||||
return true
|
||||
}
|
||||
|
||||
async forkSession(workspaceId: string, sessionId: string): Promise<Session> {
|
||||
const store = await this.loadStore(workspaceId)
|
||||
const original = store.sessions[sessionId]
|
||||
if (!original) throw new Error(`Session not found: ${sessionId}`)
|
||||
|
||||
const now = Date.now()
|
||||
const forked: Session = {
|
||||
...original,
|
||||
id: ulid(),
|
||||
title: original.title ? `${original.title} (fork)` : "Forked Session",
|
||||
parentId: original.parentId || original.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
messageIds: [...original.messageIds], // Shallow copy of message IDs
|
||||
}
|
||||
|
||||
store.sessions[forked.id] = forked
|
||||
await this.saveStore(workspaceId)
|
||||
return forked
|
||||
}
|
||||
|
||||
async revert(workspaceId: string, sessionId: string, messageId?: string): Promise<Session> {
|
||||
const store = await this.loadStore(workspaceId)
|
||||
const session = store.sessions[sessionId]
|
||||
if (!session) throw new Error(`Session not found: ${sessionId}`)
|
||||
|
||||
if (!messageId) {
|
||||
// Revert last message
|
||||
if (session.messageIds.length > 0) {
|
||||
const lastId = session.messageIds.pop()
|
||||
if (lastId) delete store.messages[lastId]
|
||||
}
|
||||
} else {
|
||||
// Revert to specific message
|
||||
const index = session.messageIds.indexOf(messageId)
|
||||
if (index !== -1) {
|
||||
const toDelete = session.messageIds.splice(index + 1)
|
||||
for (const id of toDelete) {
|
||||
delete store.messages[id]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session.updatedAt = Date.now()
|
||||
await this.saveStore(workspaceId)
|
||||
return session
|
||||
}
|
||||
|
||||
// Message operations
|
||||
|
||||
async getSessionMessages(workspaceId: string, sessionId: string): Promise<SessionMessage[]> {
|
||||
|
||||
@@ -601,7 +601,7 @@ You are committed to excellence and take pride in delivering code that professio
|
||||
<div class="px-3 py-1.5 flex items-center justify-between border-t border-white/5 bg-zinc-950/30">
|
||||
<span class="text-[9px] font-bold text-zinc-500 uppercase tracking-widest">Saved Agents</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); loadAgents(); fetchAgents(); }}
|
||||
onClick={(e) => { e.stopPropagation(); loadAgents(); fetchAgents(props.instanceId); }}
|
||||
class="p-1 hover:bg-white/5 rounded text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
title="Refresh agents"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, createSignal, onMount, For, Show } from 'solid-js'
|
||||
import { Rocket, CheckCircle, XCircle, Loader, Sparkles, LogIn, LogOut, Shield } from 'lucide-solid'
|
||||
import { Component, createSignal, onMount, onCleanup, For, Show } from 'solid-js'
|
||||
import { Rocket, CheckCircle, XCircle, Loader, Sparkles, LogIn, LogOut, Shield, ExternalLink, Copy } from 'lucide-solid'
|
||||
import { getUserScopedKey } from '../../lib/user-storage'
|
||||
|
||||
interface AntigravityModel {
|
||||
@@ -22,16 +22,25 @@ interface AntigravityToken {
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Device auth state
|
||||
const [isAuthenticating, setIsAuthenticating] = createSignal(false)
|
||||
const [deviceAuthSession, setDeviceAuthSession] = createSignal<{
|
||||
sessionId: string
|
||||
userCode: string
|
||||
verificationUrl: string
|
||||
} | null>(null)
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
let pollInterval: number | undefined
|
||||
|
||||
// Check stored token on mount
|
||||
onMount(async () => {
|
||||
checkAuthStatus()
|
||||
@@ -39,6 +48,12 @@ const AntigravitySettings: Component = () => {
|
||||
await testConnection()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
}
|
||||
})
|
||||
|
||||
const getStoredToken = (): AntigravityToken | null => {
|
||||
if (typeof window === "undefined") return null
|
||||
try {
|
||||
@@ -100,63 +115,83 @@ const AntigravitySettings: Component = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const startGoogleOAuth = async () => {
|
||||
// Start device authorization flow
|
||||
const startDeviceAuth = 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()
|
||||
const response = await fetch('/api/antigravity/device-auth/start', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
// 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.')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to start authentication')
|
||||
}
|
||||
|
||||
// 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)
|
||||
const data = await response.json() as {
|
||||
sessionId: string
|
||||
userCode: string
|
||||
verificationUrl: string
|
||||
expiresIn: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
if (accessToken) {
|
||||
setDeviceAuthSession({
|
||||
sessionId: data.sessionId,
|
||||
userCode: data.userCode,
|
||||
verificationUrl: data.verificationUrl
|
||||
})
|
||||
|
||||
// Start polling for token
|
||||
const pollIntervalMs = (data.interval || 5) * 1000
|
||||
pollInterval = window.setInterval(() => {
|
||||
pollForToken(data.sessionId)
|
||||
}, pollIntervalMs)
|
||||
|
||||
// Open verification URL in new tab
|
||||
window.open(data.verificationUrl, '_blank')
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Device auth error:', err)
|
||||
setError(err.message || 'Authentication failed')
|
||||
setIsAuthenticating(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Poll for token completion
|
||||
const pollForToken = async (sessionId: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/antigravity/device-auth/poll', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
if (response.status === 410 || response.status === 404) {
|
||||
// Session expired
|
||||
stopPolling()
|
||||
setError('Session expired. Please try again.')
|
||||
return
|
||||
}
|
||||
throw new Error(errorData.error || 'Poll failed')
|
||||
}
|
||||
|
||||
const data = await response.json() as any
|
||||
|
||||
if (data.status === 'pending') {
|
||||
// Still waiting, continue polling
|
||||
return
|
||||
}
|
||||
|
||||
if (data.status === 'success') {
|
||||
// Got tokens! Save them
|
||||
const token: AntigravityToken = {
|
||||
access_token: accessToken,
|
||||
expires_in: expiresIn,
|
||||
access_token: data.accessToken,
|
||||
refresh_token: data.refreshToken,
|
||||
expires_in: data.expiresIn,
|
||||
created_at: Date.now()
|
||||
}
|
||||
|
||||
@@ -165,39 +200,63 @@ const AntigravitySettings: Component = () => {
|
||||
JSON.stringify(token)
|
||||
)
|
||||
|
||||
popup.close()
|
||||
clearInterval(checkClosed)
|
||||
setIsAuthenticating(false)
|
||||
stopPolling()
|
||||
setAuthStatus('authenticated')
|
||||
setError(null)
|
||||
loadModels()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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()
|
||||
if (data.status === 'denied') {
|
||||
stopPolling()
|
||||
setError('Access was denied. Please try again.')
|
||||
return
|
||||
}
|
||||
|
||||
if (data.status === 'expired') {
|
||||
stopPolling()
|
||||
setError('Session expired. Please try again.')
|
||||
return
|
||||
}
|
||||
|
||||
if (data.status === 'error') {
|
||||
stopPolling()
|
||||
setError(data.error || 'Authentication failed')
|
||||
return
|
||||
}
|
||||
setIsAuthenticating(false)
|
||||
}, 300000)
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('OAuth error:', err)
|
||||
setError(err.message || 'Authentication failed')
|
||||
setIsAuthenticating(false)
|
||||
console.error('Poll error:', err)
|
||||
// Don't stop polling on network errors, just log them
|
||||
}
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = undefined
|
||||
}
|
||||
setIsAuthenticating(false)
|
||||
setDeviceAuthSession(null)
|
||||
}
|
||||
|
||||
const cancelAuth = () => {
|
||||
stopPolling()
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const signOut = () => {
|
||||
window.localStorage.removeItem(getUserScopedKey(ANTIGRAVITY_TOKEN_KEY))
|
||||
setAuthStatus('unauthenticated')
|
||||
setModels([])
|
||||
}
|
||||
|
||||
const copyCode = async () => {
|
||||
const session = deviceAuthSession()
|
||||
if (session) {
|
||||
await navigator.clipboard.writeText(session.userCode)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
@@ -223,7 +282,7 @@ const AntigravitySettings: Component = () => {
|
||||
</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>
|
||||
<p class="text-sm text-zinc-400">Premium models via Google authentication</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -254,22 +313,22 @@ const AntigravitySettings: Component = () => {
|
||||
<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>
|
||||
<h3 class="font-semibold text-purple-300 mb-1">Premium AI Models</h3>
|
||||
<p class="text-sm text-zinc-300">
|
||||
Antigravity provides access to Gemini 3 Pro/Flash, Claude Sonnet 4.5, Claude Opus 4.5,
|
||||
and GPT-OSS 120B through Google's rate limits. Sign in with your Google account to get started.
|
||||
and GPT-OSS 120B through Google's infrastructure. 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="bg-zinc-900/50 border border-zinc-800 rounded-xl p-4 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Shield class="w-5 h-5 text-zinc-400" />
|
||||
<div>
|
||||
<h4 class="font-medium text-white">Google OAuth Authentication</h4>
|
||||
<h4 class="font-medium text-white">Google Authentication</h4>
|
||||
<p class="text-xs text-zinc-500">
|
||||
{authStatus() === 'authenticated'
|
||||
? 'You are signed in and can use Antigravity models'
|
||||
@@ -278,26 +337,6 @@ const AntigravitySettings: Component = () => {
|
||||
</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">
|
||||
@@ -314,6 +353,75 @@ const AntigravitySettings: Component = () => {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Not authenticated - show login button or device auth flow */}
|
||||
<Show when={authStatus() === 'unauthenticated'}>
|
||||
<Show when={!deviceAuthSession()}>
|
||||
<button
|
||||
onClick={startDeviceAuth}
|
||||
disabled={isAuthenticating()}
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 hover:bg-purple-500 disabled:bg-purple-600/50 text-white rounded-lg transition-colors"
|
||||
>
|
||||
{isAuthenticating() ? (
|
||||
<>
|
||||
<Loader class="w-5 h-5 animate-spin" />
|
||||
Starting authentication...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn class="w-5 h-5" />
|
||||
Sign in with Google
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
{/* Device auth in progress - show code */}
|
||||
<Show when={deviceAuthSession()}>
|
||||
<div class="bg-purple-500/10 border border-purple-500/30 rounded-lg p-4 space-y-4">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-zinc-300 mb-3">
|
||||
Enter this code on the Google sign-in page:
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<code class="px-6 py-3 bg-zinc-900 rounded-lg text-2xl font-mono font-bold text-white tracking-widest">
|
||||
{deviceAuthSession()?.userCode}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyCode}
|
||||
class="p-2 text-zinc-400 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors"
|
||||
title="Copy code"
|
||||
>
|
||||
{copied() ? <CheckCircle class="w-5 h-5 text-emerald-400" /> : <Copy class="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center gap-2 text-sm text-purple-300">
|
||||
<Loader class="w-4 h-4 animate-spin" />
|
||||
Waiting for you to complete sign-in...
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center gap-4">
|
||||
<a
|
||||
href={deviceAuthSession()?.verificationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded-lg text-sm transition-colors"
|
||||
>
|
||||
<ExternalLink class="w-4 h-4" />
|
||||
Open Google Sign-in
|
||||
</a>
|
||||
<button
|
||||
onClick={cancelAuth}
|
||||
class="px-4 py-2 text-zinc-400 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
@@ -391,21 +499,9 @@ const AntigravitySettings: Component = () => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!isLoading() && models().length === 0 && authStatus() === 'authenticated'}>
|
||||
<Show when={!isLoading() && models().length === 0}>
|
||||
<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>
|
||||
<p>Models will be available after signing in.</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -414,11 +510,11 @@ const AntigravitySettings: Component = () => {
|
||||
<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>• Click "Sign in with Google" and enter the code on the Google page</li>
|
||||
<li>• Once authenticated, select any Antigravity model from the chat model picker</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>
|
||||
<li>• Full tool use and MCP support included</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,7 @@ interface ToolCallProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -671,6 +671,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
<Markdown
|
||||
part={markdownPart}
|
||||
isDark={isDark()}
|
||||
instanceId={props.instanceId}
|
||||
disableHighlight={disableHighlight}
|
||||
onRendered={handleMarkdownRendered}
|
||||
/>
|
||||
|
||||
@@ -138,6 +138,26 @@ export const nativeSessionApi = {
|
||||
return response.ok || response.status === 204
|
||||
},
|
||||
|
||||
async forkSession(workspaceId: string, sessionId: string): Promise<NativeSession> {
|
||||
const response = await fetch(`${CODENOMAD_API_BASE}/api/native/workspaces/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/fork`, {
|
||||
method: "POST"
|
||||
})
|
||||
if (!response.ok) throw new Error("Failed to fork session")
|
||||
const data = await response.json()
|
||||
return data.session
|
||||
},
|
||||
|
||||
async revertSession(workspaceId: string, sessionId: string, messageId?: string): Promise<NativeSession> {
|
||||
const response = await fetch(`${CODENOMAD_API_BASE}/api/native/workspaces/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/revert`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messageId })
|
||||
})
|
||||
if (!response.ok) throw new Error("Failed to revert session")
|
||||
const data = await response.json()
|
||||
return data.session
|
||||
},
|
||||
|
||||
async getMessages(workspaceId: string, sessionId: string): Promise<NativeMessage[]> {
|
||||
const response = await fetch(`${CODENOMAD_API_BASE}/api/native/workspaces/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/messages`)
|
||||
if (!response.ok) throw new Error("Failed to get messages")
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
|
||||
import { createSignal, createMemo, batch } from "solid-js"
|
||||
import type { Session } from "../types/session"
|
||||
import type { Message, Part } from "../types/message"
|
||||
import { nativeSessionApi, isLiteMode, NativeSession, NativeMessage } from "../lib/lite-mode"
|
||||
import type { Message } from "../types/message"
|
||||
import { nativeSessionApi, isLiteMode } from "../lib/lite-mode"
|
||||
import type { NativeSession, NativeMessage } from "../lib/lite-mode"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("native-sessions")
|
||||
@@ -53,24 +54,29 @@ export function forceLiteMode(enabled: boolean): void {
|
||||
}
|
||||
|
||||
// Convert native session to UI session format
|
||||
function nativeToUiSession(native: NativeSession): Session {
|
||||
function nativeToUiSession(native: NativeSession, workspaceId?: string): Session {
|
||||
return {
|
||||
id: native.id,
|
||||
title: native.title,
|
||||
parentId: native.parentId ?? undefined,
|
||||
createdAt: native.createdAt,
|
||||
updatedAt: native.updatedAt,
|
||||
agent: native.agent,
|
||||
instanceId: workspaceId || native.workspaceId,
|
||||
title: native.title || "",
|
||||
parentId: native.parentId ?? null,
|
||||
agent: native.agent || "Assistant",
|
||||
model: native.model ? {
|
||||
providerId: native.model.providerId,
|
||||
modelId: native.model.modelId,
|
||||
} : undefined,
|
||||
} : { providerId: "", modelId: "" },
|
||||
version: "0",
|
||||
time: {
|
||||
created: native.createdAt,
|
||||
updated: native.updatedAt
|
||||
},
|
||||
skills: []
|
||||
}
|
||||
}
|
||||
|
||||
// Convert native message to UI message format
|
||||
function nativeToUiMessage(native: NativeMessage): Message {
|
||||
const parts: Part[] = []
|
||||
const parts: any[] = []
|
||||
|
||||
if (native.content) {
|
||||
parts.push({
|
||||
@@ -82,19 +88,22 @@ function nativeToUiMessage(native: NativeMessage): Message {
|
||||
return {
|
||||
id: native.id,
|
||||
sessionId: native.sessionId,
|
||||
role: native.role,
|
||||
createdAt: native.createdAt,
|
||||
type: native.role === "user" ? "user" : "assistant",
|
||||
parts,
|
||||
timestamp: native.createdAt,
|
||||
status: native.status === "completed" ? "complete" : "streaming",
|
||||
version: 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fetch sessions from native API
|
||||
*/
|
||||
export async function fetchNativeSessions(workspaceId: string): Promise<Session[]> {
|
||||
try {
|
||||
const sessions = await nativeSessionApi.listSessions(workspaceId)
|
||||
const uiSessions = sessions.map(nativeToUiSession)
|
||||
const uiSessions = sessions.map(s => nativeToUiSession(s, workspaceId))
|
||||
|
||||
// Update state
|
||||
setNativeSessions(prev => {
|
||||
@@ -227,9 +236,11 @@ export async function sendNativeMessage(
|
||||
const userMessage: Message = {
|
||||
id: `temp-${Date.now()}`,
|
||||
sessionId,
|
||||
role: "user",
|
||||
createdAt: Date.now(),
|
||||
parts: [{ type: "text", text: content }],
|
||||
type: "user",
|
||||
timestamp: Date.now(),
|
||||
parts: [{ type: "text", text: content } as any],
|
||||
status: "complete",
|
||||
version: 0
|
||||
}
|
||||
|
||||
const key = `${workspaceId}:${sessionId}`
|
||||
@@ -264,9 +275,11 @@ export async function sendNativeMessage(
|
||||
const assistantMessage: Message = {
|
||||
id: `msg-${Date.now()}`,
|
||||
sessionId,
|
||||
role: "assistant",
|
||||
createdAt: Date.now(),
|
||||
parts: [{ type: "text", text: fullContent }],
|
||||
type: "assistant",
|
||||
timestamp: Date.now(),
|
||||
parts: [{ type: "text", text: fullContent } as any],
|
||||
status: "complete",
|
||||
version: 0
|
||||
}
|
||||
|
||||
setNativeMessages(prev => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { instances, activeInstanceId } from "./instances"
|
||||
import { addTaskMessage } from "./task-actions"
|
||||
|
||||
import { addRecentModelPreference, setAgentModelPreference, getAgentModelPreference } from "./preferences"
|
||||
import { sessions, withSession, providers, setActiveParentSession, setActiveSession } from "./session-state"
|
||||
import { sessions, setSessions, withSession, providers, setActiveParentSession, setActiveSession } from "./session-state"
|
||||
import { getDefaultModel, isModelValid } from "./session-models"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
@@ -25,6 +25,8 @@ import { QwenOAuthManager } from "../lib/integrations/qwen-oauth"
|
||||
import { getUserScopedKey } from "../lib/user-storage"
|
||||
import { loadSkillDetails } from "./skills"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { nativeSessionApi } from "../lib/lite-mode"
|
||||
import type { Session } from "../types/session"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -1936,10 +1938,18 @@ async function updateSessionAgent(instanceId: string, sessionId: string, agent:
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
if (agent && shouldApplyModel && !agentModelPreference) {
|
||||
await setAgentModelPreference(instanceId, agent, nextModel)
|
||||
}
|
||||
|
||||
const instance = instances().get(instanceId)
|
||||
const isNative = instance?.binaryPath === "__nomadarch_native__"
|
||||
if (isNative) {
|
||||
await nativeSessionApi.updateSession(instanceId, sessionId, { agent })
|
||||
|
||||
}
|
||||
|
||||
if (shouldApplyModel) {
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
@@ -1965,6 +1975,16 @@ async function updateSessionModel(
|
||||
current.model = model
|
||||
})
|
||||
|
||||
const instance = instances().get(instanceId)
|
||||
if (instance?.binaryPath === "__nomadarch_native__") {
|
||||
await nativeSessionApi.updateSession(instanceId, sessionId, {
|
||||
model: {
|
||||
providerId: model.providerId,
|
||||
modelId: model.modelId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const propagateModel = (targetSessionId?: string | null) => {
|
||||
if (!targetSessionId || targetSessionId === sessionId) return
|
||||
withSession(instanceId, targetSessionId, (current) => {
|
||||
@@ -2014,16 +2034,31 @@ async function updateSessionModelForSession(
|
||||
current.model = model
|
||||
})
|
||||
|
||||
const instance = instances().get(instanceId)
|
||||
if (instance?.binaryPath === "__nomadarch_native__") {
|
||||
await nativeSessionApi.updateSession(instanceId, sessionId, {
|
||||
model: {
|
||||
providerId: model.providerId,
|
||||
modelId: model.modelId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
addRecentModelPreference(model)
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
|
||||
async function renameSession(instanceId: string, sessionId: string, nextTitle: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
if (!instance) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const isNative = instance.binaryPath === "__nomadarch_native__"
|
||||
if (!isNative && !instance.client) {
|
||||
throw new Error("Instance client not ready")
|
||||
}
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
@@ -2034,10 +2069,14 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
|
||||
throw new Error("Session title is required")
|
||||
}
|
||||
|
||||
await instance.client.session.update({
|
||||
if (isNative) {
|
||||
await nativeSessionApi.updateSession(instanceId, sessionId, { title: trimmedTitle })
|
||||
} else {
|
||||
await instance.client!.session.update({
|
||||
path: { id: sessionId },
|
||||
body: { title: trimmedTitle },
|
||||
})
|
||||
}
|
||||
|
||||
withSession(instanceId, sessionId, (current) => {
|
||||
current.title = trimmedTitle
|
||||
@@ -2049,19 +2088,28 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
|
||||
|
||||
async function revertSession(instanceId: string, sessionId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
if (!instance) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const isNative = instance.binaryPath === "__nomadarch_native__"
|
||||
if (!isNative && !instance.client) {
|
||||
throw new Error("Instance client not ready")
|
||||
}
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
try {
|
||||
await instance.client.session.revert({
|
||||
if (isNative) {
|
||||
await nativeSessionApi.revertSession(instanceId, sessionId)
|
||||
} else {
|
||||
await instance.client!.session.revert({
|
||||
path: { id: sessionId },
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to revert session", error)
|
||||
throw error
|
||||
@@ -2070,30 +2118,76 @@ async function revertSession(instanceId: string, sessionId: string): Promise<voi
|
||||
|
||||
async function forkSession(instanceId: string, sessionId: string): Promise<string> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
if (!instance) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const isNative = instance.binaryPath === "__nomadarch_native__"
|
||||
if (!isNative && !instance.client) {
|
||||
throw new Error("Instance client not ready")
|
||||
}
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await instance.client.session.fork({
|
||||
let forkedId: string = ""
|
||||
let forkedVersion: string = "0"
|
||||
let forkedTime: any = { created: Date.now(), updated: Date.now() }
|
||||
let forkedRevert: any = undefined
|
||||
|
||||
if (isNative) {
|
||||
const response = await nativeSessionApi.forkSession(instanceId, sessionId)
|
||||
forkedId = response.id
|
||||
forkedTime = { created: response.createdAt, updated: response.updatedAt }
|
||||
} else {
|
||||
const response = await instance.client!.session.fork({
|
||||
path: { id: sessionId },
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(JSON.stringify(response.error) || "Failed to fork session")
|
||||
if (!response.data) {
|
||||
throw new Error("Failed to fork session: No data returned")
|
||||
}
|
||||
forkedId = response.data.id
|
||||
forkedVersion = response.data.version
|
||||
forkedTime = response.data.time
|
||||
forkedRevert = response.data.revert
|
||||
? {
|
||||
messageID: response.data.revert.messageID,
|
||||
partID: response.data.revert.partID,
|
||||
snapshot: response.data.revert.snapshot,
|
||||
diff: response.data.revert.diff,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
const newSessionId = response.data?.id
|
||||
if (!newSessionId) {
|
||||
if (!forkedId) {
|
||||
throw new Error("No session ID returned from fork operation")
|
||||
}
|
||||
|
||||
return newSessionId
|
||||
const forkedSession: Session = {
|
||||
id: forkedId,
|
||||
instanceId,
|
||||
title: session.title ? `${session.title} (fork)` : "Forked Session",
|
||||
parentId: session.parentId || session.id,
|
||||
agent: session.agent,
|
||||
model: session.model,
|
||||
skills: [...(session.skills || [])],
|
||||
version: forkedVersion,
|
||||
time: forkedTime,
|
||||
revert: forkedRevert
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = new Map(next.get(instanceId) || [])
|
||||
instanceSessions.set(forkedSession.id, forkedSession)
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
|
||||
return forkedId
|
||||
} catch (error) {
|
||||
log.error("Failed to fork session", error)
|
||||
throw error
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Session, Provider, Model } from "../types/session"
|
||||
import type { Message } from "../types/message"
|
||||
|
||||
import { instances } from "./instances"
|
||||
import { nativeSessionApi } from "../lib/lite-mode"
|
||||
import { preferences, setAgentModelPreference, getAgentModelPreference } from "./preferences"
|
||||
import { setSessionCompactionState } from "./session-compaction"
|
||||
import {
|
||||
@@ -366,10 +367,16 @@ interface SessionForkResponse {
|
||||
|
||||
async function fetchSessions(instanceId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
if (!instance) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const isNative = instance.binaryPath === "__nomadarch_native__"
|
||||
|
||||
if (!isNative && !instance.client) {
|
||||
throw new Error("Instance client not ready")
|
||||
}
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
next.fetchingSessions.set(instanceId, true)
|
||||
@@ -377,13 +384,38 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
})
|
||||
|
||||
try {
|
||||
log.info("session.list", { instanceId })
|
||||
const response = await instance.client.session.list()
|
||||
log.info("session.list", { instanceId, isNative })
|
||||
|
||||
let responseData: any[] = []
|
||||
|
||||
if (isNative) {
|
||||
const nativeSessions = await nativeSessionApi.listSessions(instanceId)
|
||||
responseData = nativeSessions.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
parentID: s.parentId,
|
||||
version: "0",
|
||||
time: {
|
||||
created: s.createdAt,
|
||||
updated: s.updatedAt
|
||||
},
|
||||
model: s.model ? {
|
||||
providerID: s.model.providerId,
|
||||
modelID: s.model.modelId
|
||||
} : undefined,
|
||||
agent: s.agent
|
||||
}))
|
||||
} else {
|
||||
const response = await instance.client!.session.list()
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
responseData = response.data
|
||||
}
|
||||
}
|
||||
|
||||
const sessionMap = new Map<string, Session>()
|
||||
|
||||
if (!response.data || !Array.isArray(response.data)) {
|
||||
return
|
||||
if (responseData.length === 0 && !isNative) {
|
||||
// In SDK mode we still check response.data for empty
|
||||
}
|
||||
|
||||
const existingSessions = sessions().get(instanceId)
|
||||
@@ -394,13 +426,13 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
const sessionTasks = instanceData.sessionTasks || {}
|
||||
const sessionSkills = instanceData.sessionSkills || {}
|
||||
|
||||
for (const apiSession of response.data) {
|
||||
for (const apiSession of responseData) {
|
||||
const existingSession = existingSessions?.get(apiSession.id)
|
||||
|
||||
const existingModel = existingSession?.model ?? { providerId: "", modelId: "" }
|
||||
const hasUserSelectedModel = existingModel.providerId && existingModel.modelId
|
||||
const apiModel = (apiSession as any).model?.providerID && (apiSession as any).model?.modelID
|
||||
? { providerId: (apiSession as any).model.providerID, modelId: (apiSession as any).model.modelID }
|
||||
const apiModel = apiSession.model?.providerID && apiSession.model?.modelID
|
||||
? { providerId: apiSession.model.providerID, modelId: apiSession.model.modelID }
|
||||
: { providerId: "", modelId: "" }
|
||||
|
||||
sessionMap.set(apiSession.id, {
|
||||
@@ -408,7 +440,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
instanceId,
|
||||
title: apiSession.title || "Untitled",
|
||||
parentId: apiSession.parentID || null,
|
||||
agent: existingSession?.agent ?? (apiSession as any).agent ?? "",
|
||||
agent: existingSession?.agent ?? apiSession.agent ?? "",
|
||||
model: hasUserSelectedModel ? existingModel : apiModel,
|
||||
version: apiSession.version,
|
||||
time: {
|
||||
@@ -475,10 +507,15 @@ async function createSession(
|
||||
options?: { skipAutoCleanup?: boolean },
|
||||
): Promise<Session> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
if (!instance) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const isNative = instance.binaryPath === "__nomadarch_native__"
|
||||
if (!isNative && !instance.client) {
|
||||
throw new Error("Instance client not ready")
|
||||
}
|
||||
|
||||
const instanceAgents = agents().get(instanceId) || []
|
||||
const nonSubagents = instanceAgents.filter((a) => a.mode !== "subagent")
|
||||
const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "")
|
||||
@@ -498,31 +535,57 @@ async function createSession(
|
||||
})
|
||||
|
||||
try {
|
||||
log.info(`[HTTP] POST /session.create for instance ${instanceId}`)
|
||||
const response = await instance.client.session.create()
|
||||
log.info(`[HTTP] POST session create for instance ${instanceId}, isNative: ${isNative}`)
|
||||
|
||||
if (!response.data) {
|
||||
let sessionData: any = null
|
||||
|
||||
if (isNative) {
|
||||
const native = await nativeSessionApi.createSession(instanceId, {
|
||||
agent: selectedAgent,
|
||||
model: sessionModel
|
||||
})
|
||||
sessionData = {
|
||||
id: native.id,
|
||||
title: native.title || "New Session",
|
||||
parentID: native.parentId,
|
||||
version: "0",
|
||||
time: {
|
||||
created: native.createdAt,
|
||||
updated: native.updatedAt
|
||||
},
|
||||
agent: native.agent,
|
||||
model: native.model ? {
|
||||
providerID: native.model.providerId,
|
||||
modelID: native.model.modelId
|
||||
} : undefined
|
||||
}
|
||||
} else {
|
||||
const response = await instance.client!.session.create()
|
||||
sessionData = response.data
|
||||
}
|
||||
|
||||
if (!sessionData) {
|
||||
throw new Error("Failed to create session: No data returned")
|
||||
}
|
||||
|
||||
const session: Session = {
|
||||
id: response.data.id,
|
||||
id: sessionData.id,
|
||||
instanceId,
|
||||
title: response.data.title || "New Session",
|
||||
title: sessionData.title || "New Session",
|
||||
parentId: null,
|
||||
agent: selectedAgent,
|
||||
model: sessionModel,
|
||||
skills: [],
|
||||
version: response.data.version,
|
||||
version: sessionData.version,
|
||||
time: {
|
||||
...response.data.time,
|
||||
...sessionData.time,
|
||||
},
|
||||
revert: response.data.revert
|
||||
revert: sessionData.revert
|
||||
? {
|
||||
messageID: response.data.revert.messageID,
|
||||
partID: response.data.revert.partID,
|
||||
snapshot: response.data.revert.snapshot,
|
||||
diff: response.data.revert.diff,
|
||||
messageID: sessionData.revert.messageID,
|
||||
partID: sessionData.revert.partID,
|
||||
snapshot: sessionData.revert.snapshot,
|
||||
diff: sessionData.revert.diff,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
@@ -683,9 +746,10 @@ async function forkSession(
|
||||
|
||||
async function deleteSession(instanceId: string, sessionId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
if (!instance) return
|
||||
|
||||
const isNative = instance.binaryPath === "__nomadarch_native__"
|
||||
if (!isNative && !instance.client) return
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
@@ -696,8 +760,13 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
|
||||
})
|
||||
|
||||
try {
|
||||
log.info(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId })
|
||||
await instance.client.session.delete({ path: { id: sessionId } })
|
||||
log.info("session.delete", { instanceId, sessionId, isNative })
|
||||
|
||||
if (isNative) {
|
||||
await nativeSessionApi.deleteSession(instanceId, sessionId)
|
||||
} else {
|
||||
await instance.client!.session.delete({ path: { id: sessionId } })
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
@@ -754,25 +823,42 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
|
||||
|
||||
async function fetchAgents(instanceId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
if (!instance) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const isNative = instance.binaryPath === "__nomadarch_native__"
|
||||
if (!isNative && !instance.client) {
|
||||
throw new Error("Instance client not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureInstanceConfigLoaded(instanceId)
|
||||
log.info(`[HTTP] GET /app.agents for instance ${instanceId}`)
|
||||
const response = await instance.client.app.agents()
|
||||
const agentList = (response.data ?? []).map((agent) => ({
|
||||
log.info("agents.list", { instanceId, isNative })
|
||||
|
||||
let agentList: any[] = []
|
||||
|
||||
if (isNative) {
|
||||
// In native mode, we don't have agents from the SDK yet
|
||||
// We can return a default agent or common agents
|
||||
agentList = [{
|
||||
name: "Assistant",
|
||||
description: "Native assistant agent",
|
||||
mode: "native"
|
||||
}]
|
||||
} else {
|
||||
const response = await instance.client!.app.agents()
|
||||
agentList = (response.data || []).map((agent: any) => ({
|
||||
name: agent.name,
|
||||
description: agent.description || "",
|
||||
mode: agent.mode,
|
||||
model: agent.model?.modelID
|
||||
mode: agent.mode as "standard" | "subagent",
|
||||
model: agent.model
|
||||
? {
|
||||
providerId: agent.model.providerID || "",
|
||||
modelId: agent.model.modelID,
|
||||
}
|
||||
: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
const customAgents = getInstanceConfig(instanceId)?.customAgents ?? []
|
||||
const customList = customAgents.map((agent) => ({
|
||||
@@ -793,16 +879,29 @@ async function fetchAgents(instanceId: string): Promise<void> {
|
||||
|
||||
async function fetchProviders(instanceId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
if (!instance) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
log.info(`[HTTP] GET /config.providers for instance ${instanceId}`)
|
||||
const response = await instance.client.config.providers()
|
||||
if (!response.data) return
|
||||
const isNative = instance.binaryPath === "__nomadarch_native__"
|
||||
if (!isNative && !instance.client) {
|
||||
throw new Error("Instance client not ready")
|
||||
}
|
||||
|
||||
const providerList = response.data.providers.map((provider) => ({
|
||||
try {
|
||||
log.info("config.providers", { instanceId, isNative })
|
||||
|
||||
let providerList: any[] = []
|
||||
let defaultProviders: any = {}
|
||||
|
||||
if (isNative) {
|
||||
// For native mode, we mainly rely on extra providers
|
||||
// but we could add "zen" (OpenCode Zen) if it's available via server API
|
||||
providerList = []
|
||||
} else {
|
||||
const response = await instance.client!.config.providers()
|
||||
if (response.data) {
|
||||
providerList = response.data.providers.map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
defaultModelId: response.data?.default?.[provider.id],
|
||||
@@ -814,6 +913,9 @@ async function fetchProviders(instanceId: string): Promise<void> {
|
||||
cost: model.cost,
|
||||
})),
|
||||
}))
|
||||
defaultProviders = response.data.default || {}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out Z.AI providers from SDK to use our custom routing with full message history
|
||||
const filteredBaseProviders = providerList.filter((provider) =>
|
||||
@@ -859,10 +961,15 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
}
|
||||
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
if (!instance) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const isNative = instance.binaryPath === "__nomadarch_native__"
|
||||
if (!isNative && !instance.client) {
|
||||
throw new Error("Instance client not ready")
|
||||
}
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
@@ -878,15 +985,37 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
})
|
||||
|
||||
try {
|
||||
log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId })
|
||||
const response = await instance.client.session["messages"]({ path: { id: sessionId } })
|
||||
log.info("session.getMessages", { instanceId, sessionId, isNative })
|
||||
|
||||
let apiMessages: any[] = []
|
||||
let apiMessagesInfo: any = {}
|
||||
|
||||
if (isNative) {
|
||||
const nativeMessages = await nativeSessionApi.getMessages(instanceId, sessionId)
|
||||
apiMessages = nativeMessages.map(m => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content || "",
|
||||
createdAt: m.createdAt,
|
||||
status: m.status,
|
||||
info: {
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
time: { created: m.createdAt },
|
||||
// Add other native message properties to info if needed for later processing
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
const response = await instance.client!.session.messages({ path: { id: sessionId } })
|
||||
if (!response.data || !Array.isArray(response.data)) {
|
||||
return
|
||||
}
|
||||
apiMessages = response.data || []
|
||||
apiMessagesInfo = (response as any).info || {} // Assuming 'info' might be on the response object itself for some cases
|
||||
}
|
||||
|
||||
const messagesInfo = new Map<string, any>()
|
||||
const messages: Message[] = response.data.map((apiMessage: any) => {
|
||||
const messages: Message[] = apiMessages.map((apiMessage: any) => {
|
||||
const info = apiMessage.info || apiMessage
|
||||
const role = info.role || "assistant"
|
||||
const messageId = info.id || String(Date.now())
|
||||
@@ -912,8 +1041,8 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
let providerID = ""
|
||||
let modelID = ""
|
||||
|
||||
for (let i = response.data.length - 1; i >= 0; i--) {
|
||||
const apiMessage = response.data[i]
|
||||
for (let i = apiMessages.length - 1; i >= 0; i--) {
|
||||
const apiMessage = apiMessages[i]
|
||||
const info = apiMessage.info || apiMessage
|
||||
|
||||
if (info.role === "assistant") {
|
||||
@@ -924,6 +1053,7 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!agentName && !providerID && !modelID) {
|
||||
const defaultModel = await getDefaultModel(instanceId, session.agent)
|
||||
agentName = session.agent
|
||||
|
||||
Reference in New Issue
Block a user