diff --git a/README.md b/README.md index f12d841..313db44 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ A real-time global situational awareness platform that plots security events, ge ## Features - ### Core Features +### Core Features - - **Real-Time Event Mapping** - Plot breaking news events (conflicts, protests, natural disasters) on a world map with color-coded threat levels - - **Interactive Mapbox Map** - Dark-themed map with clustering, heatmap visualization, and smooth navigation - - **Event Feed** - Real-time filterable feed of global events with category and threat level filters - - **Entity Search** - Research organizations, people, countries, and groups using Valyu's intelligence APIs - - **Alert System** - Configure keyword and region-based alerts with real-time notifications +- **Real-Time Event Mapping** - Plot breaking news events (conflicts, protests, natural disasters) on a world map with color-coded threat levels +- **Interactive Mapbox Map** - Dark-themed map with clustering, heatmap visualization, and smooth navigation +- **Event Feed** - Real-time filterable feed of global events with category and threat level filters +- **Entity Search** - Research organizations, people, countries, and groups using Valyu's intelligence APIs +- **Alert System** - Configure keyword and region-based alerts with real-time notifications ### Country Intelligence @@ -192,7 +192,7 @@ This app uses [Valyu](https://valyu.ai) for intelligence data: - **Answer API** - Synthesizing conflict intelligence and military base data - **Deep Research** - Comprehensive entity analysis -All Valyu queries exclude Wikipedia to ensure higher-quality source citations. +Wikipedia is excluded from search results. ## Authentication @@ -203,7 +203,7 @@ Global Threat Map supports two app modes controlled by the `NEXT_PUBLIC_APP_MODE | Mode | Description | |------|-------------| | `self-hosted` | Default mode. No authentication required. All features are freely accessible. | -| `valyu` | OAuth mode. Users sign in with Valyu to access premium features. | +| `valyu` | OAuth mode. Users sign in with Valyu to access additional features. | ### Self-Hosted Mode (Default) @@ -245,7 +245,7 @@ When running in valyu mode, certain features require authentication: | Entity search | ❌ Blocked | ✅ Unlimited | | Military bases | ✅ Free | ✅ Free | -After users exhaust their free usage, a sign-in modal prompts them to authenticate with Valyu. New Valyu accounts receive **$10 in free credits**. +After users exhaust their free usage, a sign-in modal prompts them to authenticate. ### OAuth Flow diff --git a/app/api/cascade/route.ts b/app/api/cascade/route.ts new file mode 100644 index 0000000..a808a9c --- /dev/null +++ b/app/api/cascade/route.ts @@ -0,0 +1,175 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Valyu } from "valyu-js"; + +const valyuClient = new Valyu(process.env.VALYU_API_KEY || ""); + +// JSON Schema for structured cascade output +const cascadeSchema = { + type: "object", + properties: { + effects: { + type: "array", + description: "List of countries that may be affected by the cascade effects of this event", + items: { + type: "object", + properties: { + country: { + type: "string", + description: "Full name of the affected country", + }, + countryCode: { + type: "string", + description: "ISO 3166-1 alpha-2 country code (e.g., US, GB, DE)", + }, + latitude: { + type: "number", + description: "Latitude coordinate of the country's geographic center", + }, + longitude: { + type: "number", + description: "Longitude coordinate of the country's geographic center", + }, + probability: { + type: "number", + description: "Probability of being affected (0-100)", + }, + timeframeHours: { + type: "number", + description: "Expected timeframe for impact in hours", + }, + impactType: { + type: "string", + enum: ["economic", "military", "political", "humanitarian", "social"], + description: "Primary type of impact expected", + }, + description: { + type: "string", + description: "Brief explanation of why and how this country would be affected", + }, + factors: { + type: "array", + items: { type: "string" }, + description: "Key factors contributing to the cascade effect (e.g., 'Neighboring country', 'Major trade partner', 'Military alliance')", + }, + }, + required: ["country", "countryCode", "latitude", "longitude", "probability", "timeframeHours", "impactType", "description", "factors"], + }, + }, + summary: { + type: "string", + description: "A 2-3 sentence summary of the overall cascade analysis", + }, + }, + required: ["effects", "summary"], +}; + +export async function POST(request: NextRequest) { + try { + const { event } = await request.json(); + + if (!event) { + return NextResponse.json({ error: "Event data required" }, { status: 400 }); + } + + const eventCountry = event.location?.country || "Unknown"; + const eventCategory = event.category || "conflict"; + + // Use Valyu to analyze potential cascade effects with structured output + const analysisQuery = `Analyze the potential geopolitical and economic ripple effects of this event: + +Event Title: "${event.title}" +Location: ${eventCountry} +Category: ${eventCategory} +Summary: ${event.summary || "No summary available"} + +Identify 8-12 countries most likely to be affected by cascade effects from this event. For each country, analyze: + +1. The probability of being affected (0-100%) based on: + - Geographic proximity (neighboring countries) + - Economic ties (trade partners, supply chains) + - Political/military alliances + - Historical relationships and tensions + - Regional stability implications + +2. The expected timeframe for when effects would manifest (in hours) + +3. The primary type of impact (economic, military, political, humanitarian, or social) + +4. A clear explanation of why this country would be affected + +5. The key factors driving the cascade effect + +Provide accurate geographic coordinates (latitude/longitude) for each country's center point. +Sort the results by probability of impact (highest first).`; + + const response = await valyuClient.answer(analysisQuery, { + structuredOutput: cascadeSchema, + searchType: "news", + excludedSources: ["wikipedia.org"], + }); + + console.log("Valyu response:", JSON.stringify(response, null, 2)); + + // Extract the structured response - contents may be string or object + let analysisData: { + effects: Array<{ + country: string; + countryCode: string; + latitude: number; + longitude: number; + probability: number; + timeframeHours: number; + impactType: "economic" | "military" | "political" | "humanitarian" | "social"; + description: string; + factors: string[]; + }>; + summary: string; + }; + + if (typeof response.contents === "string") { + // Try to parse if it's a JSON string + try { + analysisData = JSON.parse(response.contents); + } catch { + throw new Error("Failed to parse structured response: " + response.contents?.substring(0, 200)); + } + } else if (response.contents && typeof response.contents === "object") { + analysisData = response.contents as typeof analysisData; + } else { + throw new Error("No contents in response: " + JSON.stringify(response).substring(0, 500)); + } + + // Transform effects to include id and delay for animation + const effects = analysisData.effects.map((effect, index) => ({ + id: `cascade-${Date.now()}-${index}`, + targetCountry: effect.country, + targetCountryCode: effect.countryCode, + latitude: effect.latitude, + longitude: effect.longitude, + probability: effect.probability, + timeframeHours: effect.timeframeHours, + impactType: effect.impactType, + description: effect.description, + factors: effect.factors, + delay: index * 150, // Stagger animation + })); + + const highRiskCount = effects.filter((e) => e.probability >= 60).length; + + return NextResponse.json({ + sourceEvent: event, + effects, + summary: analysisData.summary, + totalAffectedCountries: effects.length, + highRiskCount, + generatedAt: new Date().toISOString(), + }); + } catch (error) { + console.error("Cascade analysis error:", error); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json( + { error: "Failed to analyze cascade effects", details: errorMessage }, + { status: 500 } + ); + } +} diff --git a/app/api/events/route.ts b/app/api/events/route.ts index 07b6068..e2b8dc3 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -1,10 +1,12 @@ import { NextResponse } from "next/server"; import { searchEvents } from "@/lib/valyu"; +import { isSelfHostedMode } from "@/lib/app-mode"; +import { classifyEvent, isAIClassificationEnabled } from "@/lib/ai-classifier"; +import { generateEventId } from "@/lib/utils"; +import { extractKeywords, extractEntities } from "@/lib/event-classifier"; +import type { ThreatEvent } from "@/types"; export const dynamic = "force-dynamic"; -import { geocodeLocationsFromText } from "@/lib/geocoding"; -import { createThreatEvent } from "@/lib/event-classifier"; -import type { ThreatEvent } from "@/types"; const THREAT_QUERIES = [ "breaking news conflict military", @@ -16,6 +18,68 @@ const THREAT_QUERIES = [ "diplomatic summit sanctions", ]; +// Clean boilerplate from content +function cleanContent(text: string): string { + return text + .replace(/skip to (?:main |primary )?content/gi, "") + .replace(/keyboard shortcuts?/gi, "") + .replace(/\n{3,}/g, "\n\n") + .replace(/\s{2,}/g, " ") + .trim(); +} + +async function processSearchResults( + results: Array<{ title: string; url: string; content: string; publishedDate?: string; source?: string }> +): Promise { + const eventsWithLocations = await Promise.all( + results.map(async (result) => { + const cleanedTitle = cleanContent(result.title); + const cleanedContent = cleanContent(result.content); + const fullText = `${cleanedTitle} ${cleanedContent}`; + + // Use AI classification (falls back to keywords if OpenAI not available) + const classification = await classifyEvent(cleanedTitle, cleanedContent); + + // Skip events without valid locations + if (!classification.location) { + return null; + } + + const event: ThreatEvent = { + id: generateEventId(), + title: cleanedTitle, + summary: cleanedContent.slice(0, 500), + category: classification.category, + threatLevel: classification.threatLevel, + location: classification.location, + timestamp: result.publishedDate || new Date().toISOString(), + source: result.source || "web", + sourceUrl: result.url, + entities: extractEntities(fullText), + keywords: extractKeywords(fullText), + rawContent: cleanedContent, + }; + + return event; + }) + ); + + const validEvents = eventsWithLocations.filter( + (event): event is ThreatEvent => event !== null + ); + + const uniqueEvents = validEvents.filter( + (event, index, self) => + index === self.findIndex((e) => e.title === event.title) + ); + + return uniqueEvents.sort((a, b) => { + const dateA = new Date(a.timestamp).getTime(); + const dateB = new Date(b.timestamp).getTime(); + return dateB - dateA; + }); +} + export async function GET(request: Request) { const { searchParams } = new URL(request.url); const query = searchParams.get("q"); @@ -23,57 +87,12 @@ export async function GET(request: Request) { try { const searchQueries = query ? [query] : THREAT_QUERIES.slice(0, 3); - // Run all searches in parallel const searchResultsArrays = await Promise.all( searchQueries.map((q) => searchEvents(q, { maxResults: 10 })) ); - const allResults = searchResultsArrays.flat(); - - // Run all geocoding in parallel - const eventsWithLocations = await Promise.all( - allResults.map(async (result) => { - const locations = await geocodeLocationsFromText( - `${result.title} ${result.content}`, - result.title - ); - - const location = locations[0] || { - latitude: 0, - longitude: 0, - placeName: "Unknown", - }; - - if (location.latitude === 0 && location.longitude === 0) { - return null; - } - - return createThreatEvent( - result.title, - result.content, - location, - result.source || "web", - result.url, - result.publishedDate - ); - }) - ); - - // Filter out nulls and duplicates - const validEvents = eventsWithLocations.filter( - (event): event is ThreatEvent => event !== null - ); - const uniqueEvents = validEvents.filter( - (event, index, self) => - index === self.findIndex((e) => e.title === event.title) - ); - - // Sort by publication date (most recent first) - const sortedEvents = uniqueEvents.sort((a, b) => { - const dateA = new Date(a.timestamp).getTime(); - const dateB = new Date(b.timestamp).getTime(); - return dateB - dateA; - }); + const allResults = searchResultsArrays.flatMap((r) => r.results); + const sortedEvents = await processSearchResults(allResults); return NextResponse.json({ events: sortedEvents, @@ -92,66 +111,31 @@ export async function GET(request: Request) { export async function POST(request: Request) { try { const body = await request.json(); - const { queries } = body; + const { queries, accessToken } = body; - if (!queries || !Array.isArray(queries)) { - return NextResponse.json( - { error: "Invalid queries parameter" }, - { status: 400 } - ); - } + const selfHosted = isSelfHostedMode(); + const tokenToUse = selfHosted ? undefined : accessToken; - // Run all searches in parallel - const searchResultsArrays = await Promise.all( - queries.slice(0, 5).map((query) => searchEvents(query, { maxResults: 15 })) - ); - const allResults = searchResultsArrays.flat(); - - // Run all geocoding in parallel - const eventsWithLocations = await Promise.all( - allResults.map(async (result) => { - const locations = await geocodeLocationsFromText( - `${result.title} ${result.content}`, - result.title - ); - - const location = locations[0] || { - latitude: 0, - longitude: 0, - placeName: "Unknown", - }; - - if (location.latitude === 0 && location.longitude === 0) { - return null; - } - - return createThreatEvent( - result.title, - result.content, - location, - result.source || "web", - result.url, - result.publishedDate - ); - }) - ); + const searchQueries = queries && Array.isArray(queries) && queries.length > 0 + ? queries.slice(0, 5) + : THREAT_QUERIES.slice(0, 3); - // Filter out nulls and duplicates - const validEvents = eventsWithLocations.filter( - (event): event is ThreatEvent => event !== null + const searchResultsArrays = await Promise.all( + searchQueries.map((query: string) => + searchEvents(query, { maxResults: 15, accessToken: tokenToUse }) + ) ); - const uniqueEvents = validEvents.filter( - (event, index, self) => - index === self.findIndex((e) => e.title === event.title) - ); + const requiresReauth = searchResultsArrays.some((r) => r.requiresReauth); + if (requiresReauth) { + return NextResponse.json( + { error: "auth_error", message: "Session expired. Please sign in again.", requiresReauth: true }, + { status: 401 } + ); + } - // Sort by publication date (most recent first) - const sortedEvents = uniqueEvents.sort((a, b) => { - const dateA = new Date(a.timestamp).getTime(); - const dateB = new Date(b.timestamp).getTime(); - return dateB - dateA; - }); + const allResults = searchResultsArrays.flatMap((r) => r.results); + const sortedEvents = await processSearchResults(allResults); return NextResponse.json({ events: sortedEvents, diff --git a/app/api/valyu-proxy/route.ts b/app/api/valyu-proxy/route.ts new file mode 100644 index 0000000..44b87ef --- /dev/null +++ b/app/api/valyu-proxy/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; + +export async function POST(request: Request) { + try { + const authHeader = request.headers.get("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return NextResponse.json( + { error: "unauthorized", message: "Missing or invalid authorization header" }, + { status: 401 } + ); + } + + const accessToken = authHeader.substring(7); + const body = await request.json(); + const { path, method, body: requestBody } = body; + + if (!path) { + return NextResponse.json( + { error: "invalid_request", message: "Missing path parameter" }, + { status: 400 } + ); + } + + const appUrl = process.env.VALYU_APP_URL || "https://platform.valyu.ai"; + const proxyUrl = `${appUrl}/api/oauth/proxy`; + + const response = await fetch(proxyUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + path, + method: method || "POST", + body: requestBody, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("[Proxy] Error:", response.status, errorText); + + let errorData; + try { + errorData = JSON.parse(errorText); + } catch { + errorData = { message: errorText }; + } + + if (response.status === 401 || response.status === 403) { + return NextResponse.json( + { + error: "auth_error", + message: "Session expired. Please sign in again.", + requiresReauth: true, + details: errorData, + }, + { status: 401 } + ); + } + + return NextResponse.json( + { error: "proxy_error", message: errorData.message || "Request failed", details: errorData }, + { status: response.status } + ); + } + + const responseData = await response.json(); + return NextResponse.json(responseData); + } catch (error) { + console.error("[Proxy] Error:", error); + return NextResponse.json( + { error: "server_error", message: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/auth/valyu/callback/page.tsx b/app/auth/valyu/callback/page.tsx index 495ba29..6bd26a6 100644 --- a/app/auth/valyu/callback/page.tsx +++ b/app/auth/valyu/callback/page.tsx @@ -2,15 +2,15 @@ import { useEffect, useState, Suspense } from "react"; import { useRouter, useSearchParams } from "next/navigation"; +import { useAuthStore } from "@/stores/auth-store"; export const dynamic = "force-dynamic"; function OAuthCallbackContent() { const router = useRouter(); const searchParams = useSearchParams(); - const [status, setStatus] = useState<"processing" | "success" | "error">( - "processing" - ); + const signIn = useAuthStore((state) => state.signIn); + const [status, setStatus] = useState<"processing" | "success" | "error">("processing"); const [errorMessage, setErrorMessage] = useState(""); useEffect(() => { @@ -19,7 +19,6 @@ function OAuthCallbackContent() { const state = searchParams.get("state"); const error = searchParams.get("error"); - // Handle OAuth errors if (error) { setStatus("error"); setErrorMessage("Authorization failed. Please try again."); @@ -27,7 +26,6 @@ function OAuthCallbackContent() { return; } - // Validate required parameters if (!code || !state) { setStatus("error"); setErrorMessage("Invalid callback parameters."); @@ -35,7 +33,6 @@ function OAuthCallbackContent() { return; } - // Validate state (CSRF protection) const storedState = sessionStorage.getItem("oauth_state"); if (state !== storedState) { setStatus("error"); @@ -44,7 +41,6 @@ function OAuthCallbackContent() { return; } - // Get code verifier from storage const codeVerifier = sessionStorage.getItem("oauth_code_verifier"); if (!codeVerifier) { setStatus("error"); @@ -54,16 +50,10 @@ function OAuthCallbackContent() { } try { - // Exchange authorization code for access token const tokenResponse = await fetch("/api/oauth/token", { method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - code, - codeVerifier, - }), + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code, codeVerifier }), }); if (!tokenResponse.ok) { @@ -71,34 +61,38 @@ function OAuthCallbackContent() { throw new Error(errorData.error || "Token exchange failed"); } - const { access_token, user } = await tokenResponse.json(); + const { access_token, refresh_token, expires_in, user } = await tokenResponse.json(); - // Store user info in localStorage - localStorage.setItem("valyu_user", JSON.stringify(user)); - localStorage.setItem("valyu_access_token", access_token); - - // Clean up session storage sessionStorage.removeItem("oauth_code_verifier"); sessionStorage.removeItem("oauth_state"); - setStatus("success"); + signIn( + { + id: user.sub || user.id, + name: user.name || user.email, + email: user.email, + picture: user.picture, + email_verified: user.email_verified, + }, + { + accessToken: access_token, + refreshToken: refresh_token, + expiresIn: expires_in || 3600, + } + ); - // Redirect to home page - setTimeout(() => { - router.push("/?auth=success"); - }, 1000); + setStatus("success"); + setTimeout(() => router.push("/"), 1000); } catch (error) { console.error("OAuth callback error:", error); setStatus("error"); - setErrorMessage( - error instanceof Error ? error.message : "Authentication failed" - ); + setErrorMessage(error instanceof Error ? error.message : "Authentication failed"); setTimeout(() => router.push("/"), 3000); } }; handleCallback(); - }, [searchParams, router]); + }, [searchParams, router, signIn]); return (
diff --git a/app/layout.tsx b/app/layout.tsx index 30cd0e9..8877c04 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { GeistMono } from "geist/font/mono"; import "mapbox-gl/dist/mapbox-gl.css"; import "./globals.css"; +import { AuthInitializer } from "@/components/auth/auth-initializer"; export const metadata: Metadata = { title: "Global Threat Map | Intelligence Platform", @@ -16,7 +17,7 @@ export default function RootLayout({ return ( - {children} + {children} ); diff --git a/app/page.tsx b/app/page.tsx index eb19454..6f2832e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -7,14 +7,15 @@ import { Sidebar } from "@/components/sidebar"; import { ThreatMap } from "@/components/map/threat-map"; import { TimelineScrubber } from "@/components/map/timeline-scrubber"; import { WelcomeModal } from "@/components/welcome-modal"; -import { SignInPanel } from "@/components/auth"; +import { SignInPanel, SignInModal } from "@/components/auth"; import { PolymarketTicker, POLYMARKET_TICKER_HEIGHT } from "@/components/polymarket-ticker"; const WELCOME_DISMISSED_KEY = "globalthreatmap_welcome_dismissed"; export default function Home() { const [showWelcome, setShowWelcome] = useState(false); - const { isLoading, refresh } = useEvents({ + const [showSignInModal, setShowSignInModal] = useState(false); + const { isLoading, refresh, requiresSignIn } = useEvents({ autoRefresh: true, refreshInterval: 300000, // 5 minutes }); @@ -26,6 +27,12 @@ export default function Home() { } }, []); + useEffect(() => { + if (requiresSignIn) { + setShowSignInModal(true); + } + }, [requiresSignIn]); + return (
+
); diff --git a/components/auth/auth-initializer.tsx b/components/auth/auth-initializer.tsx new file mode 100644 index 0000000..2917b8f --- /dev/null +++ b/components/auth/auth-initializer.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useEffect } from "react"; +import { useAuthStore } from "@/stores/auth-store"; + +export function AuthInitializer({ children }: { children: React.ReactNode }) { + const initialize = useAuthStore((state) => state.initialize); + + useEffect(() => { + initialize(); + }, [initialize]); + + return <>{children}; +} diff --git a/components/auth/index.ts b/components/auth/index.ts index e23f56c..034b4ab 100644 --- a/components/auth/index.ts +++ b/components/auth/index.ts @@ -1,2 +1,3 @@ export { SignInPanel } from "./sign-in-panel"; export { SignInModal } from "./sign-in-modal"; +export { AuthInitializer } from "./auth-initializer"; diff --git a/components/auth/sign-in-modal.tsx b/components/auth/sign-in-modal.tsx index d327d37..0205e5b 100644 --- a/components/auth/sign-in-modal.tsx +++ b/components/auth/sign-in-modal.tsx @@ -75,14 +75,8 @@ export function SignInModal({ open, onOpenChange }: SignInModalProps) { Sign in -

- Valyu is the information backbone of Global Threat Map, giving our app - access to real-time data across web, academic, and proprietary - sources. -

-

- Free to use. + Sign in to access all features.

{/* Error Message */} diff --git a/components/auth/sign-in-panel.tsx b/components/auth/sign-in-panel.tsx index ead262c..285151d 100644 --- a/components/auth/sign-in-panel.tsx +++ b/components/auth/sign-in-panel.tsx @@ -1,74 +1,19 @@ "use client"; import { useState, useRef, useEffect } from "react"; -import { Lock, LogOut, Loader2 } from "lucide-react"; +import { Lock, LogOut, Loader2, Globe } from "lucide-react"; import { useAuthStore } from "@/stores/auth-store"; import { cn } from "@/lib/utils"; import { SignInModal } from "./sign-in-modal"; const APP_MODE = process.env.NEXT_PUBLIC_APP_MODE || "self-hosted"; -function ValyuLogo({ className }: { className?: string }) { - return ( -
- - - -
- ); -} - export function SignInPanel() { - const { user, isAuthenticated, isLoading, signOut, checkAuthFromStorage } = - useAuthStore(); + const { user, isAuthenticated, isLoading, signOut } = useAuthStore(); const [showDropdown, setShowDropdown] = useState(false); const [showSignInModal, setShowSignInModal] = useState(false); - const [authMessage, setAuthMessage] = useState<{ - type: "success" | "error"; - text: string; - } | null>(null); const dropdownRef = useRef(null); - // Check for OAuth callback parameters and localStorage on mount - useEffect(() => { - // Only check auth in valyu mode - if (APP_MODE === "valyu") { - checkAuthFromStorage(); - } - - // Check URL for auth success/error - const params = new URLSearchParams(window.location.search); - const authSuccess = params.get("auth"); - const authError = params.get("error"); - - if (authSuccess === "success") { - setAuthMessage({ type: "success", text: "Successfully signed in!" }); - // Clean up URL - window.history.replaceState({}, "", window.location.pathname); - // Clear message after 3 seconds - setTimeout(() => setAuthMessage(null), 3000); - } else if (authError) { - setAuthMessage({ type: "error", text: decodeURIComponent(authError) }); - // Clean up URL - window.history.replaceState({}, "", window.location.pathname); - // Clear message after 5 seconds - setTimeout(() => setAuthMessage(null), 5000); - } - }, [checkAuthFromStorage]); - useEffect(() => { function handleClickOutside(event: MouseEvent) { if ( @@ -91,27 +36,12 @@ export function SignInPanel() { } }; - // Don't render anything in self-hosted mode if (APP_MODE === "self-hosted") { return null; } return ( <> - {/* Auth Message Toast */} - {authMessage && ( -
- {authMessage.text} -
- )} -
- {/* Divider */}
- {/* Valyu Logo */}
- +
diff --git a/components/cascade/cascade-panel.tsx b/components/cascade/cascade-panel.tsx new file mode 100644 index 0000000..27adf06 --- /dev/null +++ b/components/cascade/cascade-panel.tsx @@ -0,0 +1,332 @@ +"use client"; + +import { useState } from "react"; +import { useCascadeStore, type CascadeEffect } from "@/stores/cascade-store"; +import { useMapStore } from "@/stores/map-store"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { + Zap, + X, + Eye, + EyeOff, + ChevronRight, + AlertTriangle, + TrendingUp, + Shield, + Users, + DollarSign, + Heart, + Clock, + Target, + Loader2, +} from "lucide-react"; + +const IMPACT_ICONS: Record = { + economic: DollarSign, + military: Shield, + political: TrendingUp, + humanitarian: Heart, + social: Users, +}; + +const IMPACT_COLORS: Record = { + economic: "#eab308", + military: "#ef4444", + political: "#8b5cf6", + humanitarian: "#ec4899", + social: "#06b6d4", +}; + +function getProbabilityColor(probability: number): string { + if (probability >= 70) return "#ef4444"; // red + if (probability >= 50) return "#f97316"; // orange + if (probability >= 30) return "#eab308"; // yellow + return "#22c55e"; // green +} + +function formatTimeframe(hours: number): string { + if (hours < 24) return `${hours}h`; + const days = Math.round(hours / 24); + return `${days}d`; +} + +export function CascadePanel() { + const { + isAnalyzing, + currentAnalysis, + showCascadeOverlay, + selectedEffect, + error, + clearAnalysis, + toggleOverlay, + selectEffect, + } = useCascadeStore(); + + const { flyTo } = useMapStore(); + const [expandedEffect, setExpandedEffect] = useState(null); + + const handleEffectClick = (effect: CascadeEffect) => { + selectEffect(effect); + flyTo(effect.longitude, effect.latitude, 5); + }; + + const toggleExpand = (effectId: string) => { + setExpandedEffect(expandedEffect === effectId ? null : effectId); + }; + + if (!currentAnalysis && !isAnalyzing) { + return ( +
+
+
+ +

Cascade Prediction

+
+

+ Analyze how events ripple across regions +

+
+ +
+
+ +
+

No Analysis Active

+

+ Click on any event marker on the map, then click the{" "} + "Analyze Cascade" button + to see how it might ripple across regions. +

+
+
+ ); + } + + if (isAnalyzing) { + return ( +
+
+
+ +

Cascade Prediction

+
+
+ +
+ +

Analyzing Cascade Effects

+

+ Simulating geopolitical and economic ripple effects... +

+
+
+ ); + } + + if (error) { + return ( +
+
+
+
+ +

Cascade Prediction

+
+ +
+
+ +
+ +

Analysis Failed

+

{error}

+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ +

Cascade Prediction

+
+
+ + +
+
+
+ + +
+ {/* Source Event */} + {currentAnalysis && ( +
+
+ +
+

Source Event

+

+ {currentAnalysis.sourceEvent.title} +

+

+ {currentAnalysis.sourceEvent.location?.country || "Unknown location"} +

+
+
+
+ )} + + {/* Summary Stats */} + {currentAnalysis && ( +
+
+

+ {currentAnalysis.totalAffectedCountries} +

+

Countries Affected

+
+
+

+ {currentAnalysis.highRiskCount} +

+

High Risk (60%+)

+
+
+ )} + + {/* Summary */} + {currentAnalysis && ( +
+

{currentAnalysis.summary}

+
+ )} + + {/* Cascade Effects List */} + {currentAnalysis && ( +
+

Cascade Effects

+ + {currentAnalysis.effects.map((effect) => { + const ImpactIcon = IMPACT_ICONS[effect.impactType] || AlertTriangle; + const isExpanded = expandedEffect === effect.id; + const isSelected = selectedEffect?.id === effect.id; + + return ( +
+
handleEffectClick(effect)} + > + {/* Probability indicator */} +
+ {effect.probability}% +
+ + {/* Country info */} +
+
+ + {effect.targetCountry} + + + + {effect.impactType} + +
+
+ + ~{formatTimeframe(effect.timeframeHours)} +
+
+ + {/* Expand button */} + +
+ + {/* Expanded details */} + {isExpanded && ( +
+

{effect.description}

+ + {effect.factors.length > 0 && ( +
+

Risk Factors:

+
+ {effect.factors.map((factor, i) => ( + + {factor} + + ))} +
+
+ )} +
+ )} +
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/components/cascade/index.ts b/components/cascade/index.ts new file mode 100644 index 0000000..6ae32df --- /dev/null +++ b/components/cascade/index.ts @@ -0,0 +1 @@ +export { CascadePanel } from "./cascade-panel"; diff --git a/components/map/country-conflicts-modal.tsx b/components/map/country-conflicts-modal.tsx index eca47eb..6342f99 100644 --- a/components/map/country-conflicts-modal.tsx +++ b/components/map/country-conflicts-modal.tsx @@ -13,13 +13,12 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Swords, ExternalLink, - Globe, History, AlertTriangle, - MessageSquare, Database, RotateCw, } from "lucide-react"; +import { Favicon } from "@/components/ui/favicon"; import { cn } from "@/lib/utils"; interface CountryConflictsModalProps { @@ -45,6 +44,10 @@ function AnswerSkeleton() { return (
+ + + Researching conflicts - typically under 15 seconds +
@@ -346,9 +349,9 @@ export function CountryConflictsModal({ href={source.url} target="_blank" rel="noopener noreferrer" - className="flex items-start gap-2 rounded-lg border border-border bg-card p-3 text-sm transition-colors hover:bg-muted/50" + className="flex items-start gap-3 rounded-lg border border-border bg-card p-3 text-sm transition-colors hover:bg-muted/50" > - +
{source.title} diff --git a/components/map/event-popup.tsx b/components/map/event-popup.tsx index befb0af..19aa63e 100644 --- a/components/map/event-popup.tsx +++ b/components/map/event-popup.tsx @@ -1,18 +1,44 @@ "use client"; +import { useState } from "react"; import type { ThreatEvent } from "@/types"; import { Badge } from "@/components/ui/badge"; -import { Markdown } from "@/components/ui/markdown"; +import { Button } from "@/components/ui/button"; import { formatRelativeTime } from "@/lib/utils"; -import { ExternalLink, MapPin } from "lucide-react"; +import { useCascadeStore } from "@/stores/cascade-store"; +import { ExternalLink, MapPin, ArrowDownRight, ChevronUp, Zap, Loader2 } from "lucide-react"; +import { Streamdown } from "streamdown"; interface EventPopupProps { event: ThreatEvent; } export function EventPopup({ event }: EventPopupProps) { + const [isExpanded, setIsExpanded] = useState(false); + const { isAnalyzing, startAnalysis, setAnalysis, setError } = useCascadeStore(); + + const handleAnalyzeCascade = async () => { + startAnalysis(event); + try { + const response = await fetch("/api/cascade", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ event }), + }); + + if (!response.ok) { + throw new Error("Failed to analyze cascade effects"); + } + + const analysis = await response.json(); + setAnalysis(analysis); + } catch (error) { + setError(error instanceof Error ? error.message : "Analysis failed"); + } + }; + return ( -
+
-
- -
+ {!isExpanded ? ( +
+ {event.summary} +
+ ) : ( +
+
+ {event.rawContent || event.summary} +
+
+ )}
@@ -47,16 +81,36 @@ export function EventPopup({ event }: EventPopupProps) { {formatRelativeTime(event.timestamp)} - {event.sourceUrl && ( - - Source - - )} +
+ {event.rawContent && ( + + )} + {event.sourceUrl && ( + + Source + + )} +
@@ -69,6 +123,29 @@ export function EventPopup({ event }: EventPopupProps) { ))}
+ + {/* Cascade Analysis Button */} +
+ +
); } diff --git a/components/map/threat-map.tsx b/components/map/threat-map.tsx index 3a3a9b1..5ac3a86 100644 --- a/components/map/threat-map.tsx +++ b/components/map/threat-map.tsx @@ -15,14 +15,14 @@ import Map, { import { useMapStore } from "@/stores/map-store"; import { useEventsStore } from "@/stores/events-store"; import { useAuthStore } from "@/stores/auth-store"; +import { useCascadeStore } from "@/stores/cascade-store"; import { threatLevelColors } from "@/types"; import { EventPopup } from "./event-popup"; import { CountryConflictsModal } from "./country-conflicts-modal"; import { SignInModal } from "@/components/auth/sign-in-modal"; +import { hasReachedLimit, incrementCountryClicks } from "@/lib/usage-limits"; const APP_MODE = process.env.NEXT_PUBLIC_APP_MODE || "self-hosted"; -const COUNTRY_CONFLICTS_LIMIT = 2; -const COUNTRY_USAGE_KEY = "globalthreatmap_country_conflicts_usage"; const MAPBOX_TOKEN = process.env.NEXT_PUBLIC_MAPBOX_TOKEN; @@ -281,7 +281,8 @@ export function ThreatMap() { setMilitaryBasesLoading, } = useMapStore(); const { filteredEvents, selectedEvent, selectEvent } = useEventsStore(); - const { isAuthenticated } = useAuthStore(); + const { isAuthenticated, initialized } = useAuthStore(); + const { currentAnalysis, showCascadeOverlay, selectedEffect, selectEffect } = useCascadeStore(); const [selectedEntityLocation, setSelectedEntityLocation] = useState(null); const [selectedMilitaryBase, setSelectedMilitaryBase] = useState(null); const [selectedCountry, setSelectedCountry] = useState(null); @@ -290,29 +291,14 @@ export function ThreatMap() { const [blinkOpacity, setBlinkOpacity] = useState(0.4); const [showSignInModal, setShowSignInModal] = useState(false); - // Check if auth is required (valyu mode) const requiresAuth = APP_MODE === "valyu"; - // Get current usage count from localStorage - const getUsageCount = useCallback(() => { - if (typeof window === "undefined") return 0; - const stored = localStorage.getItem(COUNTRY_USAGE_KEY); - return stored ? parseInt(stored, 10) : 0; - }, []); - - // Increment usage count - const incrementUsageCount = useCallback(() => { - if (typeof window === "undefined") return; - const current = getUsageCount(); - localStorage.setItem(COUNTRY_USAGE_KEY, String(current + 1)); - }, [getUsageCount]); - - // Check if user has reached the limit - const hasReachedLimit = useCallback(() => { + const checkLimit = useCallback(() => { if (!requiresAuth) return false; + if (!initialized) return false; if (isAuthenticated) return false; - return getUsageCount() >= COUNTRY_CONFLICTS_LIMIT; - }, [requiresAuth, isAuthenticated, getUsageCount]); + return hasReachedLimit(); + }, [requiresAuth, isAuthenticated, initialized]); // Fetch military bases on mount useEffect(() => { @@ -420,6 +406,85 @@ export function ThreatMap() { [militaryBases] ); + // Cascade effect visualization data + const cascadeData = useMemo(() => { + if (!currentAnalysis || !showCascadeOverlay) { + return { type: "FeatureCollection" as const, features: [] }; + } + + return { + type: "FeatureCollection" as const, + features: currentAnalysis.effects.map((effect) => ({ + type: "Feature" as const, + properties: { + id: effect.id, + country: effect.targetCountry, + probability: effect.probability, + impactType: effect.impactType, + timeframe: effect.timeframeHours, + }, + geometry: { + type: "Point" as const, + coordinates: [effect.longitude, effect.latitude], + }, + })), + }; + }, [currentAnalysis, showCascadeOverlay]); + + // Cascade source point (the event that triggered the cascade) + const cascadeSourceData = useMemo(() => { + if (!currentAnalysis || !showCascadeOverlay) { + return { type: "FeatureCollection" as const, features: [] }; + } + + return { + type: "FeatureCollection" as const, + features: [ + { + type: "Feature" as const, + properties: { + id: "cascade-source", + title: currentAnalysis.sourceEvent.title, + }, + geometry: { + type: "Point" as const, + coordinates: [ + currentAnalysis.sourceEvent.location.longitude, + currentAnalysis.sourceEvent.location.latitude, + ], + }, + }, + ], + }; + }, [currentAnalysis, showCascadeOverlay]); + + // Lines connecting source to cascade effects + const cascadeLinesData = useMemo(() => { + if (!currentAnalysis || !showCascadeOverlay) { + return { type: "FeatureCollection" as const, features: [] }; + } + + const sourceCoords = [ + currentAnalysis.sourceEvent.location.longitude, + currentAnalysis.sourceEvent.location.latitude, + ]; + + return { + type: "FeatureCollection" as const, + features: currentAnalysis.effects.map((effect) => ({ + type: "Feature" as const, + properties: { + id: `line-${effect.id}`, + probability: effect.probability, + }, + geometry: { + type: "LineString" as const, + coordinates: [sourceCoords, [effect.longitude, effect.latitude]], + }, + })), + }; + }, [currentAnalysis, showCascadeOverlay]); + const handleMapClick = useCallback( async (event: MapMouseEvent) => { // If clicking on a known feature (event, cluster, entity), handle that @@ -499,15 +564,13 @@ export function ThreatMap() { // Get ISO 3166-1 alpha-2 country code from short_code property const countryCode = countryFeature.properties?.short_code?.toUpperCase() || null; - // Check usage limit in valyu mode - if (hasReachedLimit()) { + if (checkLimit()) { setShowSignInModal(true); return; } - // Increment usage count for unauthenticated users in valyu mode - if (requiresAuth && !isAuthenticated) { - incrementUsageCount(); + if (requiresAuth && initialized && !isAuthenticated) { + incrementCountryClicks(); } setSelectedCountry(countryName); @@ -518,7 +581,7 @@ export function ThreatMap() { console.error("Error reverse geocoding:", error); } }, - [filteredEvents, selectEvent, viewport.zoom, hasReachedLimit, requiresAuth, isAuthenticated, incrementUsageCount] + [filteredEvents, selectEvent, viewport.zoom, checkLimit, requiresAuth, isAuthenticated, initialized] ); const handleMouseEnter = useCallback(() => { @@ -619,12 +682,8 @@ export function ThreatMap() { clusterRadius={50} > {showHeatmap && } - {showClusters && ( - <> - - - - )} + {showClusters && } + {showClusters && } @@ -643,6 +702,120 @@ export function ThreatMap() { )} + {/* Cascade Prediction Overlay */} + {currentAnalysis && showCascadeOverlay && ( + <> + {/* Connection lines from source to affected countries */} + + + + + {/* Source event marker (pulsing) */} + + + + + + {/* Affected countries markers */} + + + + + + + )} + {selectedEvent && ( - - {source.title} + + {source.title} ))}
diff --git a/components/sidebar.tsx b/components/sidebar.tsx index a68e523..5598aaf 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -5,17 +5,23 @@ import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { EventFeed } from "@/components/feed/event-feed"; import { EntitySearch } from "@/components/search/entity-search"; -import { Activity, Search, ChevronLeft, ChevronRight } from "lucide-react"; +import { CascadePanel } from "@/components/cascade"; +import { useCascadeStore } from "@/stores/cascade-store"; +import { Activity, Search, ChevronLeft, ChevronRight, Zap } from "lucide-react"; -type Tab = "feed" | "search"; +type Tab = "feed" | "search" | "cascade"; export function Sidebar() { const [activeTab, setActiveTab] = useState("feed"); const [isCollapsed, setIsCollapsed] = useState(false); + const { currentAnalysis, isAnalyzing } = useCascadeStore(); + + const hasCascade = currentAnalysis !== null || isAnalyzing; const tabs = [ { id: "feed" as Tab, label: "Feed", icon: Activity }, { id: "search" as Tab, label: "Search", icon: Search }, + { id: "cascade" as Tab, label: "Cascade", icon: Zap, active: hasCascade }, ]; return ( @@ -46,14 +52,18 @@ export function Sidebar() { key={tab.id} onClick={() => setActiveTab(tab.id)} className={cn( - "flex flex-1 items-center justify-center gap-2 py-3 text-sm font-medium transition-colors", + "relative flex flex-1 items-center justify-center gap-1 py-3 text-xs font-medium transition-colors", activeTab === tab.id ? "border-b-2 border-primary text-primary" - : "text-muted-foreground hover:text-foreground" + : "text-muted-foreground hover:text-foreground", + tab.active && activeTab !== tab.id && "text-primary/70" )} > - + {tab.label} + {tab.active && activeTab !== tab.id && ( + + )} ))}
@@ -61,6 +71,7 @@ export function Sidebar() {
{activeTab === "feed" && } {activeTab === "search" && } + {activeTab === "cascade" && }
)} diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 1a4745c..e17b553 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -60,12 +60,12 @@ interface DialogHeaderProps { export function DialogHeader({ children, onClose }: DialogHeaderProps) { return ( -
-
{children}
+
+
{children}
{onClose && ( diff --git a/components/ui/favicon.tsx b/components/ui/favicon.tsx new file mode 100644 index 0000000..30a4532 --- /dev/null +++ b/components/ui/favicon.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useState } from "react"; +import { Globe } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface FaviconProps { + url: string; + size?: number; + className?: string; +} + +export function Favicon({ url, size = 16, className }: FaviconProps) { + const [error, setError] = useState(false); + + let hostname: string; + try { + hostname = new URL(url).hostname; + } catch { + return ; + } + + if (error) { + return ; + } + + // Use Google's favicon service for reliable favicon fetching + const faviconUrl = `https://www.google.com/s2/favicons?domain=${hostname}&sz=${size * 2}`; + + return ( + setError(true)} + /> + ); +} diff --git a/hooks/use-events.ts b/hooks/use-events.ts index 823f15e..88d11f1 100644 --- a/hooks/use-events.ts +++ b/hooks/use-events.ts @@ -1,9 +1,13 @@ "use client"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useEventsStore } from "@/stores/events-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { hasReachedLimit, incrementEventLoads } from "@/lib/usage-limits"; import type { ThreatEvent } from "@/types"; +const APP_MODE = process.env.NEXT_PUBLIC_APP_MODE || "self-hosted"; + interface UseEventsOptions { autoRefresh?: boolean; refreshInterval?: number; @@ -28,36 +32,53 @@ export function useEvents(options: UseEventsOptions = {}) { setError, } = useEventsStore(); + const { getAccessToken, signOut, isAuthenticated, initialized } = useAuthStore(); + const [requiresSignIn, setRequiresSignIn] = useState(false); + const intervalRef = useRef(null); const initialFetchRef = useRef(false); + const requiresAuth = APP_MODE === "valyu"; + const fetchEvents = useCallback(async () => { + if (requiresAuth && initialized && !isAuthenticated && hasReachedLimit()) { + setRequiresSignIn(true); + setLoading(false); + return; + } + setLoading(true); setError(null); try { - let response: Response; - - if (queries && queries.length > 0) { - response = await fetch("/api/events", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ queries }), - }); - } else { - response = await fetch("/api/events"); - } + const accessToken = getAccessToken(); + + const response = await fetch("/api/events", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ queries: queries || [], accessToken }), + }); if (!response.ok) { throw new Error("Failed to fetch events"); } const data = await response.json(); + + if (data.requiresReauth) { + signOut(); + setError("Session expired. Please sign in again."); + return; + } + const newEvents: ThreatEvent[] = data.events; if (!initialFetchRef.current) { setEvents(newEvents); initialFetchRef.current = true; + if (requiresAuth && initialized && !isAuthenticated) { + incrementEventLoads(); + } } else { const existingIds = new Set(events.map((e) => e.id)); const trulyNewEvents = newEvents.filter((e) => !existingIds.has(e.id)); @@ -71,18 +92,25 @@ export function useEvents(options: UseEventsOptions = {}) { } finally { setLoading(false); } - }, [queries, events, setEvents, addEvents, setLoading, setError]); + }, [queries, events, setEvents, addEvents, setLoading, setError, getAccessToken, signOut, requiresAuth, isAuthenticated, initialized]); const refresh = useCallback(() => { + if (requiresAuth && initialized && !isAuthenticated && hasReachedLimit()) { + setRequiresSignIn(true); + return; + } + if (requiresAuth && initialized && !isAuthenticated) { + incrementEventLoads(); + } fetchEvents(); - }, [fetchEvents]); + }, [fetchEvents, requiresAuth, isAuthenticated, initialized]); useEffect(() => { if (!initialFetchRef.current) { fetchEvents(); } - if (autoRefresh) { + if (autoRefresh && !(requiresAuth && initialized && !isAuthenticated && hasReachedLimit())) { intervalRef.current = setInterval(fetchEvents, refreshInterval); } @@ -91,7 +119,13 @@ export function useEvents(options: UseEventsOptions = {}) { clearInterval(intervalRef.current); } }; - }, [autoRefresh, refreshInterval, fetchEvents]); + }, [autoRefresh, refreshInterval, fetchEvents, requiresAuth, isAuthenticated, initialized]); + + useEffect(() => { + if (isAuthenticated) { + setRequiresSignIn(false); + } + }, [isAuthenticated]); return { events, @@ -99,5 +133,6 @@ export function useEvents(options: UseEventsOptions = {}) { isLoading, error, refresh, + requiresSignIn, }; } diff --git a/lib/ai-classifier.ts b/lib/ai-classifier.ts new file mode 100644 index 0000000..5faa6ef --- /dev/null +++ b/lib/ai-classifier.ts @@ -0,0 +1,158 @@ +import OpenAI from "openai"; +import { zodResponseFormat } from "openai/helpers/zod"; +import { z } from "zod"; +import type { EventCategory, ThreatLevel, GeoLocation } from "@/types"; +import { geocodeLocation, extractLocationsFromText } from "./geocoding"; +import { + classifyCategory as keywordClassifyCategory, + classifyThreatLevel as keywordClassifyThreatLevel, +} from "./event-classifier"; + +const OPENAI_API_KEY = process.env.OPENAI_API_KEY; +const OPENAI_MODEL = process.env.OPENAI_MODEL || "gpt-4.1-nano"; + +const openai = OPENAI_API_KEY ? new OpenAI({ apiKey: OPENAI_API_KEY }) : null; + +// Zod schema for structured event classification +const EventClassificationSchema = z.object({ + category: z.enum([ + "conflict", + "protest", + "disaster", + "diplomatic", + "economic", + "terrorism", + "cyber", + "health", + "environmental", + "military", + ]).describe("The primary category of the event"), + threatLevel: z.enum(["critical", "high", "medium", "low", "info"]).describe( + "Severity level: critical (imminent danger, mass casualties), high (significant threat), medium (developing situation), low (minor/contained), info (routine update)" + ), + primaryLocation: z.string().describe( + "The main geographic location (city, region, or country) where the event is occurring. Use proper names." + ), + country: z.string().optional().describe( + "The country where the event is occurring, if identifiable" + ), +}); + +type EventClassification = z.infer; + +export interface ClassificationResult { + category: EventCategory; + threatLevel: ThreatLevel; + location: GeoLocation | null; +} + +/** + * Classify an event using OpenAI structured outputs + * Extracts category, threat level, and location in a single API call + */ +async function classifyWithAI( + title: string, + content: string +): Promise { + if (!openai) return null; + + try { + const completion = await openai.chat.completions.parse({ + model: OPENAI_MODEL, + messages: [ + { + role: "system", + content: `You are an intelligence analyst classifying global events. Analyze the headline and content to determine: +1. Category - the type of event (conflict, protest, disaster, etc.) +2. Threat Level - severity based on potential impact and urgency +3. Location - the primary geographic location where this is happening + +Be precise with locations - use actual place names (cities, countries, regions). +For threat level: +- critical: imminent danger, mass casualties, nuclear/WMD threats +- high: significant active threats, major incidents, escalating situations +- medium: developing situations, moderate concern, ongoing tensions +- low: minor incidents, contained events, localized issues +- info: routine updates, announcements, analysis pieces`, + }, + { + role: "user", + content: `Headline: ${title}\n\nContent: ${content.slice(0, 1000)}`, + }, + ], + response_format: zodResponseFormat(EventClassificationSchema, "event_classification"), + max_tokens: 200, + temperature: 0, + }); + + const message = completion.choices[0]?.message; + if (message?.parsed) { + return message.parsed; + } + + return null; + } catch (error) { + console.error("AI classification error:", error); + return null; + } +} + +/** + * Classify an event - uses AI if available, falls back to keyword matching + * Returns category, threat level, and geocoded location + */ +export async function classifyEvent( + title: string, + content: string +): Promise { + const fullText = `${title} ${content}`; + + // Try AI classification first + const aiResult = await classifyWithAI(title, content); + + if (aiResult) { + // AI classification succeeded - geocode the location + let location: GeoLocation | null = null; + + if (aiResult.primaryLocation) { + location = await geocodeLocation(aiResult.primaryLocation); + + // If AI's location couldn't be geocoded, try with country + if (!location && aiResult.country) { + location = await geocodeLocation(aiResult.country); + } + } + + return { + category: aiResult.category as EventCategory, + threatLevel: aiResult.threatLevel as ThreatLevel, + location, + }; + } + + // Fall back to keyword-based classification + const category = keywordClassifyCategory(fullText); + const threatLevel = keywordClassifyThreatLevel(fullText); + + // Fall back to regex-based location extraction + const locationCandidates = extractLocationsFromText(fullText); + let location: GeoLocation | null = null; + + for (const candidate of locationCandidates) { + location = await geocodeLocation(candidate); + if (location) break; + } + + return { + category, + threatLevel, + location, + }; +} + +/** + * Check if AI classification is available + */ +export function isAIClassificationEnabled(): boolean { + return !!openai; +} diff --git a/lib/app-mode.ts b/lib/app-mode.ts new file mode 100644 index 0000000..42aea87 --- /dev/null +++ b/lib/app-mode.ts @@ -0,0 +1,7 @@ +export function isSelfHostedMode(): boolean { + return process.env.NEXT_PUBLIC_APP_MODE !== "valyu"; +} + +export function isValyuMode(): boolean { + return process.env.NEXT_PUBLIC_APP_MODE === "valyu"; +} diff --git a/lib/usage-limits.ts b/lib/usage-limits.ts new file mode 100644 index 0000000..f942abb --- /dev/null +++ b/lib/usage-limits.ts @@ -0,0 +1,57 @@ +const STORAGE_KEY = "globalthreatmap_usage"; + +interface UsageData { + eventLoads: number; + countryClicks: number; +} + +const LIMITS = { + eventLoads: 5, + countryClicks: 2, +}; + +function getUsageData(): UsageData { + if (typeof window === "undefined") { + return { eventLoads: 0, countryClicks: 0 }; + } + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + return JSON.parse(stored); + } + } catch {} + return { eventLoads: 0, countryClicks: 0 }; +} + +function saveUsageData(data: UsageData): void { + if (typeof window === "undefined") return; + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); +} + +export function incrementEventLoads(): number { + const data = getUsageData(); + data.eventLoads += 1; + saveUsageData(data); + return data.eventLoads; +} + +export function incrementCountryClicks(): number { + const data = getUsageData(); + data.countryClicks += 1; + saveUsageData(data); + return data.countryClicks; +} + +export function hasReachedLimit(): boolean { + const data = getUsageData(); + return data.eventLoads >= LIMITS.eventLoads || data.countryClicks >= LIMITS.countryClicks; +} + +export function getUsageCounts(): UsageData { + return getUsageData(); +} + +export function clearUsage(): void { + if (typeof window === "undefined") return; + localStorage.removeItem(STORAGE_KEY); +} diff --git a/lib/valyu.ts b/lib/valyu.ts index 62226c0..65d0dce 100644 --- a/lib/valyu.ts +++ b/lib/valyu.ts @@ -2,6 +2,10 @@ import { Valyu } from "valyu-js"; let valyuInstance: Valyu | null = null; +const OAUTH_PROXY_URL = + process.env.VALYU_OAUTH_PROXY_URL || + `${process.env.VALYU_APP_URL || "https://platform.valyu.ai"}/api/oauth/proxy`; + function getValyuClient(): Valyu { if (!valyuInstance) { const apiKey = process.env.VALYU_API_KEY; @@ -13,10 +17,45 @@ function getValyuClient(): Valyu { return valyuInstance; } +interface ProxyResult { + success: boolean; + data?: any; + error?: string; + requiresReauth?: boolean; +} + +async function callViaProxy( + path: string, + body: any, + accessToken: string +): Promise { + try { + const response = await fetch(OAUTH_PROXY_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ path, method: "POST", body }), + }); + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + return { success: false, error: "Session expired", requiresReauth: true }; + } + return { success: false, error: `API call failed: ${response.status}` }; + } + + const data = await response.json(); + return { success: true, data }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : "Unknown error" }; + } +} + function parsePublishedDate(dateValue: unknown): string | undefined { if (!dateValue) return undefined; - // Handle string dates if (typeof dateValue === "string") { const parsed = new Date(dateValue); if (!isNaN(parsed.getTime())) { @@ -24,12 +63,10 @@ function parsePublishedDate(dateValue: unknown): string | undefined { } } - // Handle Date objects if (dateValue instanceof Date && !isNaN(dateValue.getTime())) { return dateValue.toISOString(); } - // Handle Unix timestamps (seconds or milliseconds) if (typeof dateValue === "number") { const timestamp = dateValue > 1e12 ? dateValue : dateValue * 1000; const parsed = new Date(timestamp); @@ -41,13 +78,60 @@ function parsePublishedDate(dateValue: unknown): string | undefined { return undefined; } +interface SearchOptions { + maxResults?: number; + freshness?: "day" | "week" | "month"; + accessToken?: string; +} + export async function searchEvents( query: string, - options?: { - maxResults?: number; - freshness?: "day" | "week" | "month"; + options?: SearchOptions +): Promise<{ + results: Array<{ + title: string; + url: string; + content: string; + publishedDate?: string; + source?: string; + }>; + requiresReauth?: boolean; +}> { + const searchBody = { + query, + searchType: "news", + maxNumResults: options?.maxResults || 20, + }; + + if (options?.accessToken) { + const proxyResult = await callViaProxy("/v1/search", searchBody, options.accessToken); + + if (!proxyResult.success) { + if (proxyResult.requiresReauth) { + return { results: [], requiresReauth: true }; + } + throw new Error(proxyResult.error || "Search failed"); + } + + const response = proxyResult.data; + if (!response.results) { + return { results: [] }; + } + + return { + results: response.results.map((result: any) => { + const dateValue = result.date || result.publication_date; + return { + title: result.title || "Untitled", + url: result.url || "", + content: typeof result.content === "string" ? result.content : "", + publishedDate: parsePublishedDate(dateValue), + source: result.source, + }; + }), + }; } -) { + try { const valyu = getValyuClient(); const response = await valyu.search(query, { @@ -56,23 +140,23 @@ export async function searchEvents( }); if (!response.results) { - return []; + return { results: [] }; } - return response.results.map((result) => { - // Valyu returns both 'date' and 'publication_date' fields - const dateValue = result.date || result.publication_date; - - return { - title: result.title || "Untitled", - url: result.url || "", - content: typeof result.content === "string" ? result.content : "", - publishedDate: parsePublishedDate(dateValue), - source: result.source, - }; - }); + return { + results: response.results.map((result) => { + const dateValue = result.date || result.publication_date; + return { + title: result.title || "Untitled", + url: result.url || "", + content: typeof result.content === "string" ? result.content : "", + publishedDate: parsePublishedDate(dateValue), + source: result.source, + }; + }), + }; } catch (error) { - console.error("Valyu search error:", error); + console.error("Search error:", error); throw error; } } @@ -115,12 +199,10 @@ function classifyEntityType(name: string, content: string): EntityType { const lowerName = name.toLowerCase().trim(); const lowerContent = content.toLowerCase(); - // Check if it's a country if (COUNTRIES.has(lowerName)) { return "country"; } - // Check content for country indicators const countryIndicators = [ "sovereign nation", "republic of", "kingdom of", "nation state", "government of", "country located", "bordered by", "capital city", @@ -128,7 +210,6 @@ function classifyEntityType(name: string, content: string): EntityType { ]; const countryScore = countryIndicators.filter(ind => lowerContent.includes(ind)).length; - // Check for group/tribe indicators const groupIndicators = [ "ethnic group", "tribe", "tribal", "indigenous", "clan", "community", "peoples", "militant group", "rebel group", "armed group", "terrorist organization", @@ -136,7 +217,6 @@ function classifyEntityType(name: string, content: string): EntityType { ]; const groupScore = groupIndicators.filter(ind => lowerContent.includes(ind)).length; - // Check for person indicators const personIndicators = [ "was born", "born in", "died in", "biography", "personal life", "early life", "career", "married", "children", "his ", "her ", @@ -145,7 +225,6 @@ function classifyEntityType(name: string, content: string): EntityType { ]; const personScore = personIndicators.filter(ind => lowerContent.includes(ind)).length; - // Check for organization indicators const orgIndicators = [ "company", "corporation", "founded in", "headquarters", "inc.", "ltd.", "organization", "institution", "agency", "association", "foundation", @@ -153,7 +232,6 @@ function classifyEntityType(name: string, content: string): EntityType { ]; const orgScore = orgIndicators.filter(ind => lowerContent.includes(ind)).length; - // Determine type based on highest score const scores = [ { type: "country" as EntityType, score: countryScore * 2 }, { type: "group" as EntityType, score: groupScore * 1.5 }, @@ -163,7 +241,6 @@ function classifyEntityType(name: string, content: string): EntityType { scores.sort((a, b) => b.score - a.score); - // Return the type with highest score, default to organization if no clear winner if (scores[0].score > 0) { return scores[0].type; } @@ -171,7 +248,51 @@ function classifyEntityType(name: string, content: string): EntityType { return "organization"; } -export async function getEntityResearch(entityName: string) { +interface EntityOptions { + accessToken?: string; +} + +export async function getEntityResearch(entityName: string, options?: EntityOptions) { + const searchBody = { + query: `${entityName} profile background information`, + searchType: "all", + maxNumResults: 10, + }; + + if (options?.accessToken) { + const proxyResult = await callViaProxy("/v1/search", searchBody, options.accessToken); + + if (!proxyResult.success) { + if (proxyResult.requiresReauth) { + return null; + } + throw new Error(proxyResult.error || "Entity research failed"); + } + + const response = proxyResult.data; + if (!response.results || response.results.length === 0) { + return null; + } + + const combinedContent = response.results + .map((r: any) => (typeof r.content === "string" ? r.content : "")) + .join("\n\n"); + + const entityType = classifyEntityType(entityName, combinedContent); + + return { + name: entityName, + description: combinedContent.slice(0, 1000), + type: entityType, + data: { + sources: response.results.map((r: any) => ({ + title: r.title, + url: r.url, + })), + }, + }; + } + try { const valyu = getValyuClient(); const response = await valyu.search( @@ -204,12 +325,35 @@ export async function getEntityResearch(entityName: string) { }, }; } catch (error) { - console.error("Valyu entity research error:", error); + console.error("Entity research error:", error); throw error; } } -export async function searchEntityLocations(entityName: string) { +export async function searchEntityLocations(entityName: string, options?: EntityOptions) { + const searchBody = { + query: `${entityName} headquarters offices locations branches worldwide operations`, + searchType: "all", + maxNumResults: 15, + }; + + if (options?.accessToken) { + const proxyResult = await callViaProxy("/v1/search", searchBody, options.accessToken); + + if (!proxyResult.success) { + return ""; + } + + const response = proxyResult.data; + if (!response.results || response.results.length === 0) { + return ""; + } + + return response.results + .map((r: any) => (typeof r.content === "string" ? r.content : "")) + .join("\n\n"); + } + try { const valyu = getValyuClient(); const response = await valyu.search( @@ -221,23 +365,54 @@ export async function searchEntityLocations(entityName: string) { ); if (!response.results || response.results.length === 0) { - return []; + return ""; } - const combinedContent = response.results + return response.results .map((r) => (typeof r.content === "string" ? r.content : "")) .join("\n\n"); - - return combinedContent; } catch (error) { - console.error("Valyu entity locations error:", error); + console.error("Entity locations error:", error); return ""; } } export async function deepResearch( - topic: string + topic: string, + options?: EntityOptions ): Promise<{ summary: string; sources: { title: string; url: string }[] }> { + const searchBody = { + query: `comprehensive analysis: ${topic}`, + searchType: "all", + maxNumResults: 30, + }; + + if (options?.accessToken) { + const proxyResult = await callViaProxy("/v1/search", searchBody, options.accessToken); + + if (!proxyResult.success) { + return { summary: "Research failed. Please try again.", sources: [] }; + } + + const response = proxyResult.data; + if (!response.results) { + return { summary: "No research results found.", sources: [] }; + } + + const summary = response.results + .slice(0, 10) + .map((r: any) => (typeof r.content === "string" ? r.content : "")) + .join("\n\n") + .slice(0, 3000); + + const sources = response.results.map((r: any) => ({ + title: r.title || "Untitled", + url: r.url || "", + })); + + return { summary, sources }; + } + try { const valyu = getValyuClient(); const response = await valyu.search(`comprehensive analysis: ${topic}`, { @@ -262,7 +437,7 @@ export async function deepResearch( return { summary, sources }; } catch (error) { - console.error("Valyu deep research error:", error); + console.error("Deep research error:", error); throw error; } } @@ -298,11 +473,9 @@ export async function getMilitaryBases(): Promise { const answerData = response as AnswerResponse; const content = answerData.contents || ""; - // Parse the response to extract base information const bases: MilitaryBase[] = []; const lines = content.split("\n"); - // Known coordinates for countries with military bases (fallback) const countryCoordinates: Record = { "Germany": { lat: 50.1109, lng: 8.6821 }, "Japan": { lat: 35.6762, lng: 139.6503 }, @@ -343,16 +516,15 @@ export async function getMilitaryBases(): Promise { "Guam": { lat: 13.4443, lng: 144.7937 }, "Diego Garcia": { lat: -7.3195, lng: 72.4229 }, "Honduras": { lat: 14.0723, lng: -87.1921 }, - "Cuba": { lat: 19.9030, lng: -75.0997 }, // Guantanamo + "Cuba": { lat: 19.9030, lng: -75.0997 }, "Kosovo": { lat: 42.6026, lng: 20.9030 }, "Iraq": { lat: 33.3152, lng: 44.3661 }, "Syria": { lat: 35.2433, lng: 38.9637 }, "Afghanistan": { lat: 34.5553, lng: 69.2075 }, "Iceland": { lat: 64.1466, lng: -21.9426 }, - "Greenland": { lat: 76.5310, lng: -68.7030 }, // Thule + "Greenland": { lat: 76.5310, lng: -68.7030 }, }; - // Parse response and create base entries for (const line of lines) { const parts = line.split("|").map((p) => p.trim()); if (parts.length >= 2) { @@ -374,7 +546,6 @@ export async function getMilitaryBases(): Promise { } } - // If parsing didn't work well, return default known bases if (bases.length < 5) { return [ { country: "Germany", baseName: "Ramstein Air Base", latitude: 49.4369, longitude: 7.6003, type: "usa" }, @@ -416,17 +587,16 @@ export async function getMilitaryBases(): Promise { } export async function getCountryConflicts( - country: string + country: string, + options?: EntityOptions ): Promise<{ past: ConflictResult; current: ConflictResult }> { const valyu = getValyuClient(); - // Type for Valyu answer response type AnswerResponse = { contents?: string; search_results?: Array<{ title?: string; url?: string }>; }; - // Fetch both past and current conflicts in parallel const [pastResponse, currentResponse] = await Promise.all([ valyu.answer( `List all major historical wars, conflicts, and military engagements that ${country} has been involved in throughout history (excluding any ongoing conflicts). Include the dates, opposing parties, and brief outcomes for each conflict. Focus on conflicts that have ended.`, @@ -480,7 +650,6 @@ export async function* streamCountryConflicts( const pastQuery = `List all major historical wars, conflicts, and military engagements that ${country} has been involved in throughout history (excluding any ongoing conflicts). Include the dates, opposing parties, and brief outcomes for each conflict. Focus on conflicts that have ended.`; try { - // Stream current conflicts first const currentStream = await valyu.answer(currentQuery, { excludedSources: ["wikipedia.org"], streaming: true, @@ -506,7 +675,6 @@ export async function* streamCountryConflicts( } } - // Then stream past conflicts const pastStream = await valyu.answer(pastQuery, { excludedSources: ["wikipedia.org"], streaming: true, diff --git a/package-lock.json b/package-lock.json index 727aa35..3cd8157 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,15 +19,18 @@ "clsx": "^2.1.1", "eslint": "^9.39.2", "eslint-config-next": "^16.1.4", + "geist": "^1.5.1", "lucide-react": "^0.562.0", "mapbox-gl": "^3.18.0", "next": "^16.1.4", + "openai": "^6.16.0", "postcss": "^8.5.6", "react": "^19.2.3", "react-dom": "^19.2.3", "react-map-gl": "^8.1.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "streamdown": "^2.1.0", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "tailwindcss-animate": "^1.0.7", @@ -77,7 +80,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1780,7 +1782,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1860,7 +1861,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.1.tgz", "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -2370,7 +2370,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2800,7 +2799,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3464,6 +3462,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -3668,7 +3678,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3846,7 +3855,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4335,6 +4343,15 @@ "node": ">= 0.6.0" } }, + "node_modules/geist": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/geist/-/geist-1.5.1.tgz", + "integrity": "sha512-mAHZxIsL2o3ZITFaBVFBnwyDOw+zNLYum6A6nIjpzCGIO8QtC3V76XF2RnZTyLx1wlDTmMDy8jg3Ib52MIjGvQ==", + "license": "SIL OPEN FONT LICENSE", + "peerDependencies": { + "next": ">=13.2.0" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -4640,6 +4657,79 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -4667,6 +4757,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -4680,6 +4789,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -4735,6 +4861,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -5831,7 +5967,6 @@ "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.18.0.tgz", "integrity": "sha512-xMr9HUoof0qPqWrVNK+kLiPtU1ogyfR6cihGSTB4eQAzdfFuMTC7CPrbpbZK0oUKQxXI/1qvB35FXZIK7kfR9w==", "license": "SEE LICENSE IN LICENSE.txt", - "peer": true, "workspaces": [ "src/style-spec", "test/build/vite", @@ -5877,6 +6012,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", + "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/martinez-polygon-clipping": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/martinez-polygon-clipping/-/martinez-polygon-clipping-0.8.1.tgz", @@ -7165,6 +7312,27 @@ "wrappy": "1" } }, + "node_modules/openai": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.16.0.tgz", + "integrity": "sha512-fZ1uBqjFUjXzbGc35fFtYKEOxd20kd9fDpFeqWtsOZWiubY8CZ1NAlXHW3iathaFvqmNtCWMIsosCuyeI7Joxg==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7302,6 +7470,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7384,7 +7564,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7510,7 +7689,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7520,7 +7698,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7769,6 +7946,44 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rehype-harden": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/rehype-harden/-/rehype-harden-1.1.7.tgz", + "integrity": "sha512-j5DY0YSK2YavvNGV+qBHma15J9m0WZmRe8posT5AtKDS6TNWtMVTo6RiqF8SidfcASYz8f3k2J/1RWmq5zTXUw==", + "license": "MIT", + "dependencies": { + "unist-util-visit": "^5.0.0" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -7835,6 +8050,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remend": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remend/-/remend-1.1.0.tgz", + "integrity": "sha512-JENGyuIhTwzUfCarW43X4r9cehoqTo9QyYxfNDZSud2AmqeuWjZ5pfybasTa4q0dxTJAj5m8NB+wR+YueAFpxQ==", + "license": "Apache-2.0" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8372,6 +8593,31 @@ "node": ">= 0.4" } }, + "node_modules/streamdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/streamdown/-/streamdown-2.1.0.tgz", + "integrity": "sha512-u9gWd0AmjKg1d+74P44XaPlGrMeC21oDOSIhjGNEYMAttDMzCzlJO6lpTyJ9JkSinQQF65YcK4eOd3q9iTvULw==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1", + "hast-util-to-jsx-runtime": "^2.3.6", + "html-url-attributes": "^3.0.1", + "marked": "^17.0.1", + "rehype-harden": "^1.1.7", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remend": "1.1.0", + "tailwind-merge": "^3.4.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -8630,8 +8876,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -8693,7 +8938,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8893,7 +9137,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9184,6 +9427,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vfile-message": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", @@ -9198,6 +9455,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9354,7 +9621,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 336c4d2..7c24591 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "react-map-gl": "^8.1.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "streamdown": "^2.1.0", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "tailwindcss-animate": "^1.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40566e2..e264b0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: remark-gfm: specifier: ^4.0.1 version: 4.0.1 + streamdown: + specifier: ^2.1.0 + version: 2.1.0(react@19.2.3) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -1191,6 +1194,10 @@ packages: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -1568,12 +1575,30 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -1590,6 +1615,9 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -1968,6 +1996,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + martinez-polygon-clipping@0.8.1: resolution: {integrity: sha512-9PLLMzMPI6ihHox4Ns6LpVBLpRc7sbhULybZ/wyaY8sY3ECNe2+hxm1hA2/9bEEpRrdpjoeduBuZLg2aq1cSIQ==} @@ -2294,6 +2327,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2426,6 +2462,15 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + rehype-harden@1.1.7: + resolution: {integrity: sha512-j5DY0YSK2YavvNGV+qBHma15J9m0WZmRe8posT5AtKDS6TNWtMVTo6RiqF8SidfcASYz8f3k2J/1RWmq5zTXUw==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -2438,6 +2483,9 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + remend@1.1.0: + resolution: {integrity: sha512-JENGyuIhTwzUfCarW43X4r9cehoqTo9QyYxfNDZSud2AmqeuWjZ5pfybasTa4q0dxTJAj5m8NB+wR+YueAFpxQ==} + resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -2593,6 +2641,11 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + streamdown@2.1.0: + resolution: {integrity: sha512-u9gWd0AmjKg1d+74P44XaPlGrMeC21oDOSIhjGNEYMAttDMzCzlJO6lpTyJ9JkSinQQF65YcK4eOd3q9iTvULw==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -2812,12 +2865,18 @@ packages: valyu-js@2.5.2: resolution: {integrity: sha512-xviHyrn6SaIZO5RTt3UZ8syPuEPb3rbBSp2lvha5Dl2RGdUA5YunHncRpfWEffOVRZx/bLPkpPqkWURA7us3aA==} + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -3978,6 +4037,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@6.0.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -4502,6 +4563,43 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.0 + unist-util-position: 5.0.0 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -4522,10 +4620,28 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + hermes-estree@0.25.1: {} hermes-parser@0.25.1: @@ -4540,6 +4656,8 @@ snapshots: html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} + http-cache-semantics@4.2.0: {} http2-wrapper@1.0.3: @@ -4896,6 +5014,8 @@ snapshots: markdown-table@3.0.4: {} + marked@17.0.1: {} + martinez-polygon-clipping@0.8.1: dependencies: robust-predicates: 2.0.4 @@ -5456,6 +5576,10 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -5593,6 +5717,21 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + rehype-harden@1.1.7: + dependencies: + unist-util-visit: 5.1.0 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.2 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -5627,6 +5766,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + remend@1.1.0: {} + resolve-alpn@1.2.1: {} resolve-from@4.0.0: {} @@ -5829,6 +5970,26 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + streamdown@2.1.0(react@19.2.3): + dependencies: + clsx: 2.1.1 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + marked: 17.0.1 + react: 19.2.3 + rehype-harden: 1.1.7 + rehype-raw: 7.0.0 + rehype-sanitize: 6.0.0 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remend: 1.1.0 + tailwind-merge: 3.4.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + transitivePeerDependencies: + - supports-color + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.8 @@ -6120,6 +6281,11 @@ snapshots: transitivePeerDependencies: - debug + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -6130,6 +6296,8 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + web-namespaces@2.0.1: {} + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 diff --git a/stores/auth-store.ts b/stores/auth-store.ts index b36352d..7affff7 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1,5 +1,7 @@ import { create } from "zustand"; -import { persist } from "zustand/middleware"; + +const TOKEN_STORAGE_KEY = "valyu_oauth_tokens"; +const USER_STORAGE_KEY = "valyu_user"; export interface User { id: string; @@ -9,66 +11,156 @@ export interface User { email_verified?: boolean; } +interface TokenData { + accessToken: string; + refreshToken?: string; + expiresAt: number; +} + interface AuthState { user: User | null; + accessToken: string | null; + refreshToken: string | null; + tokenExpiresAt: number | null; isAuthenticated: boolean; isLoading: boolean; - signIn: (user: User) => void; + initialized: boolean; + signIn: (user: User, tokens: { accessToken: string; refreshToken?: string; expiresIn?: number }) => void; signOut: () => void; - checkAuthFromStorage: () => void; + initialize: () => void; + getAccessToken: () => string | null; +} + +function saveTokens(tokens: TokenData): void { + if (typeof window === "undefined") return; + localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(tokens)); +} + +function loadTokens(): TokenData | null { + if (typeof window === "undefined") return null; + try { + const stored = localStorage.getItem(TOKEN_STORAGE_KEY); + if (!stored) return null; + return JSON.parse(stored); + } catch { + return null; + } +} + +function clearTokens(): void { + if (typeof window === "undefined") return; + localStorage.removeItem(TOKEN_STORAGE_KEY); +} + +function saveUser(user: User): void { + if (typeof window === "undefined") return; + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user)); +} + +function loadUser(): User | null { + if (typeof window === "undefined") return null; + try { + const stored = localStorage.getItem(USER_STORAGE_KEY); + if (!stored) return null; + return JSON.parse(stored); + } catch { + return null; + } +} + +function clearUser(): void { + if (typeof window === "undefined") return; + localStorage.removeItem(USER_STORAGE_KEY); } -export const useAuthStore = create()( - persist( - (set) => ({ +export const useAuthStore = create()((set, get) => ({ + user: null, + accessToken: null, + refreshToken: null, + tokenExpiresAt: null, + isAuthenticated: false, + isLoading: true, + initialized: false, + + initialize: () => { + if (get().initialized) return; + set({ initialized: true }); + + const user = loadUser(); + const tokens = loadTokens(); + + if (user && tokens) { + const now = Date.now(); + if (tokens.expiresAt > now) { + set({ + user, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken || null, + tokenExpiresAt: tokens.expiresAt, + isAuthenticated: true, + isLoading: false, + }); + return; + } else { + clearTokens(); + clearUser(); + } + } + + set({ user: null, + accessToken: null, + refreshToken: null, + tokenExpiresAt: null, isAuthenticated: false, isLoading: false, + }); + }, + + signIn: (user, tokens) => { + const expiresAt = tokens.expiresIn + ? Date.now() + tokens.expiresIn * 1000 + : Date.now() + 3600 * 1000; + + saveUser(user); + saveTokens({ + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt, + }); + + set({ + user, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken || null, + tokenExpiresAt: expiresAt, + isAuthenticated: true, + isLoading: false, + }); + }, - signIn: (user) => { - // Store in localStorage for persistence - if (typeof window !== "undefined") { - localStorage.setItem("valyu_user", JSON.stringify(user)); - } - set({ user, isAuthenticated: true, isLoading: false }); - }, - - signOut: () => { - set({ isLoading: true }); - - // Clear localStorage - if (typeof window !== "undefined") { - localStorage.removeItem("valyu_user"); - localStorage.removeItem("valyu_access_token"); - } - - // Clear state - set({ user: null, isAuthenticated: false, isLoading: false }); - }, - - checkAuthFromStorage: () => { - if (typeof window === "undefined") return; - - const storedUser = localStorage.getItem("valyu_user"); - - if (storedUser) { - try { - const user = JSON.parse(storedUser); - if (user && user.id && user.email) { - set({ user, isAuthenticated: true }); - return; - } - } catch (error) { - console.error("Error parsing stored user:", error); - } - } - - // No valid stored user - set({ user: null, isAuthenticated: false }); - }, - }), - { - name: "globalthreatmap-auth", + signOut: () => { + clearUser(); + clearTokens(); + + set({ + user: null, + accessToken: null, + refreshToken: null, + tokenExpiresAt: null, + isAuthenticated: false, + isLoading: false, + }); + }, + + getAccessToken: () => { + const state = get(); + if (!state.accessToken) return null; + + if (state.tokenExpiresAt && Date.now() >= state.tokenExpiresAt - 30000) { + return null; } - ) -); + + return state.accessToken; + }, +})); diff --git a/stores/cascade-store.ts b/stores/cascade-store.ts new file mode 100644 index 0000000..941a410 --- /dev/null +++ b/stores/cascade-store.ts @@ -0,0 +1,88 @@ +import { create } from "zustand"; +import type { ThreatEvent } from "@/types"; + +export interface CascadeEffect { + id: string; + targetCountry: string; + targetCountryCode: string; + latitude: number; + longitude: number; + probability: number; // 0-100 + timeframeHours: number; + impactType: "economic" | "military" | "political" | "humanitarian" | "social"; + description: string; + factors: string[]; + delay: number; // Animation delay in ms +} + +export interface CascadeAnalysis { + sourceEvent: ThreatEvent; + effects: CascadeEffect[]; + summary: string; + totalAffectedCountries: number; + highRiskCount: number; + generatedAt: string; +} + +interface CascadeState { + isAnalyzing: boolean; + currentAnalysis: CascadeAnalysis | null; + showCascadeOverlay: boolean; + animationPhase: number; // 0 = not started, 1-5 = ripple phases + selectedEffect: CascadeEffect | null; + error: string | null; + + startAnalysis: (event: ThreatEvent) => void; + setAnalysis: (analysis: CascadeAnalysis) => void; + clearAnalysis: () => void; + toggleOverlay: () => void; + setAnimationPhase: (phase: number) => void; + selectEffect: (effect: CascadeEffect | null) => void; + setError: (error: string | null) => void; + setIsAnalyzing: (isAnalyzing: boolean) => void; +} + +export const useCascadeStore = create((set) => ({ + isAnalyzing: false, + currentAnalysis: null, + showCascadeOverlay: true, + animationPhase: 0, + selectedEffect: null, + error: null, + + startAnalysis: (event) => + set({ + isAnalyzing: true, + currentAnalysis: null, + animationPhase: 0, + selectedEffect: null, + error: null, + }), + + setAnalysis: (analysis) => + set({ + currentAnalysis: analysis, + isAnalyzing: false, + animationPhase: 1, + }), + + clearAnalysis: () => + set({ + currentAnalysis: null, + isAnalyzing: false, + animationPhase: 0, + selectedEffect: null, + error: null, + }), + + toggleOverlay: () => + set((state) => ({ showCascadeOverlay: !state.showCascadeOverlay })), + + setAnimationPhase: (phase) => set({ animationPhase: phase }), + + selectEffect: (effect) => set({ selectedEffect: effect }), + + setError: (error) => set({ error, isAnalyzing: false }), + + setIsAnalyzing: (isAnalyzing) => set({ isAnalyzing }), +}));