Let agents temporarily override native map layer visibility.

This commit is contained in:
Ethan Morchy
2026-08-08 02:04:20 -07:00
parent 8676a98945
commit 3ef45508b2
4 changed files with 168 additions and 8 deletions
+44
View File
@@ -381,6 +381,50 @@ async def api_refresh_layer_feed(request: Request, layer_id: str):
return {"ok": True, "layer": updated}
# ---------------------------------------------------------------------------
# Native map layer overrides — additive, transient, agent-driven.
#
# These are the DATA LAYERS toggles, not the pin layers above. An override
# switches an overlay on without touching the operator's own toggle state, and
# lapses on its own after the TTL. Agents holding an overlay open should re-PUT
# on their refresh tick rather than sending a long TTL.
# ---------------------------------------------------------------------------
class LayerOverrideUpdate(BaseModel):
layers: dict[str, bool]
ttl_seconds: float = 300.0
@router.put("/api/ai/layer-overrides", dependencies=[Depends(require_openclaw_or_local)])
@limiter.limit("30/minute")
async def put_layer_overrides(request: Request, body: LayerOverrideUpdate):
"""Replace the override map. Returns which keys were accepted and ignored."""
from services.fetchers._store import set_layer_overrides
accepted = set_layer_overrides(body.layers, body.ttl_seconds)
ignored = sorted(set(body.layers) - set(accepted))
return {"ok": True, "overrides": accepted, "ignored": ignored}
@router.get("/api/ai/layer-overrides", dependencies=[Depends(require_openclaw_or_local)])
@limiter.limit("60/minute")
async def read_layer_overrides(request: Request):
"""Return the overrides that are currently live."""
from services.fetchers._store import get_layer_overrides
return {"ok": True, "overrides": get_layer_overrides()}
@router.delete("/api/ai/layer-overrides", dependencies=[Depends(require_openclaw_or_local)])
@limiter.limit("30/minute")
async def delete_layer_overrides(request: Request):
"""Drop all overrides, restoring the operator's own layer state."""
from services.fetchers._store import clear_layer_overrides
clear_layer_overrides()
return {"ok": True}
# ---------------------------------------------------------------------------
# Agent Actions endpoint — frontend polls this for UI commands from the agent
# ---------------------------------------------------------------------------
+29 -4
View File
@@ -566,6 +566,18 @@ def _run_prediction_markets_disable() -> None:
logger.warning("Prediction markets disable cleanup failed: %s", e)
@router.get("/api/layers", dependencies=[Depends(require_local_operator)])
async def get_layers(request: Request):
"""Report operator layer state plus any live overrides.
The UI polls this so the DATA LAYERS toggles can show an overlay that an
agent switched on. ``layers`` is the operator's own state and is what the
browser persists; ``overrides`` is transient and must not be saved.
"""
from services.fetchers._store import active_layers, get_layer_overrides
return {"layers": dict(active_layers), "overrides": get_layer_overrides()}
@router.post("/api/layers", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def update_layers(update: LayerUpdate, request: Request):
@@ -668,11 +680,15 @@ async def bootstrap_critical(request: Request):
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
from services.fetchers._store import (
active_layers,
effective_layers,
get_latest_data_subset_refs,
get_source_timestamps_snapshot,
)
# Rebind the local name to the override-merged map. Every layer filter
# below reads this name, so one merge here covers all of them.
active_layers = effective_layers()
def _build() -> dict:
d = get_latest_data_subset_refs(
"last_updated", "commercial_flights", "military_flights", "private_flights",
@@ -769,14 +785,17 @@ def _try_build_fast_delta(
) -> dict | None:
"""Return a delta payload, or None to fall back to a full snapshot."""
from services.fetchers._store import (
active_layers,
compute_layer_row_delta,
effective_layers,
get_data_version,
get_layer_versions,
get_latest_data_subset_refs,
get_source_timestamps_snapshot,
)
# Rebind the local name to the override-merged map (see bootstrap_critical).
active_layers = effective_layers()
server_lv = get_layer_versions()
deltas: dict[str, Any] = {}
for key in _DELTA_FAST_KEYS:
@@ -894,7 +913,10 @@ async def live_data_fast(
etag = _current_etag(prefix=("fast|initial|" if initial else "fast|full|") + bbox_suffix.lstrip("|") + ("|" if bbox_suffix else ""))
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
from services.fetchers._store import (active_layers, get_latest_data_subset_refs, get_source_timestamps_snapshot)
from services.fetchers._store import (effective_layers, get_latest_data_subset_refs, get_source_timestamps_snapshot)
# Rebind the local name to the override-merged map (see bootstrap_critical).
active_layers = effective_layers()
def _build() -> dict:
d = get_latest_data_subset_refs(
@@ -959,7 +981,10 @@ async def live_data_slow(
etag = _current_etag(prefix="slow|full|" + bbox_suffix.lstrip("|") + ("|" if bbox_suffix else ""))
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
from services.fetchers._store import (active_layers, get_latest_data_subset_refs, get_source_timestamps_snapshot)
from services.fetchers._store import (effective_layers, get_latest_data_subset_refs, get_source_timestamps_snapshot)
# Rebind the local name to the override-merged map (see bootstrap_critical).
active_layers = effective_layers()
def _build() -> dict:
d = get_latest_data_subset_refs(
+65 -1
View File
@@ -7,6 +7,7 @@ Every fetcher imports from here instead of maintaining its own copy.
import copy
import threading
import logging
import time
from datetime import datetime
from typing import Any, Dict, List, Optional, TypedDict
@@ -505,6 +506,69 @@ active_layers: dict[str, bool] = {
}
# ---------------------------------------------------------------------------
# Layer overrides — additive, agent-driven, never persisted.
# An automation (e.g. a hotspot daemon) can switch an overlay on for a while
# without touching the operator's own toggles. Overrides merge ON TOP of
# active_layers for reads; active_layers itself is only ever written by the
# operator via POST /api/layers. The whole map shares one expiry, evaluated
# lazily on read so no background task is needed. When it lapses the operator's
# own view returns with no save/restore bookkeeping.
# ---------------------------------------------------------------------------
_MAX_OVERRIDE_TTL_S = 3600.0
layer_overrides: dict[str, bool] = {}
_layer_overrides_expires_at: float = 0.0
def get_layer_overrides() -> dict[str, bool]:
"""Return the live overrides, or {} once the TTL has lapsed."""
global _layer_overrides_expires_at
if not layer_overrides:
return {}
if time.monotonic() >= _layer_overrides_expires_at:
layer_overrides.clear()
_layer_overrides_expires_at = 0.0
bump_active_layers_version()
return {}
return dict(layer_overrides)
def set_layer_overrides(overrides: dict[str, bool], ttl_seconds: float) -> dict[str, bool]:
"""Replace the override map, returning the entries that were accepted.
Keys that are not real layers are dropped so a typo cannot silently do
nothing the caller compares the return value against what it sent.
"""
global _layer_overrides_expires_at
ttl = max(0.0, min(float(ttl_seconds), _MAX_OVERRIDE_TTL_S))
accepted = {k: bool(v) for k, v in overrides.items() if k in active_layers}
layer_overrides.clear()
layer_overrides.update(accepted)
_layer_overrides_expires_at = time.monotonic() + ttl if accepted else 0.0
bump_active_layers_version()
return accepted
def clear_layer_overrides() -> None:
"""Drop all overrides, restoring the operator's own layer state."""
global _layer_overrides_expires_at
if layer_overrides:
layer_overrides.clear()
_layer_overrides_expires_at = 0.0
bump_active_layers_version()
def effective_layers() -> dict[str, bool]:
"""Operator layer state merged with any live overrides."""
overrides = get_layer_overrides()
return {**active_layers, **overrides} if overrides else dict(active_layers)
def is_any_active(*layer_names: str) -> bool:
"""Return True if any of the given layer names is currently active."""
return any(active_layers.get(name, True) for name in layer_names)
overrides = get_layer_overrides()
return any(
overrides.get(name, active_layers.get(name, True))
for name in layer_names
)
+30 -3
View File
@@ -191,6 +191,9 @@ export default function Dashboard() {
}, []);
const [activeLayers, setActiveLayers] = useState<ActiveLayers>(getDefaultActiveLayers);
// Backend-driven layer overrides. Additive on top of activeLayers, never
// persisted and never pushed back — the operator's own toggles stay theirs.
const [layerOverrides, setLayerOverrides] = useState<Partial<ActiveLayers>>({});
const [activeStyle, setActiveStyle] = useState<MapStyle>(getDefaultMapStyle);
const [activeFilters, setActiveFilters] = useState<Record<string, string[]>>({});
const [layerPrefsHydrated, setLayerPrefsHydrated] = useState(false);
@@ -323,6 +326,27 @@ export default function Dashboard() {
};
}, [activeLayers, secondaryBootReady, layerPrefsHydrated]);
// Poll for backend layer overrides so an agent can switch an overlay on
// without the operator reloading. Overrides carry a TTL server-side, so a
// missed poll self-corrects and a dropped backend just lets them lapse.
useEffect(() => {
if (!secondaryBootReady) return;
let cancelled = false;
const pollOverrides = () =>
fetch(`${API_BASE}/api/layers`)
.then((r) => r.json())
.then((d) => {
if (!cancelled) setLayerOverrides(d?.overrides ?? {});
})
.catch(() => {});
void pollOverrides();
const id = setInterval(pollOverrides, 5000);
return () => {
cancelled = true;
clearInterval(id);
};
}, [secondaryBootReady]);
// Left panel accordion state
const [leftDataMinimized, setLeftDataMinimized] = useState(false);
const [leftMeshExpanded, setLeftMeshExpanded] = useState(true);
@@ -457,8 +481,10 @@ export default function Dashboard() {
});
};
// Overrides merge last so they win over both the operator's toggles and the
// first-paint suppressions below.
const firstPaintActiveLayers = useMemo<ActiveLayers>(() => {
if (secondaryBootReady) return activeLayers;
if (secondaryBootReady) return { ...activeLayers, ...layerOverrides };
return {
...activeLayers,
cctv: false,
@@ -472,8 +498,9 @@ export default function Dashboard() {
tinygs: false,
datacenters: false,
power_plants: false,
...layerOverrides,
};
}, [activeLayers, secondaryBootReady]);
}, [activeLayers, layerOverrides, secondaryBootReady]);
// Agent fly_to handler (sar_focus_aoi etc.) — wired here now that
// setFlyToLocation is in scope. show_image is routed through
// useAgentActions at the top of Dashboard.
@@ -587,7 +614,7 @@ export default function Dashboard() {
{secondaryBootReady ? (
<ErrorBoundary name="WorldviewLeftPanel">
<WorldviewLeftPanel
activeLayers={activeLayers}
activeLayers={firstPaintActiveLayers}
setActiveLayers={setActiveLayers}
onResetLayers={resetActiveLayers}
shodanResultCount={shodanResults.length}