Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
87 changes: 87 additions & 0 deletions app/api/skyfi/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { NextResponse, NextRequest } from 'next/server';
import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user';
import { SkyfiOAuthProvider } from '@/lib/skyfi/provider';
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 baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
const redirectUri = `${baseUrl}/api/skyfi/callback`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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...');
// Exchange the authorization code for tokens
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,
}),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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));

// Redirect user back to the settings page
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`);
}
}
16 changes: 13 additions & 3 deletions components/settings/components/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { useToast } from "@/components/ui/hooks/use-toast"
import { getSystemPrompt, saveSystemPrompt } from "../../../lib/actions/chat"
import { getSelectedModel, saveSelectedModel } from "../../../lib/actions/users"
import { getSelectedModel, saveSelectedModel, getSkyfiConfig, saveSkyfiConfig } from "../../../lib/actions/users"
import { useCurrentUser } from "@/lib/auth/use-current-user"
import { SettingsSkeleton } from './settings-skeleton'
import { useUser } from '@clerk/nextjs'
Expand All @@ -40,6 +40,7 @@ const settingsFormSchema = z.object({
selectedModel: z.string().refine(value => value.trim() !== '', {
message: "Please select a tool.",
}),
skyfiApiKey: z.string().optional(),
users: z.array(
z.object({
id: z.string(),
Expand All @@ -59,6 +60,7 @@ const defaultValues: Partial<SettingsFormValues> = {
systemPrompt:
"You are a planetary copilot, an AI assistant designed to help users with information about planets, space exploration, and astronomy. Provide accurate, educational, and engaging responses about our solar system and beyond.",
selectedModel: "QCX-Terra",
skyfiApiKey: "",
users: [],
domain: "",
}
Expand Down Expand Up @@ -97,9 +99,10 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
async function fetchData() {
if (!userId || authLoading) return;

const [existingPrompt, selectedModel] = await Promise.all([
const [existingPrompt, selectedModel, skyfiConfig] = await Promise.all([
getSystemPrompt(userId),
getSelectedModel(),
getSkyfiConfig(),
]);

if (existingPrompt) {
Expand All @@ -108,6 +111,9 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
if (selectedModel) {
form.setValue("selectedModel", selectedModel, { shouldValidate: true, shouldDirty: false });
}
if (skyfiConfig?.apiKey) {
form.setValue("skyfiApiKey", skyfiConfig.apiKey, { shouldValidate: true, shouldDirty: false });
}
}
fetchData();
}, [form, userId, authLoading]);
Expand All @@ -130,9 +136,10 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {

try {
// Save the system prompt and selected model
const [promptSaveResult, modelSaveResult] = await Promise.all([
const [promptSaveResult, modelSaveResult, skyfiSaveResult] = await Promise.all([
saveSystemPrompt(userId, data.systemPrompt),
saveSelectedModel(data.selectedModel),
saveSkyfiConfig({ apiKey: data.skyfiApiKey, initialized: !!data.skyfiApiKey }),
]);

if (promptSaveResult?.error) {
Expand All @@ -141,6 +148,9 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
if (modelSaveResult?.error) {
throw new Error(modelSaveResult.error);
}
if (skyfiSaveResult?.error) {
throw new Error(skyfiSaveResult.error);
}

console.log("Submitted data:", data)

Expand Down
138 changes: 137 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,31 @@ 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);

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 +153,112 @@ 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"
onClick={async () => {
setLoadingStatus(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 {
setLoadingStatus(false);
}
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
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
Loading