Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,8 @@ DATABASE_URL=postgresql://postgres:[YOUR-POSTGRES-PASSWORD]@[YOUR-SUPABASE-DB-HO
# Clerk Custom Sign-In/Up URLs
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up

# SkyFi MCP Configuration
# Users connect their own accounts via OAuth 2.1 in Settings.
# The app redirect URI is built using NEXT_PUBLIC_APP_URL.
NEXT_PUBLIC_APP_URL=http://localhost:3000
7 changes: 7 additions & 0 deletions app/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Chat, AIMessage } from '@/lib/types'
import { UserMessage } from '@/components/user-message'
import { BotMessage } from '@/components/message'
import { SearchSection } from '@/components/search-section'
import { SkyfiSection } from '@/components/skyfi-section'
import SearchRelated from '@/components/search-related'
import { GeoJsonLayer } from '@/components/map/geojson-layer'
import { ResolutionCarousel } from '@/components/resolution-carousel'
Expand Down Expand Up @@ -853,6 +854,12 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => {
)
searchResults.done(JSON.stringify(toolOutput))
switch (name) {
case 'skyfiQueryTool':
return {
id,
component: <SkyfiSection result={searchResults.value} />,
isCollapsed: isCollapsed.value
}
case 'search':
return {
id,
Expand Down
101 changes: 101 additions & 0 deletions app/api/skyfi/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { NextResponse, NextRequest } from 'next/server';
import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user';
import { SkyfiOAuthProvider } from '@/lib/skyfi/provider';
import { getRedirectUri } from '@/lib/actions/skyfi';
import { db } from '@/lib/db';
import { skyfiOAuthTokens } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';

export async function GET(request: NextRequest) {
try {
const userId = await getCurrentUserIdOnServer();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized: No user session found.' }, { status: 401 });
}

const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
const state = searchParams.get('state');

if (!code || !state) {
return NextResponse.json({ error: 'Missing code or state parameters.' }, { status: 400 });
}

const redirectUri = await getRedirectUri();
const provider = new SkyfiOAuthProvider(userId, redirectUri);

const storedState = await provider.state();
if (state !== storedState) {
return NextResponse.json({ error: 'CSRF State mismatch.' }, { status: 400 });
}

const clientInfo = await provider.clientInformation();
const verifier = await provider.codeVerifier();

if (!clientInfo?.client_id || !verifier) {
return NextResponse.json({ error: 'Missing client registration or PKCE verifier.' }, { status: 400 });
}

console.log('[SkyFiCallback] Exchanging code for tokens with a 10s timeout...');

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);

try {
const response = await fetch('https://mcp.skyfi.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientInfo.client_id,
code,
redirect_uri: redirectUri,
code_verifier: verifier,
}),
signal: controller.signal,
});

clearTimeout(timeoutId);

if (!response.ok) {
const errorText = await response.text();
console.error('[SkyFiCallback] Token exchange failed:', errorText);
return NextResponse.json({ error: `Token exchange failed: ${errorText}` }, { status: 500 });
}

const data = await response.json();
console.log('[SkyFiCallback] Code successfully exchanged for tokens.');

// Save tokens in database
await provider.saveTokens({
access_token: data.access_token,
refresh_token: data.refresh_token || undefined,
expires_at: data.expires_in ? Math.floor(Date.now() / 1000) + data.expires_in : undefined,
});

// Clear temporary state and code verifier
await db.update(skyfiOAuthTokens)
.set({
state: null,
codeVerifier: null,
updatedAt: new Date(),
})
.where(eq(skyfiOAuthTokens.userId, userId));

} catch (fetchError: any) {
clearTimeout(timeoutId);
console.error('[SkyFiCallback] Fetch failed or timed out:', fetchError.message);
return NextResponse.json({ error: `Token exchange failed or timed out: ${fetchError.message}` }, { status: 500 });
}

