'use client'; import React, { useState, useEffect, useCallback } from 'react'; import ReactDOM from 'react-dom'; import { getBackendEndpoint } from '@/lib/backendEndpoint'; import { motion, AnimatePresence } from 'framer-motion'; import { Brain, MapPin, Trash2, Minus, Plus, Crosshair, Navigation, RefreshCw, X, Link2, Copy, Check, Shield, Eye, EyeOff, AlertTriangle, Zap, ChevronDown, ChevronRight, Globe, Rss, } from 'lucide-react'; import { API_BASE } from '@/lib/api'; import type { AIIntelPin, AIIntelLayer, SatelliteScene } from '@/types/aiIntel'; import { useTranslation } from '@/i18n'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { createLayer as apiCreateLayer, updateLayer as apiUpdateLayer, deleteLayer as apiDeleteLayer, refreshLayerFeed as apiRefreshLayerFeed, fetchSatelliteImages, } from '@/lib/aiIntelClient'; interface AIIntelPanelProps { onFlyTo?: (lat: number, lng: number) => void; isMinimized?: boolean; onMinimizedChange?: (minimized: boolean) => void; pinPlacementMode?: boolean; onPinPlacementModeChange?: (active: boolean) => void; } /* ─── Agent Identity (Ed25519 keypair — future MLS upgrade) ───────── */ function WormholeIdentitySection() { const [identity, setIdentity] = React.useState<{ bootstrapped: boolean; node_id: string; public_key: string; } | null>(null); const [loading, setLoading] = React.useState(true); const [bootstrapping, setBootstrapping] = React.useState(false); const fetchIdentity = React.useCallback(async () => { try { setLoading(true); const res = await fetch(`${API_BASE}/api/ai/agent-identity`); if (res.ok) { const data = await res.json(); setIdentity(data); } } catch { /* ignore */ } finally { setLoading(false); } }, []); React.useEffect(() => { fetchIdentity(); }, [fetchIdentity]); const handleBootstrap = async (force: boolean = false) => { if (force && !confirm('Regenerate agent identity? The old keypair will be permanently destroyed.')) return; setBootstrapping(true); try { const res = await fetch(`${API_BASE}/api/ai/agent-identity/bootstrap`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ force }), }); if (res.ok) { const data = await res.json(); setIdentity(data); } } catch { /* ignore */ } finally { setBootstrapping(false); } }; const handleRevoke = async () => { if (!confirm('Permanently revoke agent identity? This cannot be undone.')) return; try { await fetch(`${API_BASE}/api/ai/agent-identity`, { method: 'DELETE' }); setIdentity({ bootstrapped: false, node_id: '', public_key: '' }); } catch { /* ignore */ } }; if (loading) { return (
Agent Identity loading...
); } return (
Agent Identity (Ed25519) HMAC AUTH
{identity?.bootstrapped ? (
Identity Active
Node ID: {identity.node_id}
Public Key: {identity.public_key ? identity.public_key.substring(0, 12) + '...' : 'N/A'}

Agent has its own Ed25519 identity, separate from the operator. Commands currently travel via HMAC-authenticated HTTP (not E2EE). Private key never leaves this server.

) : (

Generate an Ed25519 identity for your agent. This keypair is used for mesh signing. Commands currently travel via HMAC-authenticated HTTP.

)}
); } /* ─── Command Channel Status ───────────────────────────────────────── */ function ChannelStatusSection() { const [channelInfo, setChannelInfo] = React.useState<{ ok: boolean; tier: number; reason: string; transport: string; forward_secrecy: boolean; sealed_sender: boolean; pending_commands: number; completed_commands: number; pending_tasks: number; stats: Record; } | null>(null); const [loading, setLoading] = React.useState(true); const [taskType, setTaskType] = React.useState('alert'); const [taskPayload, setTaskPayload] = React.useState(''); const [pushing, setPushing] = React.useState(false); const fetchStatus = React.useCallback(async () => { try { setLoading(true); const res = await fetch(`${API_BASE}/api/ai/channel/status`); if (res.ok) setChannelInfo(await res.json()); } catch { /* ignore */ } finally { setLoading(false); } }, []); React.useEffect(() => { fetchStatus(); }, [fetchStatus]); const handlePushTask = async () => { if (!taskPayload.trim()) return; setPushing(true); try { let payload: Record; try { payload = JSON.parse(taskPayload); } catch { payload = { message: taskPayload }; } await fetch(`${API_BASE}/api/ai/channel/task`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task_type: taskType, payload }), }); setTaskPayload(''); fetchStatus(); } catch { /* ignore */ } finally { setPushing(false); } }; if (loading) { return (
Command Channel loading...
); } const tier = channelInfo?.tier ?? 1; const tierLabel = 'HMAC Direct'; const tierColor = 'amber'; return (
Command Channel TIER {tier}
{/* Tier Status */}
{tierLabel} {channelInfo?.transport}
{/* Security badges */}
{channelInfo?.forward_secrecy && ( FORWARD SECRECY )} {channelInfo?.sealed_sender && ( SEALED SENDER )} BIDIRECTIONAL
{/* Queue stats */}
Pending
{channelInfo?.pending_commands ?? 0}
Completed
{channelInfo?.completed_commands ?? 0}
Tasks Queued
{channelInfo?.pending_tasks ?? 0}

Commands authenticated via HMAC-SHA256 with body-integrity binding over HTTP. Wire privacy relies on TLS. End-to-end encryption is not yet available for this channel.

{/* Push task */}
Push Task to Agent
setTaskPayload(e.target.value)} placeholder='{"message":"..."} or plain text' className="flex-1 bg-black/60 border border-emerald-800/40 text-emerald-200 text-[10px] font-mono px-2 py-1 placeholder:text-gray-600" onKeyDown={e => e.key === 'Enter' && handlePushTask()} title="Task payload" />
); } /* ─── Connect OpenClaw Modal Body ─────────────────────────────────── */ interface ConnectModalBodyProps { apiEndpoint: string; handleCopy: (text: string) => void; copied: boolean; } function ConnectModalBody({ apiEndpoint, handleCopy, copied }: ConnectModalBodyProps) { const [riskAccepted, setRiskAccepted] = React.useState(false); const [accessTier, setAccessTier] = React.useState<'restricted' | 'full'>('restricted'); const [connectionMode, setConnectionMode] = React.useState<'local' | 'remote'>('local'); // hmacSecret holds the FULL secret once the operator has clicked // Reveal (or after a regenerate). maskedHmacSecret is the safe-to-show // fingerprint returned by GET /api/ai/connect-info and is loaded on // mount. The two are independent state slots so a stale full secret // can never leak back into the UI after a regenerate. const [hmacSecret, setHmacSecret] = React.useState(''); const [maskedHmacSecret, setMaskedHmacSecret] = React.useState(''); const [hmacLoading, setHmacLoading] = React.useState(false); const [revealing, setRevealing] = React.useState(false); const [tierSaving, setTierSaving] = React.useState(false); const [showAdvanced, setShowAdvanced] = React.useState(false); const [showResetConfirm, setShowResetConfirm] = React.useState(false); const [resetting, setResetting] = React.useState(false); const [regenerating, setRegenerating] = React.useState(false); const [showSecret, setShowSecret] = React.useState(false); const [snippetCopied, setSnippetCopied] = React.useState(false); // Node state const [nodeEnabled, setNodeEnabled] = React.useState(false); const [nodeLoading, setNodeLoading] = React.useState(true); const [nodeToggling, setNodeToggling] = React.useState(false); const [nodeId, setNodeId] = React.useState(''); const [nodeConfirmed, setNodeConfirmed] = React.useState(false); const [remoteUrl, setRemoteUrl] = React.useState(''); // Tor state const [torStarting, setTorStarting] = React.useState(false); const [torError, setTorError] = React.useState(''); const [torOnion, setTorOnion] = React.useState(''); // Issue #302 (tg12): the full HMAC secret no longer travels through // GET /api/ai/connect-info on every modal open. The flow is now: // // 1. GET /api/ai/connect-info — always returns the masked fingerprint // (first6 + bullets + last4). `hmacSecret` stays empty until the // operator clicks the Reveal (eye) button below. // 2. POST /api/ai/connect-info/bootstrap — fires once on mount if the // backend reports `hmac_secret_set: false`. Idempotent and never // returns the secret in the response. // 3. POST /api/ai/connect-info/reveal — fires when the operator clicks // Reveal or Copy without the secret yet loaded. Returns the full // secret with strict `Cache-Control: no-store` so it doesn't land // in browser caches or HAR exports. React.useEffect(() => { (async () => { try { setHmacLoading(true); const res = await fetch(`${API_BASE}/api/ai/connect-info`); if (!res.ok) return; const data = await res.json(); setMaskedHmacSecret(data.masked_hmac_secret || ''); setAccessTier(data.access_tier === 'full' ? 'full' : 'restricted'); // Transparent first-use bootstrap. Mirrors the pre-#302 UX of // "open modal → secret exists" without the GET side-effect. if (!data.hmac_secret_set) { const bootRes = await fetch( `${API_BASE}/api/ai/connect-info/bootstrap`, { method: 'POST' }, ); if (bootRes.ok) { const bootData = await bootRes.json(); setMaskedHmacSecret(bootData.masked_hmac_secret || ''); } } } catch { /* ignore */ } finally { setHmacLoading(false); } })(); (async () => { try { setNodeLoading(true); const res = await fetch(`${API_BASE}/api/settings/node`); if (res.ok) { const data = await res.json(); setNodeEnabled(!!data.node_enabled || !!data.enabled); } } catch { /* ignore */ } finally { setNodeLoading(false); } })(); (async () => { try { const res = await fetch(`${API_BASE}/api/ai/agent-identity`); if (res.ok) { const data = await res.json(); if (data.bootstrapped) setNodeId(data.node_id || ''); } } catch { /* ignore */ } })(); // Fetch Tor status (async () => { try { const res = await fetch(`${API_BASE}/api/settings/tor`); if (res.ok) { const data = await res.json(); if (data.onion_address) { setTorOnion(data.onion_address); setRemoteUrl(data.onion_address); } } } catch { /* ignore */ } })(); }, []); // One-click remote setup: start node + bootstrap identity + start Tor + get address const handleRemoteSetup = async () => { setTorStarting(true); setTorError(''); try { // 1. Enable mesh node await fetch(`${API_BASE}/api/settings/node`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true }), }); setNodeEnabled(true); setNodeConfirmed(true); // 2. Bootstrap agent identity (gets node_id) const idRes = await fetch(`${API_BASE}/api/ai/agent-identity/bootstrap`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ force: false }), }); if (idRes.ok) { const idData = await idRes.json(); if (idData.node_id) setNodeId(idData.node_id); } // 3. Start Tor hidden service const torRes = await fetch(`${API_BASE}/api/settings/tor/start`, { method: 'POST' }); const torData = await torRes.json(); if (torData.ok && torData.onion_address) { setTorOnion(torData.onion_address); setRemoteUrl(torData.onion_address); } else { setTorError(torData.detail || 'Failed to start Tor'); } } catch { setTorError('Failed to connect to backend'); } finally { setTorStarting(false); } }; const handleResetAll = async () => { setResetting(true); setShowResetConfirm(false); try { const res = await fetch(`${API_BASE}/api/settings/agent/reset-all`, { method: 'POST' }); const data = await res.json(); if (data.ok) { // Update local state with new credentials. reset-all returns // the new HMAC secret in-band (same one-time-disclosure rule // as /regenerate — a deliberate destructive action). Refresh // both slots so the masked display stays in sync. if (data.new_hmac_secret) { setHmacSecret(data.new_hmac_secret); const s = String(data.new_hmac_secret); setMaskedHmacSecret( s.length > 10 ? s.slice(0, 6) + '•'.repeat(8) + s.slice(-4) : '•'.repeat(16), ); } if (data.new_onion) { setTorOnion(data.new_onion); setRemoteUrl(data.new_onion); } if (data.new_node_id) setNodeId(data.new_node_id); } } catch { /* ignore */ } finally { setResetting(false); } }; const handleTierChange = async (tier: 'restricted' | 'full') => { setAccessTier(tier); setTierSaving(true); try { await fetch(`${API_BASE}/api/ai/connect-info/access-tier`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tier }), }); } catch { /* ignore */ } finally { setTierSaving(false); } }; // Issue #302: POST /reveal returns the full secret with strict // no-store headers. Lazily fetched — never on mount. Returns the // secret string so callers can copy it immediately without waiting // for React state propagation. const revealHmacSecret = async (): Promise => { if (hmacSecret) return hmacSecret; setRevealing(true); try { const res = await fetch(`${API_BASE}/api/ai/connect-info/reveal`, { method: 'POST', }); if (!res.ok) return ''; const data = await res.json(); const secret = String(data.hmac_secret || ''); setHmacSecret(secret); return secret; } catch { return ''; } finally { setRevealing(false); } }; const handleRegenerate = async () => { setRegenerating(true); try { const res = await fetch(`${API_BASE}/api/ai/connect-info/regenerate`, { method: 'POST' }); if (res.ok) { const data = await res.json(); // Regenerate is a deliberate destructive action — operator needs // to see the new secret once to update their OpenClaw config. // Both the full and masked forms refresh in one shot. setHmacSecret(data.hmac_secret || ''); setMaskedHmacSecret(data.masked_hmac_secret || ''); setShowSecret(true); } } catch { /* ignore */ } finally { setRegenerating(false); } }; const handleNodeToggle = async (enable: boolean) => { setNodeToggling(true); try { const res = await fetch(`${API_BASE}/api/settings/node`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: enable }), }); if (res.ok) { setNodeEnabled(enable); // Auto-bootstrap agent identity when enabling node if (enable && !nodeId) { try { const idRes = await fetch(`${API_BASE}/api/ai/agent-identity/bootstrap`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ force: false }), }); if (idRes.ok) { const idData = await idRes.json(); setNodeId(idData.node_id || ''); } } catch { /* ignore */ } } } } catch { /* ignore */ } finally { setNodeToggling(false); } }; // Issue #302: prefer the server-supplied fingerprint // (maskedHmacSecret) \u2014 it's filled on mount via the (no-secret) GET. // If the operator has clicked Reveal, fall through to deriving the // mask from the in-memory full secret so we keep the same shape // (first6 + bullets + last4) regardless of source. Final fallback // (no secret loaded yet) is a generic bullet string. const maskedSecret = maskedHmacSecret || (hmacSecret ? hmacSecret.slice(0, 6) + '\u2022'.repeat(8) + hmacSecret.slice(-4) : '\u2022'.repeat(16)); // Resolve the endpoint URL const resolvedUrl = connectionMode === 'local' ? 'http://localhost:8000' : remoteUrl.trim() || ''; // Connection snippet — display is masked, copy has real key const buildSnippet = (key: string) => { const lines: string[] = []; if (resolvedUrl) lines.push(`SHADOWBROKER_URL=${resolvedUrl}`); lines.push(`SHADOWBROKER_HMAC_SECRET=${key}`); lines.push(`SHADOWBROKER_ACCESS=${accessTier}`); if (connectionMode === 'remote' && nodeId) lines.push(`SHADOWBROKER_NODE_ID=${nodeId}`); lines.push(''); lines.push('# AUTH DIRECTIVE:'); lines.push('# SHADOWBROKER_HMAC_SECRET is a shared HMAC signing secret, not a raw API key.'); lines.push('# Never send it as X-Admin-Key, Authorization: Bearer, a query parameter, or a plain request header.'); lines.push('# Every direct ShadowBroker API request must be HMAC-SHA256 signed with:'); lines.push('# X-SB-Timestamp, X-SB-Nonce, X-SB-Signature'); lines.push('# Signature input: METHOD|path|timestamp|nonce|sha256(body)'); lines.push('# Use the ShadowBrokerClient/openclaw skill helper so requests are signed automatically.'); lines.push(''); lines.push('# OPERATING DIRECTIVE:'); lines.push('# You are a remote OpenClaw agent connected to ShadowBroker.'); lines.push('# ShadowBroker is a live intelligence and telemetry platform, not a narrow single-purpose API.'); lines.push('# Treat the platform as having broad live telemetry unless a focused check proves otherwise.'); lines.push("# Do not claim a layer, entity type, or capability is unavailable until you verify it from live data."); lines.push("# If something is absent, say 'not found in current telemetry' or 'not present in the current dataset' rather than saying ShadowBroker cannot track it."); lines.push('# Full access means you MAY place pins, create layers, inject data, set watches, and trigger displays, but do not perform write actions unless the user asks or the task clearly requires it.'); lines.push('# For ordinary questions, prefer read commands and concise answers grounded in live results.'); // Tool manifest — first thing the agent should hit if (resolvedUrl) { lines.push(''); lines.push('# FIRST: Load your tools from this endpoint:'); lines.push(`# GET ${resolvedUrl}/api/ai/tools`); lines.push('# Returns structured tool definitions (names, params, types, examples).'); lines.push('# Load these as your available tool/function definitions on connect.'); lines.push('# Prefer compact lookups first: get_summary, search_telemetry, find_flights, find_ships, search_news, entities_near, get_layer_slice.'); lines.push('# Reserve get_telemetry, get_slow_telemetry, and get_report for rare full-context pulls.'); lines.push('# BATCH COMMANDS: POST /api/ai/channel/batch with {"commands": [{"cmd": "...", "args": {...}}, ...]} (max 20).'); lines.push('# Batch executes all commands concurrently in one HTTP round-trip. Use it whenever you need 2+ lookups.'); lines.push('# Example: batch entities_near + search_news + get_correlations in one call instead of 3 sequential calls.'); lines.push('# INCREMENTAL UPDATES: get_layer_slice supports since_version. Pass the version from the previous response to skip unchanged data (instant 0-byte response when nothing changed).'); lines.push("# get_summary is full layer discovery: use it to learn every live telemetry layer before concluding something is unavailable."); lines.push('# get_layer_slice is uncapped by default. Pass limit_per_layer only when you intentionally want a smaller slice.'); lines.push("# UAP sightings, wastewater, and tracked_flights/VIP aircraft are real layers when populated. Verify with get_summary/get_layer_slice before claiming they don't exist."); lines.push("# fishing_activity is the fishing-vessel activity layer. Aliases like gfw and global_fishing_watch should be treated as fishing_activity."); lines.push("# Use search_telemetry as the Google-style entry point whenever the user gives you a person, place, company, owner, nickname, or natural-language phrase and you do not already know the source layer."); lines.push("# Example: 'Where is Jerry Jones yacht?' -> search_telemetry('Jerry Jones') first, then refine with find_ships only after you identify the ship match."); lines.push("# For fuzzy natural-language lookups like 'Patriots jet' or 'Jerry Jones yacht', inspect the ranked search_telemetry candidates before making a hard claim."); lines.push("# search_telemetry returns ranked candidates grouped by entity type, so use the groups to narrow aircraft vs ships vs events before answering."); lines.push("# For AF1/AF2 or other VIP aircraft, use find_flights first when the domain is obvious, then get_layer_slice(['tracked_flights']) if you need raw layer context."); lines.push("# If one domain-specific command returns 0, do not conclude the entity is absent. Fall back to search_telemetry before any layer pull."); lines.push("# If search_telemetry returns several plausible matches, summarize the top candidates instead of pretending one uncertain hit is definitive."); lines.push("# If a user asks 'what is near here', use entities_near before pulling large datasets."); lines.push("# If a user asks about a topic or incident, use search_news before downloading the full slow feed."); } // SAR (Synthetic Aperture Radar) ground-change layer lines.push(''); lines.push('# SAR GROUND-CHANGE LAYER:'); lines.push('# ShadowBroker has a full SAR (Synthetic Aperture Radar) layer that detects ground changes through cloud cover, at night, anywhere on Earth.'); lines.push('# Two modes — both free:'); lines.push('# Mode A (Catalog): Free Sentinel-1 scene metadata from Alaska Satellite Facility. No account needed. Shows radar passes over AOIs and next-pass timing.'); lines.push('# Mode B (Anomalies): Pre-processed ground-change alerts from NASA OPERA (DISP deformation, DSWx water, DIST-ALERT vegetation), Copernicus EGMS, GFM floods, UNOSAT/EMS damage. Requires free Earthdata token.'); lines.push('# SAR commands (all routed through /api/ai/channel/command):'); lines.push('# sar_status — check Mode A/B status. ALWAYS call this first when the user asks about SAR/radar/deformation/floods. If Mode B is off, the response includes signup URLs to paste to the user.'); lines.push('# sar_anomalies_recent(kind?, limit?) — latest anomalies. Kinds: ground_deformation, surface_water_change, flood_extent, vegetation_disturbance, damage_assessment, coherence_change.'); lines.push('# sar_anomalies_near(lat, lon, radius_km?, kind?, limit?) — anomalies within radius of a point.'); lines.push('# sar_scene_search(aoi_id?, limit?) — Sentinel-1 scene catalog (Mode A, always works when AOIs exist).'); lines.push('# sar_coverage_for_aoi(aoi_id?) — per-AOI coverage and rough next-pass estimate.'); lines.push('# sar_aoi_list — list all operator-defined Areas of Interest.'); lines.push('# sar_aoi_add(id, name, center_lat, center_lon, radius_km?, category?, description?) — create/update an AOI (write command).'); lines.push('# sar_aoi_remove(aoi_id) — delete an AOI (write command).'); lines.push('# sar_pin_click(anomaly_id) — fetch the full detail payload for a specific anomaly (same data as the map popup). Returns {anomaly, aoi, recent_scenes}.'); lines.push('# sar_focus_aoi(aoi_id, zoom?) — fly the operator\'s map to an AOI center. The frontend picks this up in real time.'); lines.push('# sar_pin_from_anomaly(anomaly_id, label?) — promote a SAR anomaly to a persistent AI Intel pin on the map (write command).'); lines.push('# sar_watch_anomaly(aoi_id, kind?) — set up a watchdog that fires when matching anomalies appear in an AOI (write command).'); lines.push('# SAR rules: (1) Call sar_status first. (2) If Mode B is off, paste the help URLs — never tell the user to "search for it". (3) Anomalies have evidence_hash — preserve it when promoting to pins. (4) AOI categories: watchlist, conflict, infrastructure, natural_hazard, border, maritime.'); // Analysis zones — agent-placed map overlays with written assessments lines.push(''); lines.push('# ANALYSIS ZONES (agent-authored map notes):'); lines.push('# The old regex-based "contradiction detector" has been REMOVED. It pattern-matched denial keywords against outages and produced constant false positives.'); lines.push('# Instead, you — the agent — place colored square overlays on the map with a written assessment that the operator can read by clicking the zone.'); lines.push('# Think of these as sticky notes: "I noticed X in this area, here is what I think it means." The operator can delete any zone by clicking the trash icon in the popup.'); lines.push('# Analysis zone commands (all routed through /api/ai/channel/command):'); lines.push('# list_analysis_zones — list all currently active zones (read).'); lines.push('# place_analysis_zone(lat, lng, title, body, category?, severity?, drivers?, cell_size_deg?, ttl_hours?) — drop a new zone (write).'); lines.push('# category: contradiction | analysis | warning | observation | hypothesis (default: analysis)'); lines.push('# severity: high | medium | low (default: medium — controls fill opacity, not an alarm level)'); lines.push('# drivers: up to 5 short bullet strings shown as "KEY INDICATORS" in the popup.'); lines.push('# body: your full written assessment (up to ~2000 chars), shown verbatim in the "AGENT ASSESSMENT" section — newlines preserved.'); lines.push('# cell_size_deg: square size in degrees (default 2.0 ≈ ~220km). Use smaller (0.3-0.8) for city-scale, larger (3-5) for regional.'); lines.push('# ttl_hours: optional auto-expiry. Omit for permanent until user deletes.'); lines.push('# delete_analysis_zone(zone_id) — remove a specific zone you placed (write).'); lines.push('# clear_analysis_zones — wipe all zones (write, use sparingly).'); lines.push('# Analysis zone rules:'); lines.push('# (1) Only place zones when you have something genuinely worth noting. Do NOT spam the map.'); lines.push('# (2) Write the body as a short intelligence note in YOUR voice — what you observed, what it might mean, what you are NOT sure about. 2-6 sentences is ideal.'); lines.push('# (3) Use category="contradiction" (amber) when official statements conflict with telemetry; "warning" (red) for active threats; "observation" (blue) for neutral notes; "hypothesis" (purple) for speculative reads; "analysis" (cyan, default) for general assessments.'); lines.push('# (4) Prefer placing zones in response to operator questions or emerging events you spot while reviewing telemetry, not on a fixed schedule.'); lines.push('# (5) Zones persist across restarts. If yours become stale, clean them up with delete_analysis_zone.'); // SSE endpoint (preferred for remote — works over Tor, keeps circuit warm) if (resolvedUrl) { lines.push(''); lines.push('# Real-time push (SSE stream — works over Tor, keeps circuit warm):'); lines.push(`# GET ${resolvedUrl}/api/ai/channel/sse (keep open, receives events)`); lines.push(`# POST ${resolvedUrl}/api/ai/channel/command (send commands)`); lines.push('# Command replies are returned immediately from POST /api/ai/channel/command.'); lines.push('# Use SSE for pushed alerts/tasks. Use /api/ai/channel/poll only as a fallback if SSE is unavailable.'); lines.push('# Suggested lookup flow:'); lines.push('# 1. get_summary (discover available layers + counts)'); lines.push('# 2. Batch your focused lookups: POST /api/ai/channel/batch with multiple commands in one call'); lines.push('# e.g. {"commands": [{"cmd":"find_flights","args":{"callsign":"AF1"}}, {"cmd":"search_news","args":{"query":"military"}}]}'); lines.push('# 3. For repeat polling, use get_layer_slice with since_version to skip unchanged data'); lines.push('# 4. Only pull full telemetry (get_telemetry/get_report) if focused commands were insufficient'); lines.push('# 5. Use write commands only when the user explicitly wants an action on the map/system'); } if (connectionMode === 'remote' && resolvedUrl.includes('.onion')) { lines.push(''); lines.push('# .onion requires Tor on the agent machine too:'); lines.push('# 1. Install Tor: sudo apt install tor (or brew install tor)'); lines.push('# 2. Tor starts a SOCKS5 proxy on localhost:9050'); lines.push('# 3. Route requests through it: pip install PySocks requests[socks]'); lines.push('# proxies = {"http": "socks5h://127.0.0.1:9050", "https": "socks5h://127.0.0.1:9050"}'); lines.push('# requests.get(SHADOWBROKER_URL + "/api/health", proxies=proxies)'); } return lines.join('\n'); }; const displaySnippet = buildSnippet(maskedSecret); // Issue #302: the copy snippet needs the FULL secret. Pre-#302 we kept // it in memory from the GET-with-reveal load; now we lazy-fetch via // POST /reveal only when the operator actually clicks Copy. If they // already revealed, the in-memory value is reused (no extra request). const handleCopySnippet = async () => { const secret = hmacSecret || (await revealHmacSecret()); if (!secret) return; navigator.clipboard.writeText(buildSnippet(secret)); setSnippetCopied(true); setTimeout(() => setSnippetCopied(false), 2000); }; // Remote mode requires node confirmed + a reachable URL const remoteReady = connectionMode === 'local' || (nodeConfirmed && resolvedUrl.length > 0); return (
{/* ── Risk acceptance ──────────────────────────────── */} {!riskAccepted && (
Heads Up

Connecting an AI agent gives it access to your ShadowBroker data. You control what it can do (read-only or full access). You're responsible for what your agent does with it.

)} {/* ── Main flow ────────────────────────────────────── */} {riskAccepted && ( <> {/* Step 1: Where is your agent? */}
Step 1 — Where is your agent?
{/* Step 2 (Remote): Generate private link — one button does everything */} {connectionMode === 'remote' && (
Step 2 — Generate your private link
{torOnion && remoteReady ? ( /* Already have an address — show it */
Private link active
{torOnion}

This .onion address is persistent and stays the same across restarts.

) : ( /* Need to generate */
{!torStarting && ( )} {torStarting && (
SETTING UP SECURE CONNECTION...
)} {torError && (
{torError}
)}
)}
)} {/* Step N: Access Level */}
Step {connectionMode === 'local' ? '2' : '3'} — What can it do? {tierSaving && saving...}
{/* Step N+1: Connection Credentials */}
Step {connectionMode === 'local' ? '3' : '4'} — Copy this into your agent
{!remoteReady ? (
Start the mesh node above first
) : ( <>

{connectionMode === 'local' ? 'Paste these as environment variables or add them to your agent\u2019s config.' : 'Give these to your agent. The key is masked below \u2014 COPY sends the real key to your clipboard.'}

                    {hmacLoading ? 'Loading...' : displaySnippet}
                  
)}
{/* Done indicator */} {remoteReady && (

{connectionMode === 'local' ? 'Done. Your agent authenticates via HMAC-signed requests to localhost. Use WebSocket or SSE for persistent real-time comms.' : 'Done. Your agent registers with this node. Open GET /api/ai/channel/sse for real-time push over a single Tor circuit.'}

)} {/* ── Advanced (collapsed) ─────────────────────── */}
{showAdvanced && (
{/* HMAC Key Management */}
HMAC Key
{/* Issue #302: when the operator hasn't clicked Reveal yet, hmacSecret is empty and we fall back to maskedHmacSecret (the safe fingerprint returned by GET /api/ai/connect-info). */} {showSecret && hmacSecret ? hmacSecret : (maskedHmacSecret || maskedSecret)}

Regenerating creates a new key and immediately invalidates the old one.

{/* Node Control */}
Mesh Node
{nodeLoading ? 'Checking...' : nodeEnabled ? 'Active' : 'Inactive'}
{!nodeLoading && ( )}
{nodeId && (
Node ID: {nodeId}
)}
{/* Agent Identity */} {/* Command Channel */} {/* API Endpoint */}
API Endpoint
{apiEndpoint}
{/* Nuclear Reset */}

Generates a new HMAC key, .onion address, and node identity. Your agent will be fully disconnected and will need new credentials.

)}
{/* Reset confirmation dialog */} setShowResetConfirm(false)} /> )}
); } export default function AIIntelPanel({ onFlyTo, isMinimized: isMinimizedProp, onMinimizedChange, pinPlacementMode, onPinPlacementModeChange, }: AIIntelPanelProps) { const { t } = useTranslation(); const [internalMinimized, setInternalMinimized] = useState(true); const isMinimized = isMinimizedProp !== undefined ? isMinimizedProp : internalMinimized; const setIsMinimized = (val: boolean | ((prev: boolean) => boolean)) => { const newVal = typeof val === 'function' ? val(isMinimized) : val; setInternalMinimized(newVal); onMinimizedChange?.(newVal); }; const [error, setError] = useState(null); const [busy, setBusy] = useState(false); // Confirm dialog state const [confirmDialog, setConfirmDialog] = useState<{ title: string; message: string; confirmLabel?: string; onConfirm: () => void; } | null>(null); // Layers + pins const [layers, setLayers] = useState([]); const [pins, setPins] = useState([]); const [expandedLayers, setExpandedLayers] = useState>(new Set()); const [newLayerName, setNewLayerName] = useState(''); const [newLayerFeedUrl, setNewLayerFeedUrl] = useState(''); const [showNewLayer, setShowNewLayer] = useState(false); // Near Me const [nearMeRadius, setNearMeRadius] = useState(100); const [nearMeResults, setNearMeResults] = useState(null); // Satellite imagery search const [satLat, setSatLat] = useState(''); const [satLng, setSatLng] = useState(''); const [satScenes, setSatScenes] = useState([]); const [satSearching, setSatSearching] = useState(false); const [satLocationQuery, setSatLocationQuery] = useState(''); const [satGeocoding, setSatGeocoding] = useState(false); // Connect panel const [showConnect, setShowConnect] = useState(false); const [copied, setCopied] = useState(false); const apiEndpoint = getBackendEndpoint(); const handleCopy = useCallback((text: string) => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 2000); }, []); const totalPins = pins.length; // ── Data fetching ─────────────────────────────────────────────── const refreshData = useCallback(async () => { try { const [layerResp, pinResp] = await Promise.all([ fetch(`${API_BASE}/api/ai/layers`), fetch(`${API_BASE}/api/ai/pins?limit=500`), ]); if (layerResp.ok) { const ld = await layerResp.json(); setLayers(ld.layers || []); } if (pinResp.ok) { const pd = await pinResp.json(); setPins(pd.pins || []); } setError(null); } catch (err) { setError(err instanceof Error ? err.message : 'AI Intel unavailable'); } }, []); useEffect(() => { void refreshData(); const tid = setInterval(refreshData, 30_000); return () => clearInterval(tid); }, [refreshData]); // ── Layer actions ─────────────────────────────────────────────── const handleCreateLayer = async () => { const name = newLayerName.trim(); if (!name) return; setBusy(true); try { const feedUrl = newLayerFeedUrl.trim(); await apiCreateLayer({ name, source: feedUrl ? 'feed' : 'user', ...(feedUrl ? { feed_url: feedUrl } : {}), }); setNewLayerName(''); setNewLayerFeedUrl(''); setShowNewLayer(false); await refreshData(); } catch {} setBusy(false); }; const handleToggleLayerVisibility = async (layerId: string, currentlyVisible: boolean) => { try { await apiUpdateLayer(layerId, { visible: !currentlyVisible }); await refreshData(); } catch {} }; const handleDeleteLayer = (layerId: string) => { const layer = layers.find((l) => l.id === layerId); const layerPinCount = pins.filter((p) => p.layer_id === layerId).length; const name = layer?.name || 'this layer'; const msg = layerPinCount > 0 ? `Delete "${name}" and all ${layerPinCount} pin${layerPinCount === 1 ? '' : 's'} in it?\n\nThis cannot be undone.` : `Delete layer "${name}"?`; setConfirmDialog({ title: 'DELETE LAYER', message: msg, confirmLabel: 'DELETE', onConfirm: async () => { setConfirmDialog(null); setBusy(true); try { await apiDeleteLayer(layerId); await refreshData(); } catch {} setBusy(false); }, }); }; const handleRefreshFeed = async (layerId: string) => { setBusy(true); try { await apiRefreshLayerFeed(layerId); await refreshData(); } catch {} setBusy(false); }; const toggleLayerExpanded = (layerId: string) => { setExpandedLayers(prev => { const next = new Set(prev); if (next.has(layerId)) next.delete(layerId); else next.add(layerId); return next; }); }; // ── Pin actions ───────────────────────────────────────────────── const deletePin = (pinId: string) => { const target = pins.find((p) => p.id === pinId); const label = target?.label || 'this pin'; setConfirmDialog({ title: 'DELETE PIN', message: `Delete pin "${label}"?\n\nThis cannot be undone.`, confirmLabel: 'DELETE', onConfirm: async () => { setConfirmDialog(null); try { await fetch(`${API_BASE}/api/ai/pins/${pinId}`, { method: 'DELETE' }); await refreshData(); } catch {} }, }); }; // ── Near Me ────────────────────────────────────────────────────── const fetchNearMe = async () => { if (!navigator.geolocation) { setError('Geolocation not available'); return; } setBusy(true); try { const pos = await new Promise((resolve, reject) => navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000 }), ); const { latitude: lat, longitude: lng } = pos.coords; const resp = await fetch( `${API_BASE}/api/ai/news-near?lat=${lat}&lng=${lng}&radius=${nearMeRadius}`, ); if (!resp.ok) throw new Error(`${resp.status}`); setNearMeResults(await resp.json()); } catch (err) { setError(err instanceof Error ? err.message : 'Near Me failed'); } setBusy(false); }; // ── Satellite imagery search ───────────────────────────────────── const handleLocationLookup = async () => { const q = satLocationQuery.trim(); if (!q) return; setSatGeocoding(true); try { const resp = await fetch(`${API_BASE}/api/geocode/search?q=${encodeURIComponent(q)}&limit=1`); if (!resp.ok) throw new Error(`${resp.status}`); const data = await resp.json(); const first = data.results?.[0]; if (first && typeof first.lat === 'number' && typeof first.lng === 'number') { setSatLat(first.lat.toFixed(5)); setSatLng(first.lng.toFixed(5)); // Auto-search imagery at the resolved location setSatSearching(true); setSatScenes([]); try { const imgs = await fetchSatelliteImages(first.lat, first.lng, 3); setSatScenes(imgs.scenes || []); if (!imgs.scenes?.length) setError('No scenes found for this location'); } catch (err) { setError(err instanceof Error ? err.message : 'Satellite search failed'); } setSatSearching(false); } else { setError(`Location "${q}" not found`); } } catch (err) { setError(err instanceof Error ? err.message : 'Geocoding failed'); } setSatGeocoding(false); }; const handleSatSearch = async () => { const lat = parseFloat(satLat); const lng = parseFloat(satLng); if (isNaN(lat) || isNaN(lng)) { setError('Enter valid lat/lng coordinates'); return; } setSatSearching(true); setSatScenes([]); setError(null); try { const resp = await fetchSatelliteImages(lat, lng, 3); setSatScenes(resp.scenes || []); if (!resp.scenes?.length) setError('No scenes found for this location'); } catch (err) { setError(err instanceof Error ? err.message : 'Satellite search failed'); } setSatSearching(false); }; // ── Render ─────────────────────────────────────────────────────── return (
{/* Header */}
setIsMinimized(!isMinimized)} className="flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-violet-950/40 transition-colors border-b border-violet-500/30 bg-violet-950/20" >
{t('ai.title').toUpperCase()} {totalPins > 0 && ( {totalPins} )} {error && ( OFFLINE )}
{isMinimized ? ( ) : ( )}
{!isMinimized && (
{/* ── Connect OpenClaw Button ──────────────────────── */} {/* ── Pin Placement Button ─────────────────────────── */} {/* ── Pin Layers ──────────────────────────────────── */}
PIN LAYERS ({layers.length})
{/* New layer form */} {showNewLayer && (
setNewLayerName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleCreateLayer()} placeholder="Layer name..." autoFocus className="flex-1 px-2 py-1.5 text-[12px] font-mono bg-[var(--bg-primary)] border border-violet-500/30 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-violet-500/50 outline-none" />
setNewLayerFeedUrl(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleCreateLayer()} placeholder="Feed URL (optional GeoJSON/JSON)..." className="flex-1 px-2 py-1 text-[11px] font-mono bg-[var(--bg-primary)] border border-emerald-500/20 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-emerald-500/40 outline-none" />
)} {/* Layer list */} {layers.length === 0 && !showNewLayer && (
No layers yet. Create one or let OpenClaw add them.
)}
{layers.map((layer) => { const isExpanded = expandedLayers.has(layer.id); const layerPins = pins.filter((p) => p.layer_id === layer.id); return (
{/* Layer header */}
{/* Expand/collapse */} {/* Color dot */}
{/* Name + count */} {layer.pin_count} {/* Source badge */} {layer.source === 'openclaw' && ( AI )} {/* Feed badge + refresh */} {layer.feed_url && ( <> FEED )} {/* Visibility toggle */} {/* Delete */}
{/* Expanded: show pins */} {isExpanded && layerPins.length > 0 && (
{layerPins.slice(0, 30).map((pin) => (
onFlyTo?.(pin.lat, pin.lng)} >
{pin.label} {pin.entity_attachment && ( TRACKING )} {pin.source === 'openclaw' && ( AI )}
))} {layerPins.length > 30 && (
+ {layerPins.length - 30} more
)}
)} {isExpanded && layerPins.length === 0 && (
No pins in this layer
)}
); })}
{/* Ungrouped pins (no layer_id) */} {pins.filter(p => !p.layer_id).length > 0 && (
UNGROUPED ({pins.filter(p => !p.layer_id).length})
{pins.filter(p => !p.layer_id).slice(0, 20).map((pin) => (
onFlyTo?.(pin.lat, pin.lng)} >
{pin.label}
))}
)}
{/* ── Near Me ─────────────────────────────────────── */}
NEAR ME
{[50, 100, 500, 1000].map((r) => ( ))}
{nearMeResults && (
{(nearMeResults.gdelt || []).slice(0, 3).map((g: any, i: number) => (
{g.name} ({g.count} events) -- {g.distance_miles}mi
))} {(nearMeResults.news || []).slice(0, 3).map((n: any, i: number) => (
{n.title?.slice(0, 60)} -- {n.distance_miles}mi
))} {!nearMeResults.gdelt?.length && !nearMeResults.news?.length && (
All clear -- nothing notable within {nearMeRadius}mi
)}
)}
{/* ── Satellite Imagery ──────────────────────────── */}
SATELLITE IMAGERY
{/* Location lookup (place name) */}
setSatLocationQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleLocationLookup()} placeholder="Search location (e.g. Tehran, Kyiv)..." className="flex-1 px-2 py-1.5 text-[11px] font-mono bg-[var(--bg-primary)] border border-sky-500/20 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-sky-500/50 outline-none" />
— or enter coordinates —
setSatLat(e.target.value)} placeholder="Lat" className="flex-1 px-2 py-1.5 text-[11px] font-mono bg-[var(--bg-primary)] border border-sky-500/20 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-sky-500/50 outline-none" /> setSatLng(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSatSearch()} placeholder="Lng" className="flex-1 px-2 py-1.5 text-[11px] font-mono bg-[var(--bg-primary)] border border-sky-500/20 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-sky-500/50 outline-none" />
{/* Error message (inline) */} {error && !satSearching && satScenes.length === 0 && (
{error}
)} {/* Results */} {satScenes.length > 0 && (
{satScenes.map((scene) => (
{/* Thumbnail */} {scene.thumbnail_url && ( {scene.scene_id} )} {/* Info bar */}
{scene.platform} — {scene.datetime ? new Date(scene.datetime).toLocaleDateString() : 'N/A'}
Cloud: {scene.cloud_cover != null ? `${Math.round(scene.cloud_cover)}%` : 'N/A'}
{scene.bbox && scene.bbox.length >= 4 && ( )} {scene.fullres_url && ( FULL RES )}
))}
)}
{/* ── Refresh ─────────────────────────────────────── */}
)} {/* ── Connect OpenClaw Modal (Portal) ──────────────────── */} {showConnect && ReactDOM.createPortal(
setShowConnect(false)} >
e.stopPropagation()} >
Connect OpenClaw Agent
, document.body, )} {/* In-app confirmation dialog */} confirmDialog?.onConfirm()} onCancel={() => setConfirmDialog(null)} />
); }