From 9d500fe30a111f54e1f02eb43c824b8e019b4b31 Mon Sep 17 00:00:00 2001 From: yorkeccak Date: Fri, 23 Jan 2026 19:42:21 +0000 Subject: [PATCH 01/15] Add OAuth proxy support and usage limits for valyu mode - Add OAuth proxy route for authenticated API calls - Update valyu.ts to support OAuth tokens with proxy fallback - Add shared usage limits (5 event loads, 2 country clicks) - Show sign-in modal when limits reached in valyu mode - Self-hosted mode unchanged - uses API key with no limits - Add loading time estimate to country conflicts modal - Fix dialog header centering - Update auth panel with globe icon Co-Authored-By: Claude Opus 4.5 --- app/api/events/route.ts | 174 ++++++-------- app/api/valyu-proxy/route.ts | 78 ++++++ app/page.tsx | 12 +- components/auth/sign-in-panel.tsx | 28 +-- components/map/country-conflicts-modal.tsx | 4 + components/map/threat-map.tsx | 33 +-- components/ui/dialog.tsx | 6 +- hooks/use-events.ts | 67 ++++-- lib/app-mode.ts | 7 + lib/usage-limits.ts | 57 +++++ lib/valyu.ts | 266 +++++++++++++++++---- stores/auth-store.ts | 123 ++++++++-- 12 files changed, 615 insertions(+), 240 deletions(-) create mode 100644 app/api/valyu-proxy/route.ts create mode 100644 lib/app-mode.ts create mode 100644 lib/usage-limits.ts diff --git a/app/api/events/route.ts b/app/api/events/route.ts index 07b6068..f65722b 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -1,11 +1,12 @@ import { NextResponse } from "next/server"; import { searchEvents } from "@/lib/valyu"; - -export const dynamic = "force-dynamic"; +import { isSelfHostedMode } from "@/lib/app-mode"; import { geocodeLocationsFromText } from "@/lib/geocoding"; import { createThreatEvent } from "@/lib/event-classifier"; import type { ThreatEvent } from "@/types"; +export const dynamic = "force-dynamic"; + const THREAT_QUERIES = [ "breaking news conflict military", "geopolitical crisis tensions", @@ -16,6 +17,53 @@ const THREAT_QUERIES = [ "diplomatic summit sanctions", ]; +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 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 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 +71,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 +95,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/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/sign-in-panel.tsx b/components/auth/sign-in-panel.tsx index ead262c..a5c1aa2 100644 --- a/components/auth/sign-in-panel.tsx +++ b/components/auth/sign-in-panel.tsx @@ -1,35 +1,13 @@ "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 } = @@ -146,12 +124,10 @@ export function SignInPanel() { )} - {/* Divider */}
- {/* Valyu Logo */}
- +
diff --git a/components/map/country-conflicts-modal.tsx b/components/map/country-conflicts-modal.tsx index eca47eb..70cacf1 100644 --- a/components/map/country-conflicts-modal.tsx +++ b/components/map/country-conflicts-modal.tsx @@ -45,6 +45,10 @@ function AnswerSkeleton() { return (
+ + + Researching conflicts - typically under 15 seconds +
diff --git a/components/map/threat-map.tsx b/components/map/threat-map.tsx index 3a3a9b1..c8ef9b3 100644 --- a/components/map/threat-map.tsx +++ b/components/map/threat-map.tsx @@ -19,10 +19,9 @@ 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; @@ -290,29 +289,13 @@ 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 (isAuthenticated) return false; - return getUsageCount() >= COUNTRY_CONFLICTS_LIMIT; - }, [requiresAuth, isAuthenticated, getUsageCount]); + return hasReachedLimit(); + }, [requiresAuth, isAuthenticated]); // Fetch military bases on mount useEffect(() => { @@ -499,15 +482,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(); + incrementCountryClicks(); } setSelectedCountry(countryName); @@ -518,7 +499,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] ); const handleMouseEnter = useCallback(() => { 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/hooks/use-events.ts b/hooks/use-events.ts index 823f15e..d73f830 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 } = 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 && !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 && !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]); const refresh = useCallback(() => { + if (requiresAuth && !isAuthenticated && hasReachedLimit()) { + setRequiresSignIn(true); + return; + } + if (requiresAuth && !isAuthenticated) { + incrementEventLoads(); + } fetchEvents(); - }, [fetchEvents]); + }, [fetchEvents, requiresAuth, isAuthenticated]); useEffect(() => { if (!initialFetchRef.current) { fetchEvents(); } - if (autoRefresh) { + if (autoRefresh && !(requiresAuth && !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]); + + 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/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/stores/auth-store.ts b/stores/auth-store.ts index b36352d..d79ecc0 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { persist } from "zustand/middleware"; +import { persist, createJSONStorage } from "zustand/middleware"; export interface User { id: string; @@ -13,49 +13,103 @@ interface AuthState { user: User | null; isAuthenticated: boolean; isLoading: boolean; - signIn: (user: User) => void; + accessToken: string | null; + refreshToken: string | null; + tokenExpiresAt: number | null; + signIn: (user: User, tokens?: { accessToken: string; refreshToken?: string; expiresIn?: number }) => void; signOut: () => void; checkAuthFromStorage: () => void; + getAccessToken: () => string | null; + setTokens: (tokens: { accessToken: string; refreshToken?: string; expiresIn?: number }) => void; +} + +function loadInitialTokens() { + if (typeof window === "undefined") return {}; + try { + const accessToken = sessionStorage.getItem("access_token"); + const refreshToken = sessionStorage.getItem("refresh_token"); + const expiresAt = sessionStorage.getItem("token_expires_at"); + return { + accessToken, + refreshToken, + tokenExpiresAt: expiresAt ? parseInt(expiresAt, 10) : null, + }; + } catch { + return {}; + } } export const useAuthStore = create()( persist( - (set) => ({ + (set, get) => ({ user: null, isAuthenticated: false, isLoading: false, + accessToken: null, + refreshToken: null, + tokenExpiresAt: null, - signIn: (user) => { - // Store in localStorage for persistence + signIn: (user, tokens) => { if (typeof window !== "undefined") { - localStorage.setItem("valyu_user", JSON.stringify(user)); + localStorage.setItem("user", JSON.stringify(user)); + if (tokens?.accessToken) { + sessionStorage.setItem("access_token", tokens.accessToken); + if (tokens.refreshToken) { + sessionStorage.setItem("refresh_token", tokens.refreshToken); + } + if (tokens.expiresIn) { + const expiresAt = Date.now() + tokens.expiresIn * 1000; + sessionStorage.setItem("token_expires_at", expiresAt.toString()); + } + } } - set({ user, isAuthenticated: true, isLoading: false }); + set({ + user, + isAuthenticated: true, + isLoading: false, + accessToken: tokens?.accessToken || null, + refreshToken: tokens?.refreshToken || null, + tokenExpiresAt: tokens?.expiresIn ? Date.now() + tokens.expiresIn * 1000 : null, + }); }, signOut: () => { set({ isLoading: true }); - // Clear localStorage if (typeof window !== "undefined") { - localStorage.removeItem("valyu_user"); - localStorage.removeItem("valyu_access_token"); + localStorage.removeItem("user"); + sessionStorage.removeItem("access_token"); + sessionStorage.removeItem("refresh_token"); + sessionStorage.removeItem("token_expires_at"); } - // Clear state - set({ user: null, isAuthenticated: false, isLoading: false }); + set({ + user: null, + isAuthenticated: false, + isLoading: false, + accessToken: null, + refreshToken: null, + tokenExpiresAt: null, + }); }, checkAuthFromStorage: () => { if (typeof window === "undefined") return; - const storedUser = localStorage.getItem("valyu_user"); + const storedUser = localStorage.getItem("user"); + const initialTokens = loadInitialTokens(); if (storedUser) { try { const user = JSON.parse(storedUser); if (user && user.id && user.email) { - set({ user, isAuthenticated: true }); + set({ + user, + isAuthenticated: true, + accessToken: initialTokens.accessToken || null, + refreshToken: initialTokens.refreshToken || null, + tokenExpiresAt: initialTokens.tokenExpiresAt || null, + }); return; } } catch (error) { @@ -63,12 +117,51 @@ export const useAuthStore = create()( } } - // No valid stored user set({ user: null, isAuthenticated: false }); }, + + getAccessToken: () => { + const state = get(); + if (!state.accessToken) return null; + + if (state.tokenExpiresAt && Date.now() >= state.tokenExpiresAt - 30000) { + return null; + } + + return state.accessToken; + }, + + setTokens: (tokens) => { + const expiresAt = tokens.expiresIn ? Date.now() + tokens.expiresIn * 1000 : null; + + if (typeof window !== "undefined") { + sessionStorage.setItem("access_token", tokens.accessToken); + if (tokens.refreshToken) { + sessionStorage.setItem("refresh_token", tokens.refreshToken); + } + if (expiresAt) { + sessionStorage.setItem("token_expires_at", expiresAt.toString()); + } + } + + set({ + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken || get().refreshToken, + tokenExpiresAt: expiresAt, + }); + }, }), { name: "globalthreatmap-auth", + storage: createJSONStorage(() => sessionStorage), + partialize: (state) => ({ + user: state.user, + isAuthenticated: state.isAuthenticated, + accessToken: state.accessToken, + refreshToken: state.refreshToken, + tokenExpiresAt: state.tokenExpiresAt, + }), + skipHydration: true, } ) ); From 75ea44e375dbc1356de555c050e906894dcad9d4 Mon Sep 17 00:00:00 2001 From: yorkeccak Date: Fri, 23 Jan 2026 20:04:01 +0000 Subject: [PATCH 02/15] feat --- app/auth/valyu/callback/page.tsx | 54 +++--- app/layout.tsx | 3 +- components/auth/auth-initializer.tsx | 14 ++ components/auth/index.ts | 1 + components/auth/sign-in-panel.tsx | 50 +---- components/map/threat-map.tsx | 9 +- hooks/use-events.ts | 18 +- stores/auth-store.ts | 263 +++++++++++++-------------- 8 files changed, 187 insertions(+), 225 deletions(-) create mode 100644 components/auth/auth-initializer.tsx 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/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-panel.tsx b/components/auth/sign-in-panel.tsx index a5c1aa2..285151d 100644 --- a/components/auth/sign-in-panel.tsx +++ b/components/auth/sign-in-panel.tsx @@ -8,45 +8,12 @@ import { SignInModal } from "./sign-in-modal"; const APP_MODE = process.env.NEXT_PUBLIC_APP_MODE || "self-hosted"; - 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 ( @@ -69,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} -
- )} -
(null); const [selectedMilitaryBase, setSelectedMilitaryBase] = useState(null); const [selectedCountry, setSelectedCountry] = useState(null); @@ -293,9 +293,10 @@ export function ThreatMap() { const checkLimit = useCallback(() => { if (!requiresAuth) return false; + if (!initialized) return false; if (isAuthenticated) return false; return hasReachedLimit(); - }, [requiresAuth, isAuthenticated]); + }, [requiresAuth, isAuthenticated, initialized]); // Fetch military bases on mount useEffect(() => { @@ -487,7 +488,7 @@ export function ThreatMap() { return; } - if (requiresAuth && !isAuthenticated) { + if (requiresAuth && initialized && !isAuthenticated) { incrementCountryClicks(); } @@ -499,7 +500,7 @@ export function ThreatMap() { console.error("Error reverse geocoding:", error); } }, - [filteredEvents, selectEvent, viewport.zoom, checkLimit, requiresAuth, isAuthenticated] + [filteredEvents, selectEvent, viewport.zoom, checkLimit, requiresAuth, isAuthenticated, initialized] ); const handleMouseEnter = useCallback(() => { diff --git a/hooks/use-events.ts b/hooks/use-events.ts index d73f830..88d11f1 100644 --- a/hooks/use-events.ts +++ b/hooks/use-events.ts @@ -32,7 +32,7 @@ export function useEvents(options: UseEventsOptions = {}) { setError, } = useEventsStore(); - const { getAccessToken, signOut, isAuthenticated } = useAuthStore(); + const { getAccessToken, signOut, isAuthenticated, initialized } = useAuthStore(); const [requiresSignIn, setRequiresSignIn] = useState(false); const intervalRef = useRef(null); @@ -41,7 +41,7 @@ export function useEvents(options: UseEventsOptions = {}) { const requiresAuth = APP_MODE === "valyu"; const fetchEvents = useCallback(async () => { - if (requiresAuth && !isAuthenticated && hasReachedLimit()) { + if (requiresAuth && initialized && !isAuthenticated && hasReachedLimit()) { setRequiresSignIn(true); setLoading(false); return; @@ -76,7 +76,7 @@ export function useEvents(options: UseEventsOptions = {}) { if (!initialFetchRef.current) { setEvents(newEvents); initialFetchRef.current = true; - if (requiresAuth && !isAuthenticated) { + if (requiresAuth && initialized && !isAuthenticated) { incrementEventLoads(); } } else { @@ -92,25 +92,25 @@ export function useEvents(options: UseEventsOptions = {}) { } finally { setLoading(false); } - }, [queries, events, setEvents, addEvents, setLoading, setError, getAccessToken, signOut, requiresAuth, isAuthenticated]); + }, [queries, events, setEvents, addEvents, setLoading, setError, getAccessToken, signOut, requiresAuth, isAuthenticated, initialized]); const refresh = useCallback(() => { - if (requiresAuth && !isAuthenticated && hasReachedLimit()) { + if (requiresAuth && initialized && !isAuthenticated && hasReachedLimit()) { setRequiresSignIn(true); return; } - if (requiresAuth && !isAuthenticated) { + if (requiresAuth && initialized && !isAuthenticated) { incrementEventLoads(); } fetchEvents(); - }, [fetchEvents, requiresAuth, isAuthenticated]); + }, [fetchEvents, requiresAuth, isAuthenticated, initialized]); useEffect(() => { if (!initialFetchRef.current) { fetchEvents(); } - if (autoRefresh && !(requiresAuth && !isAuthenticated && hasReachedLimit())) { + if (autoRefresh && !(requiresAuth && initialized && !isAuthenticated && hasReachedLimit())) { intervalRef.current = setInterval(fetchEvents, refreshInterval); } @@ -119,7 +119,7 @@ export function useEvents(options: UseEventsOptions = {}) { clearInterval(intervalRef.current); } }; - }, [autoRefresh, refreshInterval, fetchEvents, requiresAuth, isAuthenticated]); + }, [autoRefresh, refreshInterval, fetchEvents, requiresAuth, isAuthenticated, initialized]); useEffect(() => { if (isAuthenticated) { diff --git a/stores/auth-store.ts b/stores/auth-store.ts index d79ecc0..7affff7 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1,5 +1,7 @@ import { create } from "zustand"; -import { persist, createJSONStorage } from "zustand/middleware"; + +const TOKEN_STORAGE_KEY = "valyu_oauth_tokens"; +const USER_STORAGE_KEY = "valyu_user"; export interface User { id: string; @@ -9,159 +11,156 @@ export interface User { email_verified?: boolean; } +interface TokenData { + accessToken: string; + refreshToken?: string; + expiresAt: number; +} + interface AuthState { user: User | null; - isAuthenticated: boolean; - isLoading: boolean; accessToken: string | null; refreshToken: string | null; tokenExpiresAt: number | null; - signIn: (user: User, tokens?: { accessToken: string; refreshToken?: string; expiresIn?: number }) => void; + isAuthenticated: boolean; + isLoading: boolean; + initialized: boolean; + signIn: (user: User, tokens: { accessToken: string; refreshToken?: string; expiresIn?: number }) => void; signOut: () => void; - checkAuthFromStorage: () => void; + initialize: () => void; getAccessToken: () => string | null; - setTokens: (tokens: { accessToken: string; refreshToken?: string; expiresIn?: number }) => void; } -function loadInitialTokens() { - if (typeof window === "undefined") return {}; +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 accessToken = sessionStorage.getItem("access_token"); - const refreshToken = sessionStorage.getItem("refresh_token"); - const expiresAt = sessionStorage.getItem("token_expires_at"); - return { - accessToken, - refreshToken, - tokenExpiresAt: expiresAt ? parseInt(expiresAt, 10) : null, - }; + const stored = localStorage.getItem(TOKEN_STORAGE_KEY); + if (!stored) return null; + return JSON.parse(stored); } catch { - return {}; + return null; } } -export const useAuthStore = create()( - persist( - (set, get) => ({ - user: null, - isAuthenticated: false, - isLoading: false, - accessToken: null, - refreshToken: null, - tokenExpiresAt: 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); +} - signIn: (user, tokens) => { - if (typeof window !== "undefined") { - localStorage.setItem("user", JSON.stringify(user)); - if (tokens?.accessToken) { - sessionStorage.setItem("access_token", tokens.accessToken); - if (tokens.refreshToken) { - sessionStorage.setItem("refresh_token", tokens.refreshToken); - } - if (tokens.expiresIn) { - const expiresAt = Date.now() + tokens.expiresIn * 1000; - sessionStorage.setItem("token_expires_at", expiresAt.toString()); - } - } - } +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, - accessToken: tokens?.accessToken || null, - refreshToken: tokens?.refreshToken || null, - tokenExpiresAt: tokens?.expiresIn ? Date.now() + tokens.expiresIn * 1000 : null, }); - }, + return; + } else { + clearTokens(); + clearUser(); + } + } - signOut: () => { - set({ isLoading: true }); + 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, + }); + }, - if (typeof window !== "undefined") { - localStorage.removeItem("user"); - sessionStorage.removeItem("access_token"); - sessionStorage.removeItem("refresh_token"); - sessionStorage.removeItem("token_expires_at"); - } + signOut: () => { + clearUser(); + clearTokens(); - set({ - user: null, - isAuthenticated: false, - isLoading: false, - accessToken: null, - refreshToken: null, - tokenExpiresAt: null, - }); - }, - - checkAuthFromStorage: () => { - if (typeof window === "undefined") return; - - const storedUser = localStorage.getItem("user"); - const initialTokens = loadInitialTokens(); - - if (storedUser) { - try { - const user = JSON.parse(storedUser); - if (user && user.id && user.email) { - set({ - user, - isAuthenticated: true, - accessToken: initialTokens.accessToken || null, - refreshToken: initialTokens.refreshToken || null, - tokenExpiresAt: initialTokens.tokenExpiresAt || null, - }); - return; - } - } catch (error) { - console.error("Error parsing stored user:", error); - } - } - - set({ user: null, isAuthenticated: false }); - }, - - getAccessToken: () => { - const state = get(); - if (!state.accessToken) return null; - - if (state.tokenExpiresAt && Date.now() >= state.tokenExpiresAt - 30000) { - return null; - } - - return state.accessToken; - }, - - setTokens: (tokens) => { - const expiresAt = tokens.expiresIn ? Date.now() + tokens.expiresIn * 1000 : null; - - if (typeof window !== "undefined") { - sessionStorage.setItem("access_token", tokens.accessToken); - if (tokens.refreshToken) { - sessionStorage.setItem("refresh_token", tokens.refreshToken); - } - if (expiresAt) { - sessionStorage.setItem("token_expires_at", expiresAt.toString()); - } - } + set({ + user: null, + accessToken: null, + refreshToken: null, + tokenExpiresAt: null, + isAuthenticated: false, + isLoading: false, + }); + }, - set({ - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken || get().refreshToken, - tokenExpiresAt: expiresAt, - }); - }, - }), - { - name: "globalthreatmap-auth", - storage: createJSONStorage(() => sessionStorage), - partialize: (state) => ({ - user: state.user, - isAuthenticated: state.isAuthenticated, - accessToken: state.accessToken, - refreshToken: state.refreshToken, - tokenExpiresAt: state.tokenExpiresAt, - }), - skipHydration: true, + getAccessToken: () => { + const state = get(); + if (!state.accessToken) return null; + + if (state.tokenExpiresAt && Date.now() >= state.tokenExpiresAt - 30000) { + return null; } - ) -); + + return state.accessToken; + }, +})); From bb18ab5b54db6440105abc5aa8d3147df703ad05 Mon Sep 17 00:00:00 2001 From: yorkeccak Date: Fri, 23 Jan 2026 20:09:37 +0000 Subject: [PATCH 03/15] feat --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f12d841..6a16e90 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 From efcd03e98f6f91c29f5f45b41a1d3bad20575319 Mon Sep 17 00:00:00 2001 From: yorkeccak Date: Fri, 23 Jan 2026 20:12:07 +0000 Subject: [PATCH 04/15] feat --- components/map/country-conflicts-modal.tsx | 7 ++-- components/search/entity-search.tsx | 8 ++--- components/ui/favicon.tsx | 40 ++++++++++++++++++++++ 3 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 components/ui/favicon.tsx diff --git a/components/map/country-conflicts-modal.tsx b/components/map/country-conflicts-modal.tsx index 70cacf1..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 { @@ -350,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/search/entity-search.tsx b/components/search/entity-search.tsx index 2042958..b14298a 100644 --- a/components/search/entity-search.tsx +++ b/components/search/entity-search.tsx @@ -15,11 +15,11 @@ import { Globe, Users, FileText, - ExternalLink, MapPin, Navigation, Lock, } from "lucide-react"; +import { Favicon } from "@/components/ui/favicon"; import { useMapStore } from "@/stores/map-store"; import { useAuthStore } from "@/stores/auth-store"; import { Markdown } from "@/components/ui/markdown"; @@ -252,10 +252,10 @@ export function EntitySearch() { href={source.url} target="_blank" rel="noopener noreferrer" - className="flex items-center gap-2 text-sm text-primary hover:underline" + className="flex items-center gap-2 text-sm text-foreground hover:text-primary transition-colors" > - - {source.title} + + {source.title} ))}
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)} + /> + ); +} From 848070837a799dfb311b15a1b7f64bb8ce2b290b Mon Sep 17 00:00:00 2001 From: yorkeccak Date: Fri, 23 Jan 2026 20:13:53 +0000 Subject: [PATCH 05/15] feat --- app/api/events/route.ts | 56 +++++++++----- lib/ai-classifier.ts | 158 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 20 deletions(-) create mode 100644 lib/ai-classifier.ts diff --git a/app/api/events/route.ts b/app/api/events/route.ts index f65722b..e2b8dc3 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from "next/server"; import { searchEvents } from "@/lib/valyu"; import { isSelfHostedMode } from "@/lib/app-mode"; -import { geocodeLocationsFromText } from "@/lib/geocoding"; -import { createThreatEvent } from "@/lib/event-classifier"; +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"; @@ -17,34 +18,49 @@ 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 locations = await geocodeLocationsFromText( - `${result.title} ${result.content}`, - result.title - ); + const cleanedTitle = cleanContent(result.title); + const cleanedContent = cleanContent(result.content); + const fullText = `${cleanedTitle} ${cleanedContent}`; - const location = locations[0] || { - latitude: 0, - longitude: 0, - placeName: "Unknown", - }; + // Use AI classification (falls back to keywords if OpenAI not available) + const classification = await classifyEvent(cleanedTitle, cleanedContent); - if (location.latitude === 0 && location.longitude === 0) { + // Skip events without valid locations + if (!classification.location) { return null; } - return createThreatEvent( - result.title, - result.content, - location, - result.source || "web", - result.url, - result.publishedDate - ); + 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; }) ); 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; +} From c9220780b28571ed6509d4f6c545d80ae4ade834 Mon Sep 17 00:00:00 2001 From: yorkeccak Date: Fri, 23 Jan 2026 20:16:57 +0000 Subject: [PATCH 06/15] feat --- README.md | 6 +++--- components/auth/sign-in-modal.tsx | 8 +------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6a16e90..313db44 100644 --- a/README.md +++ b/README.md @@ -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/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 */} From 95ddf7f8a0a7604b516632f4b674d12f3d1790f3 Mon Sep 17 00:00:00 2001 From: yorkeccak Date: Fri, 23 Jan 2026 20:19:11 +0000 Subject: [PATCH 07/15] feat --- components/map/event-popup.tsx | 63 +++++++++---- package.json | 1 + pnpm-lock.yaml | 168 +++++++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+), 16 deletions(-) diff --git a/components/map/event-popup.tsx b/components/map/event-popup.tsx index befb0af..0aa3e1d 100644 --- a/components/map/event-popup.tsx +++ b/components/map/event-popup.tsx @@ -1,18 +1,21 @@ "use client"; +import { useState } from "react"; import type { ThreatEvent } from "@/types"; import { Badge } from "@/components/ui/badge"; -import { Markdown } from "@/components/ui/markdown"; import { formatRelativeTime } from "@/lib/utils"; -import { ExternalLink, MapPin } from "lucide-react"; +import { ExternalLink, MapPin, ArrowDownRight, ChevronUp } from "lucide-react"; +import { Streamdown } from "streamdown"; interface EventPopupProps { event: ThreatEvent; } export function EventPopup({ event }: EventPopupProps) { + const [isExpanded, setIsExpanded] = useState(false); + return ( -
+
-
- -
+ {!isExpanded ? ( +
+ {event.summary} +
+ ) : ( +
+
+ {event.rawContent || event.summary} +
+
+ )}
@@ -47,16 +58,36 @@ export function EventPopup({ event }: EventPopupProps) { {formatRelativeTime(event.timestamp)} - {event.sourceUrl && ( - - Source - - )} +
+ {event.rawContent && ( + + )} + {event.sourceUrl && ( + + Source + + )} +
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 From 431efac2852e475f3a6f9aab628c969e4085c505 Mon Sep 17 00:00:00 2001 From: Rahul Monish Date: Fri, 23 Jan 2026 22:28:28 +0000 Subject: [PATCH 08/15] Add cascade prediction state management store --- stores/cascade-store.ts | 88 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 stores/cascade-store.ts 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 }), +})); From 8f22d0a4f21fcb73a0c2af05ba5e3127ee64f006 Mon Sep 17 00:00:00 2001 From: Rahul Monish Date: Fri, 23 Jan 2026 22:28:32 +0000 Subject: [PATCH 09/15] Add cascade analysis API endpoint with Valyu integration --- app/api/cascade/route.ts | 430 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 430 insertions(+) create mode 100644 app/api/cascade/route.ts diff --git a/app/api/cascade/route.ts b/app/api/cascade/route.ts new file mode 100644 index 0000000..7ce160e --- /dev/null +++ b/app/api/cascade/route.ts @@ -0,0 +1,430 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Valyu } from "valyu-js"; + +const valyuClient = new Valyu(process.env.VALYU_API_KEY || ""); + +// Country data with coordinates and relationships +const COUNTRY_DATA: Record< + string, + { + code: string; + lat: number; + lng: number; + neighbors: string[]; + economicPartners: string[]; + alliances: string[]; + region: string; + } +> = { + Ukraine: { + code: "UA", + lat: 48.3794, + lng: 31.1656, + neighbors: ["Russia", "Belarus", "Poland", "Slovakia", "Hungary", "Romania", "Moldova"], + economicPartners: ["Germany", "Poland", "Turkey", "China", "Italy"], + alliances: ["EU-candidate"], + region: "Eastern Europe", + }, + Russia: { + code: "RU", + lat: 61.524, + lng: 105.3188, + neighbors: ["Ukraine", "Belarus", "Finland", "Estonia", "Latvia", "Lithuania", "Poland", "Georgia", "Azerbaijan", "Kazakhstan", "China", "Mongolia", "North Korea"], + economicPartners: ["China", "India", "Turkey", "Belarus", "Kazakhstan"], + alliances: ["CSTO", "BRICS"], + region: "Eurasia", + }, + China: { + code: "CN", + lat: 35.8617, + lng: 104.1954, + neighbors: ["Russia", "Mongolia", "North Korea", "Vietnam", "Laos", "Myanmar", "India", "Bhutan", "Nepal", "Pakistan", "Afghanistan", "Tajikistan", "Kyrgyzstan", "Kazakhstan"], + economicPartners: ["United States", "Japan", "South Korea", "Germany", "Australia", "Vietnam"], + alliances: ["SCO", "BRICS"], + region: "East Asia", + }, + "United States": { + code: "US", + lat: 37.0902, + lng: -95.7129, + neighbors: ["Canada", "Mexico"], + economicPartners: ["China", "Canada", "Mexico", "Japan", "Germany", "United Kingdom", "South Korea"], + alliances: ["NATO", "AUKUS", "Five Eyes"], + region: "North America", + }, + Israel: { + code: "IL", + lat: 31.0461, + lng: 34.8516, + neighbors: ["Lebanon", "Syria", "Jordan", "Egypt", "Palestine"], + economicPartners: ["United States", "China", "United Kingdom", "Germany", "India"], + alliances: ["US-ally"], + region: "Middle East", + }, + Iran: { + code: "IR", + lat: 32.4279, + lng: 53.688, + neighbors: ["Iraq", "Turkey", "Armenia", "Azerbaijan", "Turkmenistan", "Afghanistan", "Pakistan"], + economicPartners: ["China", "UAE", "Turkey", "Iraq", "India"], + alliances: ["SCO-observer"], + region: "Middle East", + }, + Germany: { + code: "DE", + lat: 51.1657, + lng: 10.4515, + neighbors: ["France", "Belgium", "Netherlands", "Luxembourg", "Switzerland", "Austria", "Czech Republic", "Poland", "Denmark"], + economicPartners: ["United States", "China", "France", "Netherlands", "United Kingdom", "Italy", "Poland"], + alliances: ["NATO", "EU"], + region: "Western Europe", + }, + Poland: { + code: "PL", + lat: 51.9194, + lng: 19.1451, + neighbors: ["Germany", "Czech Republic", "Slovakia", "Ukraine", "Belarus", "Lithuania", "Russia"], + economicPartners: ["Germany", "Czech Republic", "United Kingdom", "France", "Italy"], + alliances: ["NATO", "EU"], + region: "Eastern Europe", + }, + Taiwan: { + code: "TW", + lat: 23.6978, + lng: 120.9605, + neighbors: [], + economicPartners: ["China", "United States", "Japan", "South Korea", "Singapore"], + alliances: ["US-partner"], + region: "East Asia", + }, + Japan: { + code: "JP", + lat: 36.2048, + lng: 138.2529, + neighbors: [], + economicPartners: ["China", "United States", "South Korea", "Taiwan", "Thailand"], + alliances: ["US-ally", "Quad"], + region: "East Asia", + }, + "South Korea": { + code: "KR", + lat: 35.9078, + lng: 127.7669, + neighbors: ["North Korea"], + economicPartners: ["China", "United States", "Japan", "Vietnam", "Taiwan"], + alliances: ["US-ally"], + region: "East Asia", + }, + "North Korea": { + code: "KP", + lat: 40.3399, + lng: 127.5101, + neighbors: ["South Korea", "China", "Russia"], + economicPartners: ["China", "Russia"], + alliances: [], + region: "East Asia", + }, + India: { + code: "IN", + lat: 20.5937, + lng: 78.9629, + neighbors: ["Pakistan", "China", "Nepal", "Bhutan", "Bangladesh", "Myanmar"], + economicPartners: ["United States", "China", "UAE", "Saudi Arabia", "Iraq"], + alliances: ["Quad", "BRICS"], + region: "South Asia", + }, + Pakistan: { + code: "PK", + lat: 30.3753, + lng: 69.3451, + neighbors: ["India", "Afghanistan", "Iran", "China"], + economicPartners: ["China", "UAE", "Saudi Arabia", "United States"], + alliances: ["China-ally"], + region: "South Asia", + }, + "Saudi Arabia": { + code: "SA", + lat: 23.8859, + lng: 45.0792, + neighbors: ["Jordan", "Iraq", "Kuwait", "Qatar", "UAE", "Oman", "Yemen"], + economicPartners: ["China", "United States", "Japan", "India", "South Korea"], + alliances: ["GCC", "US-partner"], + region: "Middle East", + }, + Turkey: { + code: "TR", + lat: 38.9637, + lng: 35.2433, + neighbors: ["Greece", "Bulgaria", "Georgia", "Armenia", "Iran", "Iraq", "Syria"], + economicPartners: ["Germany", "United Kingdom", "Italy", "Iraq", "United States"], + alliances: ["NATO"], + region: "Middle East", + }, + "United Kingdom": { + code: "GB", + lat: 55.3781, + lng: -3.436, + neighbors: ["Ireland"], + economicPartners: ["United States", "Germany", "Netherlands", "France", "China"], + alliances: ["NATO", "Five Eyes", "AUKUS"], + region: "Western Europe", + }, + France: { + code: "FR", + lat: 46.2276, + lng: 2.2137, + neighbors: ["Belgium", "Luxembourg", "Germany", "Switzerland", "Italy", "Spain", "Andorra", "Monaco"], + economicPartners: ["Germany", "United States", "Italy", "Spain", "Belgium"], + alliances: ["NATO", "EU"], + region: "Western Europe", + }, + Syria: { + code: "SY", + lat: 34.8021, + lng: 38.9968, + neighbors: ["Turkey", "Iraq", "Jordan", "Israel", "Lebanon"], + economicPartners: ["Russia", "China", "Iran", "UAE"], + alliances: ["Russia-ally", "Iran-ally"], + region: "Middle East", + }, + Lebanon: { + code: "LB", + lat: 33.8547, + lng: 35.8623, + neighbors: ["Syria", "Israel"], + economicPartners: ["UAE", "Saudi Arabia", "China", "Turkey"], + alliances: [], + region: "Middle East", + }, + Egypt: { + code: "EG", + lat: 26.8206, + lng: 30.8025, + neighbors: ["Libya", "Sudan", "Israel", "Palestine"], + economicPartners: ["United States", "UAE", "Saudi Arabia", "China", "Turkey"], + alliances: ["US-partner", "Arab League"], + region: "Middle East", + }, + Sudan: { + code: "SD", + lat: 12.8628, + lng: 30.2176, + neighbors: ["Egypt", "Libya", "Chad", "Central African Republic", "South Sudan", "Ethiopia", "Eritrea"], + economicPartners: ["UAE", "China", "Saudi Arabia", "India"], + alliances: [], + region: "Africa", + }, + Ethiopia: { + code: "ET", + lat: 9.145, + lng: 40.4897, + neighbors: ["Eritrea", "Djibouti", "Somalia", "Kenya", "South Sudan", "Sudan"], + economicPartners: ["China", "United States", "Saudi Arabia", "UAE"], + alliances: ["AU"], + region: "Africa", + }, + Nigeria: { + code: "NG", + lat: 9.082, + lng: 8.6753, + neighbors: ["Benin", "Niger", "Chad", "Cameroon"], + economicPartners: ["India", "United States", "Spain", "Netherlands", "France"], + alliances: ["AU", "ECOWAS"], + region: "Africa", + }, + Brazil: { + code: "BR", + lat: -14.235, + lng: -51.9253, + neighbors: ["Argentina", "Paraguay", "Bolivia", "Peru", "Colombia", "Venezuela", "Guyana", "Suriname", "French Guiana", "Uruguay"], + economicPartners: ["China", "United States", "Argentina", "Netherlands", "Germany"], + alliances: ["BRICS", "Mercosur"], + region: "South America", + }, + Australia: { + code: "AU", + lat: -25.2744, + lng: 133.7751, + neighbors: [], + economicPartners: ["China", "Japan", "United States", "South Korea", "India"], + alliances: ["AUKUS", "Five Eyes", "Quad"], + region: "Oceania", + }, +}; + +// Impact type descriptions based on event category +const IMPACT_MAPPINGS: Record = { + conflict: ["military", "humanitarian", "economic", "political"], + military: ["military", "political", "economic"], + terrorism: ["political", "social", "economic"], + protest: ["political", "social", "economic"], + economic: ["economic", "political", "social"], + diplomatic: ["political", "economic"], + disaster: ["humanitarian", "economic"], + cyber: ["economic", "military", "political"], + health: ["humanitarian", "economic", "social"], + environmental: ["humanitarian", "economic"], +}; + +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 + const analysisQuery = `Analyze the potential geopolitical and economic ripple effects of this event: "${event.title}". + + The event occurred in ${eventCountry} and is categorized as: ${eventCategory}. + + For each potentially affected country, provide: + 1. How likely they are to be affected (probability 0-100%) + 2. Expected timeframe for impact (hours/days) + 3. Type of impact (economic, military, political, humanitarian, social) + 4. Brief explanation of why they would be affected + + Focus on: + - Neighboring countries + - Major trading partners + - Military allies + - Countries with historical tensions + - Supply chain dependencies + + List the top 8-12 most likely affected countries.`; + + type AnswerResponse = { + contents?: string; + search_results?: Array<{ title?: string; url?: string }>; + }; + + const response = await valyuClient.answer(analysisQuery, { + excludedSources: ["wikipedia.org"], + }); + + const answerData = response as AnswerResponse; + const analysisContent = answerData.contents || ""; + + // Parse the AI response and build cascade effects + const effects: Array<{ + id: string; + targetCountry: string; + targetCountryCode: string; + latitude: number; + longitude: number; + probability: number; + timeframeHours: number; + impactType: string; + description: string; + factors: string[]; + delay: number; + }> = []; + + // Get the source country data + const sourceCountryData = COUNTRY_DATA[eventCountry]; + + // Determine affected countries based on relationships + AI analysis + const affectedCountries = new Set(); + + // Add neighbors + if (sourceCountryData) { + sourceCountryData.neighbors.forEach((c) => affectedCountries.add(c)); + sourceCountryData.economicPartners.slice(0, 5).forEach((c) => affectedCountries.add(c)); + } + + // Parse AI response for mentioned countries + Object.keys(COUNTRY_DATA).forEach((country) => { + if (country !== eventCountry && analysisContent.toLowerCase().includes(country.toLowerCase())) { + affectedCountries.add(country); + } + }); + + // Build effects for each affected country + let delay = 0; + const impactTypes = IMPACT_MAPPINGS[eventCategory] || ["political", "economic"]; + + Array.from(affectedCountries).slice(0, 12).forEach((country, index) => { + const countryData = COUNTRY_DATA[country]; + if (!countryData) return; + + // Calculate probability based on relationship + let baseProbability = 30; + if (sourceCountryData?.neighbors.includes(country)) baseProbability += 40; + if (sourceCountryData?.economicPartners.includes(country)) baseProbability += 20; + if (sourceCountryData?.alliances.some((a) => countryData.alliances.includes(a))) baseProbability += 15; + + // Add some variance + const probability = Math.min(95, Math.max(15, baseProbability + Math.floor(Math.random() * 20) - 10)); + + // Calculate timeframe + const isNeighbor = sourceCountryData?.neighbors.includes(country); + const timeframeHours = isNeighbor ? 24 + Math.floor(Math.random() * 48) : 72 + Math.floor(Math.random() * 168); + + // Determine impact type + const impactType = impactTypes[index % impactTypes.length]; + + // Generate factors + const factors: string[] = []; + if (sourceCountryData?.neighbors.includes(country)) factors.push("Neighboring country"); + if (sourceCountryData?.economicPartners.includes(country)) factors.push("Major trade partner"); + if (sourceCountryData?.alliances.some((a) => countryData.alliances.includes(a))) { + factors.push("Alliance member"); + } + if (countryData.region === sourceCountryData?.region) factors.push("Same region"); + + // Generate description + let description = `${country} may experience ${impactType} effects`; + if (isNeighbor) description += " due to direct proximity"; + if (factors.includes("Major trade partner")) description += " through trade disruption"; + description += "."; + + effects.push({ + id: `cascade-${Date.now()}-${index}`, + targetCountry: country, + targetCountryCode: countryData.code, + latitude: countryData.lat, + longitude: countryData.lng, + probability, + timeframeHours, + impactType, + description, + factors, + delay, + }); + + delay += 150; // Stagger animation + }); + + // Sort by probability + effects.sort((a, b) => b.probability - a.probability); + + // Update delays after sorting + effects.forEach((effect, index) => { + effect.delay = index * 150; + }); + + const highRiskCount = effects.filter((e) => e.probability >= 60).length; + + // Generate summary + const summary = `This ${eventCategory} event in ${eventCountry} could potentially cascade to ${effects.length} countries. ${highRiskCount} countries face high probability (60%+) of being affected. Primary impact vectors include ${[...new Set(effects.slice(0, 5).map((e) => e.impactType))].join(", ")} effects.`; + + return NextResponse.json({ + sourceEvent: event, + effects, + summary, + totalAffectedCountries: effects.length, + highRiskCount, + generatedAt: new Date().toISOString(), + }); + } catch (error) { + console.error("Cascade analysis error:", error); + return NextResponse.json( + { error: "Failed to analyze cascade effects" }, + { status: 500 } + ); + } +} From 4a60175f0f88d06fd0fb19d34500ee57e7a93256 Mon Sep 17 00:00:00 2001 From: Rahul Monish Date: Fri, 23 Jan 2026 22:28:37 +0000 Subject: [PATCH 10/15] Add cascade panel UI components --- components/cascade/cascade-panel.tsx | 332 +++++++++++++++++++++++++++ components/cascade/index.ts | 1 + 2 files changed, 333 insertions(+) create mode 100644 components/cascade/cascade-panel.tsx create mode 100644 components/cascade/index.ts 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"; From c0068dbc8ecc7df11e5b9bc637e158fb81031736 Mon Sep 17 00:00:00 2001 From: Rahul Monish Date: Fri, 23 Jan 2026 22:28:41 +0000 Subject: [PATCH 11/15] Add cascade analysis button to event popup --- components/map/event-popup.tsx | 48 +++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/components/map/event-popup.tsx b/components/map/event-popup.tsx index 0aa3e1d..19aa63e 100644 --- a/components/map/event-popup.tsx +++ b/components/map/event-popup.tsx @@ -3,8 +3,10 @@ import { useState } from "react"; import type { ThreatEvent } from "@/types"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { formatRelativeTime } from "@/lib/utils"; -import { ExternalLink, MapPin, ArrowDownRight, ChevronUp } 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 { @@ -13,6 +15,27 @@ interface EventPopupProps { 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 (
@@ -100,6 +123,29 @@ export function EventPopup({ event }: EventPopupProps) { ))}
+ + {/* Cascade Analysis Button */} +
+ +
); } From a4e5991ad2b54ac96cd65e7945d7ab8b386296bb Mon Sep 17 00:00:00 2001 From: Rahul Monish Date: Fri, 23 Jan 2026 22:28:45 +0000 Subject: [PATCH 12/15] Add cascade visualization layers to threat map --- components/map/threat-map.tsx | 203 +++++++++++++++++++++++++++++++++- 1 file changed, 197 insertions(+), 6 deletions(-) diff --git a/components/map/threat-map.tsx b/components/map/threat-map.tsx index a6e301e..5ac3a86 100644 --- a/components/map/threat-map.tsx +++ b/components/map/threat-map.tsx @@ -15,6 +15,7 @@ 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"; @@ -281,6 +282,7 @@ export function ThreatMap() { } = useMapStore(); const { filteredEvents, selectedEvent, selectEvent } = useEventsStore(); 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); @@ -404,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 @@ -601,12 +682,8 @@ export function ThreatMap() { clusterRadius={50} > {showHeatmap && } - {showClusters && ( - <> - - - - )} + {showClusters && } + {showClusters && } @@ -625,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 && ( Date: Fri, 23 Jan 2026 22:28:49 +0000 Subject: [PATCH 13/15] Add cascade tab to sidebar navigation --- components/sidebar.tsx | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) 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" && }
)} From 83f5ad117ffd9622520681bba22e4ee001fb98c1 Mon Sep 17 00:00:00 2001 From: Rahul Monish Date: Fri, 23 Jan 2026 22:28:53 +0000 Subject: [PATCH 14/15] Update package-lock.json --- package-lock.json | 298 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 282 insertions(+), 16 deletions(-) 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" } From ae385d5e9fd881a9f668723a890f2c376501914c Mon Sep 17 00:00:00 2001 From: Rahul Monish Date: Fri, 23 Jan 2026 22:52:20 +0000 Subject: [PATCH 15/15] Refactor cascade API to use Valyu structured outputs --- app/api/cascade/route.ts | 507 ++++++++++----------------------------- 1 file changed, 126 insertions(+), 381 deletions(-) diff --git a/app/api/cascade/route.ts b/app/api/cascade/route.ts index 7ce160e..a808a9c 100644 --- a/app/api/cascade/route.ts +++ b/app/api/cascade/route.ts @@ -3,267 +3,64 @@ import { Valyu } from "valyu-js"; const valyuClient = new Valyu(process.env.VALYU_API_KEY || ""); -// Country data with coordinates and relationships -const COUNTRY_DATA: Record< - string, - { - code: string; - lat: number; - lng: number; - neighbors: string[]; - economicPartners: string[]; - alliances: string[]; - region: string; - } -> = { - Ukraine: { - code: "UA", - lat: 48.3794, - lng: 31.1656, - neighbors: ["Russia", "Belarus", "Poland", "Slovakia", "Hungary", "Romania", "Moldova"], - economicPartners: ["Germany", "Poland", "Turkey", "China", "Italy"], - alliances: ["EU-candidate"], - region: "Eastern Europe", - }, - Russia: { - code: "RU", - lat: 61.524, - lng: 105.3188, - neighbors: ["Ukraine", "Belarus", "Finland", "Estonia", "Latvia", "Lithuania", "Poland", "Georgia", "Azerbaijan", "Kazakhstan", "China", "Mongolia", "North Korea"], - economicPartners: ["China", "India", "Turkey", "Belarus", "Kazakhstan"], - alliances: ["CSTO", "BRICS"], - region: "Eurasia", - }, - China: { - code: "CN", - lat: 35.8617, - lng: 104.1954, - neighbors: ["Russia", "Mongolia", "North Korea", "Vietnam", "Laos", "Myanmar", "India", "Bhutan", "Nepal", "Pakistan", "Afghanistan", "Tajikistan", "Kyrgyzstan", "Kazakhstan"], - economicPartners: ["United States", "Japan", "South Korea", "Germany", "Australia", "Vietnam"], - alliances: ["SCO", "BRICS"], - region: "East Asia", - }, - "United States": { - code: "US", - lat: 37.0902, - lng: -95.7129, - neighbors: ["Canada", "Mexico"], - economicPartners: ["China", "Canada", "Mexico", "Japan", "Germany", "United Kingdom", "South Korea"], - alliances: ["NATO", "AUKUS", "Five Eyes"], - region: "North America", - }, - Israel: { - code: "IL", - lat: 31.0461, - lng: 34.8516, - neighbors: ["Lebanon", "Syria", "Jordan", "Egypt", "Palestine"], - economicPartners: ["United States", "China", "United Kingdom", "Germany", "India"], - alliances: ["US-ally"], - region: "Middle East", - }, - Iran: { - code: "IR", - lat: 32.4279, - lng: 53.688, - neighbors: ["Iraq", "Turkey", "Armenia", "Azerbaijan", "Turkmenistan", "Afghanistan", "Pakistan"], - economicPartners: ["China", "UAE", "Turkey", "Iraq", "India"], - alliances: ["SCO-observer"], - region: "Middle East", - }, - Germany: { - code: "DE", - lat: 51.1657, - lng: 10.4515, - neighbors: ["France", "Belgium", "Netherlands", "Luxembourg", "Switzerland", "Austria", "Czech Republic", "Poland", "Denmark"], - economicPartners: ["United States", "China", "France", "Netherlands", "United Kingdom", "Italy", "Poland"], - alliances: ["NATO", "EU"], - region: "Western Europe", - }, - Poland: { - code: "PL", - lat: 51.9194, - lng: 19.1451, - neighbors: ["Germany", "Czech Republic", "Slovakia", "Ukraine", "Belarus", "Lithuania", "Russia"], - economicPartners: ["Germany", "Czech Republic", "United Kingdom", "France", "Italy"], - alliances: ["NATO", "EU"], - region: "Eastern Europe", - }, - Taiwan: { - code: "TW", - lat: 23.6978, - lng: 120.9605, - neighbors: [], - economicPartners: ["China", "United States", "Japan", "South Korea", "Singapore"], - alliances: ["US-partner"], - region: "East Asia", - }, - Japan: { - code: "JP", - lat: 36.2048, - lng: 138.2529, - neighbors: [], - economicPartners: ["China", "United States", "South Korea", "Taiwan", "Thailand"], - alliances: ["US-ally", "Quad"], - region: "East Asia", - }, - "South Korea": { - code: "KR", - lat: 35.9078, - lng: 127.7669, - neighbors: ["North Korea"], - economicPartners: ["China", "United States", "Japan", "Vietnam", "Taiwan"], - alliances: ["US-ally"], - region: "East Asia", - }, - "North Korea": { - code: "KP", - lat: 40.3399, - lng: 127.5101, - neighbors: ["South Korea", "China", "Russia"], - economicPartners: ["China", "Russia"], - alliances: [], - region: "East Asia", - }, - India: { - code: "IN", - lat: 20.5937, - lng: 78.9629, - neighbors: ["Pakistan", "China", "Nepal", "Bhutan", "Bangladesh", "Myanmar"], - economicPartners: ["United States", "China", "UAE", "Saudi Arabia", "Iraq"], - alliances: ["Quad", "BRICS"], - region: "South Asia", - }, - Pakistan: { - code: "PK", - lat: 30.3753, - lng: 69.3451, - neighbors: ["India", "Afghanistan", "Iran", "China"], - economicPartners: ["China", "UAE", "Saudi Arabia", "United States"], - alliances: ["China-ally"], - region: "South Asia", - }, - "Saudi Arabia": { - code: "SA", - lat: 23.8859, - lng: 45.0792, - neighbors: ["Jordan", "Iraq", "Kuwait", "Qatar", "UAE", "Oman", "Yemen"], - economicPartners: ["China", "United States", "Japan", "India", "South Korea"], - alliances: ["GCC", "US-partner"], - region: "Middle East", - }, - Turkey: { - code: "TR", - lat: 38.9637, - lng: 35.2433, - neighbors: ["Greece", "Bulgaria", "Georgia", "Armenia", "Iran", "Iraq", "Syria"], - economicPartners: ["Germany", "United Kingdom", "Italy", "Iraq", "United States"], - alliances: ["NATO"], - region: "Middle East", - }, - "United Kingdom": { - code: "GB", - lat: 55.3781, - lng: -3.436, - neighbors: ["Ireland"], - economicPartners: ["United States", "Germany", "Netherlands", "France", "China"], - alliances: ["NATO", "Five Eyes", "AUKUS"], - region: "Western Europe", - }, - France: { - code: "FR", - lat: 46.2276, - lng: 2.2137, - neighbors: ["Belgium", "Luxembourg", "Germany", "Switzerland", "Italy", "Spain", "Andorra", "Monaco"], - economicPartners: ["Germany", "United States", "Italy", "Spain", "Belgium"], - alliances: ["NATO", "EU"], - region: "Western Europe", - }, - Syria: { - code: "SY", - lat: 34.8021, - lng: 38.9968, - neighbors: ["Turkey", "Iraq", "Jordan", "Israel", "Lebanon"], - economicPartners: ["Russia", "China", "Iran", "UAE"], - alliances: ["Russia-ally", "Iran-ally"], - region: "Middle East", - }, - Lebanon: { - code: "LB", - lat: 33.8547, - lng: 35.8623, - neighbors: ["Syria", "Israel"], - economicPartners: ["UAE", "Saudi Arabia", "China", "Turkey"], - alliances: [], - region: "Middle East", - }, - Egypt: { - code: "EG", - lat: 26.8206, - lng: 30.8025, - neighbors: ["Libya", "Sudan", "Israel", "Palestine"], - economicPartners: ["United States", "UAE", "Saudi Arabia", "China", "Turkey"], - alliances: ["US-partner", "Arab League"], - region: "Middle East", - }, - Sudan: { - code: "SD", - lat: 12.8628, - lng: 30.2176, - neighbors: ["Egypt", "Libya", "Chad", "Central African Republic", "South Sudan", "Ethiopia", "Eritrea"], - economicPartners: ["UAE", "China", "Saudi Arabia", "India"], - alliances: [], - region: "Africa", - }, - Ethiopia: { - code: "ET", - lat: 9.145, - lng: 40.4897, - neighbors: ["Eritrea", "Djibouti", "Somalia", "Kenya", "South Sudan", "Sudan"], - economicPartners: ["China", "United States", "Saudi Arabia", "UAE"], - alliances: ["AU"], - region: "Africa", - }, - Nigeria: { - code: "NG", - lat: 9.082, - lng: 8.6753, - neighbors: ["Benin", "Niger", "Chad", "Cameroon"], - economicPartners: ["India", "United States", "Spain", "Netherlands", "France"], - alliances: ["AU", "ECOWAS"], - region: "Africa", - }, - Brazil: { - code: "BR", - lat: -14.235, - lng: -51.9253, - neighbors: ["Argentina", "Paraguay", "Bolivia", "Peru", "Colombia", "Venezuela", "Guyana", "Suriname", "French Guiana", "Uruguay"], - economicPartners: ["China", "United States", "Argentina", "Netherlands", "Germany"], - alliances: ["BRICS", "Mercosur"], - region: "South America", - }, - Australia: { - code: "AU", - lat: -25.2744, - lng: 133.7751, - neighbors: [], - economicPartners: ["China", "Japan", "United States", "South Korea", "India"], - alliances: ["AUKUS", "Five Eyes", "Quad"], - region: "Oceania", - }, -}; - -// Impact type descriptions based on event category -const IMPACT_MAPPINGS: Record = { - conflict: ["military", "humanitarian", "economic", "political"], - military: ["military", "political", "economic"], - terrorism: ["political", "social", "economic"], - protest: ["political", "social", "economic"], - economic: ["economic", "political", "social"], - diplomatic: ["political", "economic"], - disaster: ["humanitarian", "economic"], - cyber: ["economic", "military", "political"], - health: ["humanitarian", "economic", "social"], - environmental: ["humanitarian", "economic"], +// 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) { @@ -277,153 +74,101 @@ export async function POST(request: NextRequest) { const eventCountry = event.location?.country || "Unknown"; const eventCategory = event.category || "conflict"; - // Use Valyu to analyze potential cascade effects - const analysisQuery = `Analyze the potential geopolitical and economic ripple effects of this event: "${event.title}". - - The event occurred in ${eventCountry} and is categorized as: ${eventCategory}. - - For each potentially affected country, provide: - 1. How likely they are to be affected (probability 0-100%) - 2. Expected timeframe for impact (hours/days) - 3. Type of impact (economic, military, political, humanitarian, social) - 4. Brief explanation of why they would be affected + // Use Valyu to analyze potential cascade effects with structured output + const analysisQuery = `Analyze the potential geopolitical and economic ripple effects of this event: - Focus on: - - Neighboring countries - - Major trading partners - - Military allies - - Countries with historical tensions - - Supply chain dependencies +Event Title: "${event.title}" +Location: ${eventCountry} +Category: ${eventCategory} +Summary: ${event.summary || "No summary available"} - List the top 8-12 most likely affected countries.`; +Identify 8-12 countries most likely to be affected by cascade effects from this event. For each country, analyze: - type AnswerResponse = { - contents?: string; - search_results?: Array<{ title?: string; url?: string }>; - }; - - const response = await valyuClient.answer(analysisQuery, { - excludedSources: ["wikipedia.org"], - }); +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 - const answerData = response as AnswerResponse; - const analysisContent = answerData.contents || ""; +2. The expected timeframe for when effects would manifest (in hours) - // Parse the AI response and build cascade effects - const effects: Array<{ - id: string; - targetCountry: string; - targetCountryCode: string; - latitude: number; - longitude: number; - probability: number; - timeframeHours: number; - impactType: string; - description: string; - factors: string[]; - delay: number; - }> = []; +3. The primary type of impact (economic, military, political, humanitarian, or social) - // Get the source country data - const sourceCountryData = COUNTRY_DATA[eventCountry]; +4. A clear explanation of why this country would be affected - // Determine affected countries based on relationships + AI analysis - const affectedCountries = new Set(); +5. The key factors driving the cascade effect - // Add neighbors - if (sourceCountryData) { - sourceCountryData.neighbors.forEach((c) => affectedCountries.add(c)); - sourceCountryData.economicPartners.slice(0, 5).forEach((c) => affectedCountries.add(c)); - } +Provide accurate geographic coordinates (latitude/longitude) for each country's center point. +Sort the results by probability of impact (highest first).`; - // Parse AI response for mentioned countries - Object.keys(COUNTRY_DATA).forEach((country) => { - if (country !== eventCountry && analysisContent.toLowerCase().includes(country.toLowerCase())) { - affectedCountries.add(country); - } + const response = await valyuClient.answer(analysisQuery, { + structuredOutput: cascadeSchema, + searchType: "news", + excludedSources: ["wikipedia.org"], }); - // Build effects for each affected country - let delay = 0; - const impactTypes = IMPACT_MAPPINGS[eventCategory] || ["political", "economic"]; - - Array.from(affectedCountries).slice(0, 12).forEach((country, index) => { - const countryData = COUNTRY_DATA[country]; - if (!countryData) return; - - // Calculate probability based on relationship - let baseProbability = 30; - if (sourceCountryData?.neighbors.includes(country)) baseProbability += 40; - if (sourceCountryData?.economicPartners.includes(country)) baseProbability += 20; - if (sourceCountryData?.alliances.some((a) => countryData.alliances.includes(a))) baseProbability += 15; - - // Add some variance - const probability = Math.min(95, Math.max(15, baseProbability + Math.floor(Math.random() * 20) - 10)); - - // Calculate timeframe - const isNeighbor = sourceCountryData?.neighbors.includes(country); - const timeframeHours = isNeighbor ? 24 + Math.floor(Math.random() * 48) : 72 + Math.floor(Math.random() * 168); - - // Determine impact type - const impactType = impactTypes[index % impactTypes.length]; + 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; + }; - // Generate factors - const factors: string[] = []; - if (sourceCountryData?.neighbors.includes(country)) factors.push("Neighboring country"); - if (sourceCountryData?.economicPartners.includes(country)) factors.push("Major trade partner"); - if (sourceCountryData?.alliances.some((a) => countryData.alliances.includes(a))) { - factors.push("Alliance member"); + 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)); } - if (countryData.region === sourceCountryData?.region) factors.push("Same region"); - - // Generate description - let description = `${country} may experience ${impactType} effects`; - if (isNeighbor) description += " due to direct proximity"; - if (factors.includes("Major trade partner")) description += " through trade disruption"; - description += "."; - - effects.push({ - id: `cascade-${Date.now()}-${index}`, - targetCountry: country, - targetCountryCode: countryData.code, - latitude: countryData.lat, - longitude: countryData.lng, - probability, - timeframeHours, - impactType, - description, - factors, - delay, - }); - - delay += 150; // Stagger animation - }); - - // Sort by probability - effects.sort((a, b) => b.probability - a.probability); + } 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)); + } - // Update delays after sorting - effects.forEach((effect, index) => { - effect.delay = index * 150; - }); + // 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; - // Generate summary - const summary = `This ${eventCategory} event in ${eventCountry} could potentially cascade to ${effects.length} countries. ${highRiskCount} countries face high probability (60%+) of being affected. Primary impact vectors include ${[...new Set(effects.slice(0, 5).map((e) => e.impactType))].join(", ")} effects.`; - return NextResponse.json({ sourceEvent: event, effects, - summary, + 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" }, + { error: "Failed to analyze cascade effects", details: errorMessage }, { status: 500 } ); }