// Redirect user back to the settings page
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
return NextResponse.redirect(`${baseUrl}/settings`);
} catch (error: any) {
console.error('[SkyFiCallback] Unexpected error:', error.message);
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
return NextResponse.redirect(`${baseUrl}/settings?error=skyfi_callback_failed`);
}
}
147 changes: 146 additions & 1 deletion components/settings/components/tool-selection-form.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useState, useEffect } from "react";
import type { UseFormReturn } from "react-hook-form";
import {
FormField,
Expand All @@ -19,7 +20,11 @@ import {
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Earth, Orbit } from "lucide-react";
import { Button } from "@/components/ui/button";
import { startSkyfiConnection, getSkyfiConnectionStatus, disconnectSkyfi } from "@/lib/actions/skyfi";
import { Earth, Orbit, ShieldCheck, Loader2 } from "lucide-react";
import { useToast } from "@/components/ui/hooks/use-toast";
import { cn } from "@/lib/utils";

interface ToolSelectionFormProps {
form: UseFormReturn<any>;
Expand All @@ -45,6 +50,32 @@ const tools = [
];

export function ToolSelectionForm({ form }: ToolSelectionFormProps) {
const selectedModel = form.watch("selectedModel");
const { toast } = useToast();
const [skyfiConnected, setSkyfiConnected] = useState(false);
const [skyfiBudget, setSkyfiBudget] = useState<string | null>(null);
const [loadingStatus, setLoadingStatus] = useState(true);
const [isConnecting, setIsConnecting] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);

useEffect(() => {
async function loadStatus() {
if (selectedModel === "SkyFi") {
setLoadingStatus(true);
try {
const status = await getSkyfiConnectionStatus();
setSkyfiConnected(status.connected);
setSkyfiBudget(status.budget || null);
} catch (err) {
console.error("Failed to load SkyFi connection status:", err);
} finally {
setLoadingStatus(false);
}
}
}
loadStatus();
}, [selectedModel]);

return (
<FormField
control={form.control}
Expand Down Expand Up @@ -123,6 +154,120 @@ export function ToolSelectionForm({ form }: ToolSelectionFormProps) {
characteristics.
</FormDescription>
<FormMessage />

{selectedModel === "SkyFi" && (
<Card className="mt-4 border-2 border-primary/20 bg-muted/20">
<CardContent className="p-4 space-y-4">
<div className="flex items-center gap-2 text-primary font-semibold">
<Orbit className="h-5 w-5 animate-pulse" />
<h4>SkyFi Account Connection</h4>
</div>
{loadingStatus ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm py-2">
<Loader2 className="h-4 w-4 animate-spin" />
Checking SkyFi connection status...
</div>
) : skyfiConnected ? (
<div className="space-y-4">
<div className="flex items-center gap-4 p-4 border rounded-xl bg-emerald-500/10 border-emerald-500/20 text-emerald-600 dark:text-emerald-400">
<ShieldCheck className="h-10 w-10 shrink-0" />
<div className="flex-1 space-y-1">
<p className="font-semibold text-foreground">SkyFi Account Linked</p>
{skyfiBudget ? (
<div className="text-xs text-muted-foreground font-mono bg-card p-2 rounded-lg max-h-[100px] overflow-y-auto">
{skyfiBudget}
</div>
) : (
<p className="text-xs text-muted-foreground">Successfully authenticated and ready to query satellite imagery.</p>
)}
</div>
<Button
type="button"
variant="destructive"
size="sm"
disabled={isDisconnecting}
onClick={async () => {
setIsDisconnecting(true);
try {
const res = await disconnectSkyfi();
if (res.success) {
setSkyfiConnected(false);
setSkyfiBudget(null);
toast({
title: "SkyFi Disconnected",
description: "Your SkyFi account has been disconnected.",
});
} else {
throw new Error(res.error);
}
} catch (err: any) {
toast({
title: "Disconnection Error",
description: err.message || "Failed to disconnect SkyFi.",
variant: "destructive",
});
} finally {
setIsDisconnecting(false);
}
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
{isDisconnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Disconnecting...
</>
) : (
"Disconnect"
)}
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Connect your SkyFi account to search existing satellite imagery, task new captures, and manage Areas of Interest (AOIs) directly from your chats.
</p>
<Button
type="button"
disabled={isConnecting}
className="bg-[#000000] hover:bg-[#333333] text-white flex items-center gap-2 px-6 py-5 text-base font-semibold"
onClick={async () => {
setIsConnecting(true);
try {
const res = await startSkyfiConnection();
if (res.error) {
throw new Error(res.error);
}
if (res.url) {
window.location.href = res.url;
}
} catch (err: any) {
toast({
title: "Connection Error",
description: err.message || "Failed to initiate SkyFi connection.",
variant: "destructive",
});
setIsConnecting(false);
}
}}
>
{isConnecting ? (
<>
<Loader2 className="h-5 w-5 animate-spin mr-2" />
<span>Initiating OAuth...</span>
</>
) : (
<>
<Orbit className="h-5 w-5 mr-2" />
<span>Connect SkyFi Account</span>
</>
)}
</Button>
</div>
)}
</CardContent>
</Card>
)}
</FormItem>
)}
/>
Expand Down
82 changes: 82 additions & 0 deletions components/skyfi-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
'use client'

import { Section } from './section'
import { ToolBadge } from './tool-badge'
import { SearchSkeleton } from './search-skeleton'
import { MemoizedReactMarkdown } from './ui/markdown'
import rehypeExternalLinks from 'rehype-external-links'
import remarkGfm from 'remark-gfm'
import { StreamableValue, useStreamableValue } from 'ai/rsc'
import { cn } from '@/lib/utils'

export type SkyfiSectionProps = {
result?: StreamableValue<string>
}

export function SkyfiSection({ result }: SkyfiSectionProps) {
const [data, error, pending] = useStreamableValue(result)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

let queryType = 'Query'
let resultText = ''
let hasError = false

if (data) {
try {
const parsed = JSON.parse(data)
queryType = parsed.queryType || 'Query'
resultText = parsed.result || ''
if (parsed.error) {
hasError = true
resultText = parsed.error
}
} catch {
resultText = data
}
}

// Handle stream error explicitly
if (error) {
return (
<div>
<Section size="sm" className="pt-2 pb-0">
<ToolBadge tool="skyfiQueryTool">{`SkyFi MCP Error`}</ToolBadge>
</Section>
<Section title="Error Details">
<div className="text-destructive font-mono text-xs bg-destructive/10 p-3 rounded-lg border border-destructive/20">
{(error as any).message || String(error)}
</div>
</Section>
</div>
)
}

return (
<div>
{!pending && data ? (
<>
<Section size="sm" className="pt-2 pb-0">
<ToolBadge tool="skyfiQueryTool">{`SkyFi MCP: ${queryType}`}</ToolBadge>
</Section>
<Section title="Results">
<div className={cn(
"overflow-x-auto",
hasError ? "text-destructive font-mono text-xs bg-destructive/10 p-3 rounded-lg border border-destructive/20" : ""
)}>
<MemoizedReactMarkdown
rehypePlugins={[[rehypeExternalLinks, { target: '_blank' }]]}
remarkPlugins={[remarkGfm]}
className="prose-sm prose-neutral prose-a:text-accent-foreground/50"
>
{resultText}
</MemoizedReactMarkdown>
</div>
</Section>
</>
) : (
<Section className="pt-2 pb-0">
<SearchSkeleton />
</Section>
)}
</div>
)
}
Loading