feat: extend agent-driven map workbench
This commit is contained in:
@@ -1,7 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps, type CSSProperties } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type PointerEvent as ReactPointerEvent
|
||||
} from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import {
|
||||
@@ -28,6 +38,7 @@ import {
|
||||
} from "@/features/map/core/components/map-control-styles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AgentTaskTicker } from "./components/agent-task-ticker";
|
||||
import { MapDevPanel } from "./components/map-dev-panel";
|
||||
import { FeaturePopover } from "./components/feature-popover";
|
||||
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
||||
import { ToolbarPanel, type ExportViewPreset, type ToolbarPanelProps, type ToolbarToolId } from "./components/toolbar-panel";
|
||||
@@ -40,11 +51,19 @@ import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
|
||||
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
|
||||
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
|
||||
import { useWorkbenchMap } from "./hooks/use-workbench-map";
|
||||
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
|
||||
import { toMapFeatureReference } from "./hooks/use-map-interactions";
|
||||
import { useWorkbenchMeasurement, type WorkbenchMeasureMode, type WorkbenchMeasureUnit } from "./hooks/use-workbench-measurement";
|
||||
import { getWorkbenchBasemapTone, getWorkbenchLayoutCssVariables } from "./layout/workbench-layout";
|
||||
import {
|
||||
WORKBENCH_LAYOUT,
|
||||
clampAgentPanelWidth,
|
||||
getWorkbenchBasemapTone,
|
||||
getWorkbenchLayoutCssVariables,
|
||||
getWorkbenchViewportLayout
|
||||
} from "./layout/workbench-layout";
|
||||
import { getResponsiveWorkbenchPadding } from "./map/camera";
|
||||
import { exportMapViewImage } from "./map/export-view";
|
||||
import { parseScadaAnalysisItems } from "./map/scada-analysis";
|
||||
import {
|
||||
BASE_LAYER_IDS,
|
||||
BASE_LAYER_OPTIONS,
|
||||
@@ -86,10 +105,18 @@ function getMsUntilNextHeaderDataTimeTick(date: Date) {
|
||||
|
||||
export function MapWorkbenchPage() {
|
||||
const hasMapboxToken = Boolean(process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN);
|
||||
const devPanelEnabled = process.env.NEXT_PUBLIC_ENABLE_DEV_PANEL === "true";
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const desktopAgentPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const agentPanelResizeLeftRef = useRef(0);
|
||||
const agentPanelResizeCleanupRef = useRef<(() => void) | null>(null);
|
||||
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
|
||||
const [devPanelOpen, setDevPanelOpen] = useState(false);
|
||||
const [impactVisible, setImpactVisible] = useState(false);
|
||||
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
||||
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
|
||||
const [agentPanelWidth, setAgentPanelWidth] = useState<number | null>(null);
|
||||
const [agentPanelResizing, setAgentPanelResizing] = useState(false);
|
||||
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
|
||||
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
|
||||
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
|
||||
@@ -126,15 +153,27 @@ export function MapWorkbenchPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia("(min-width: 1024px)");
|
||||
const handleChange = () => setIsLargeScreen(mediaQuery.matches);
|
||||
const handleChange = () => {
|
||||
setIsLargeScreen(mediaQuery.matches);
|
||||
setViewportWidth(window.innerWidth);
|
||||
setAgentPanelWidth((currentWidth) =>
|
||||
currentWidth === null ? null : clampAgentPanelWidth(currentWidth, window.innerWidth)
|
||||
);
|
||||
};
|
||||
|
||||
handleChange();
|
||||
setConditionFeedVisible(window.innerWidth >= 1280);
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
window.addEventListener("resize", handleChange);
|
||||
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handleChange);
|
||||
window.removeEventListener("resize", handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => agentPanelResizeCleanupRef.current?.(), []);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: number;
|
||||
|
||||
@@ -182,7 +221,7 @@ export function MapWorkbenchPage() {
|
||||
onFrontendAction: handleFrontendAction
|
||||
});
|
||||
const leftPanelOpen = isLargeScreen && agent.panelOpen;
|
||||
const rightPanelOpen = isLargeScreen && shouldShowConditionFeed;
|
||||
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
|
||||
|
||||
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
|
||||
containerRef: mapContainerRef,
|
||||
@@ -192,6 +231,12 @@ export function MapWorkbenchPage() {
|
||||
onSelectFeature: handleSelectFeature,
|
||||
selectedFeature: detailFeature
|
||||
});
|
||||
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
||||
mapRef,
|
||||
mapReady,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen
|
||||
});
|
||||
|
||||
const activeAgentUiResults = useMemo(
|
||||
() => agentUiResults.filter((result) => result.sessionId === agent.sessionId),
|
||||
@@ -524,36 +569,50 @@ export function MapWorkbenchPage() {
|
||||
}
|
||||
|
||||
async function handleFrontendAction(request: FrontendActionRequest, signal: AbortSignal) {
|
||||
const map = mapRef.current;
|
||||
if (request.name === "zoom_to_map") {
|
||||
if (!map || !mapReady) throw new Error("MAP_NOT_READY");
|
||||
const trustedAction = toTrustedMapAction("zoom_to_map", request.params);
|
||||
if (!trustedAction || trustedAction.type !== "zoom_to_map") throw new Error("INVALID_ZOOM_TO_MAP");
|
||||
const zoom = trustedAction.zoom ?? 18;
|
||||
const duration = trustedAction.durationMs ?? 500;
|
||||
map.easeTo({
|
||||
center: trustedAction.center,
|
||||
zoom,
|
||||
padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen),
|
||||
duration
|
||||
if (request.name === "render_scada_analysis") {
|
||||
const items = parseScadaAnalysisItems(request.params.items);
|
||||
if (!items) throw new Error("INVALID_SCADA_ANALYSIS");
|
||||
const result = await mapController.renderScadaAnalysis(items, signal);
|
||||
const counts = result.level_counts;
|
||||
showMapNotice({
|
||||
tone: "success",
|
||||
title: "Agent SCADA 分析已渲染",
|
||||
message: `已渲染 ${result.rendered_ids.length} 个,高 ${counts.high}、中 ${counts.medium}、低 ${counts.low}${result.missing_ids.length ? `,缺失 ${result.missing_ids.length} 个` : ""}。`
|
||||
});
|
||||
return { center: trustedAction.center, zoom, duration };
|
||||
return result;
|
||||
}
|
||||
if (request.name === "locate_features") {
|
||||
if (!map || !mapReady) throw new Error("MAP_NOT_READY"); const ids = request.params.ids as string[]; const featureType = String(request.params.feature_type);
|
||||
const response = await fetch("/api/agent-locate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids, featureType }), signal }); const geojson = await response.json(); if (!response.ok) throw new Error(geojson.code ?? "WFS_FAILED");
|
||||
const features = geojson.features as Array<{ id?: string | number; properties?: Record<string, unknown>; geometry: { coordinates: unknown } }>; if (!features.length) throw new Error("FEATURE_NOT_FOUND");
|
||||
if (map.getSource("agent-locate")) (map.getSource("agent-locate") as unknown as { setData: (data: unknown) => void }).setData(geojson); else { map.addSource("agent-locate", { type: "geojson", data: geojson }); map.addLayer({ id: "agent-locate-lines", type: "line", source: "agent-locate", filter: ["==", ["geometry-type"], "LineString"], paint: { "line-color": "#f97316", "line-width": 6 } }); map.addLayer({ id: "agent-locate-points", type: "circle", source: "agent-locate", filter: ["==", ["geometry-type"], "Point"], paint: { "circle-color": "#f97316", "circle-radius": 8, "circle-stroke-color": "#fff", "circle-stroke-width": 2 } }); }
|
||||
const coordinates: number[][] = []; const collect = (value: unknown) => { if (Array.isArray(value) && value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") coordinates.push(value as number[]); else if (Array.isArray(value)) value.forEach(collect); }; features.forEach((feature) => collect(feature.geometry.coordinates)); const bounds: [number, number, number, number] = [Math.min(...coordinates.map((c) => c[0])), Math.min(...coordinates.map((c) => c[1])), Math.max(...coordinates.map((c) => c[0])), Math.max(...coordinates.map((c) => c[1]))]; map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen), maxZoom: 18 }); const locatedIds = features.map((feature) => String(feature.id ?? feature.properties?.id ?? "")).filter(Boolean); return { locatedIds, missingIds: ids.filter((id) => !locatedIds.includes(id)), featureType, bounds };
|
||||
}
|
||||
if (request.name === "view_history") {
|
||||
const itemCount = (request.params.feature_infos as unknown[]).length;
|
||||
setAgentUiResults((current) => [...current.slice(-5), { id: request.actionId, sessionId: request.sessionId, createdAt: Date.now(), envelope: { kind: "registered_component", schemaVersion: "agent-ui@1", component: "HistoryPanel", surface: "side_panel", props: request.params } }]);
|
||||
return { component: "HistoryPanel", rendered: true, itemCount };
|
||||
if (request.name === "clear_scada_analysis") {
|
||||
const result = mapController.clearScadaAnalysis();
|
||||
showMapNotice({
|
||||
tone: "info",
|
||||
title: "Agent SCADA 分析已清除",
|
||||
message: "地图已移除当前会话的 SCADA 分析结果。"
|
||||
});
|
||||
return result;
|
||||
}
|
||||
throw new Error("UNSUPPORTED_FRONTEND_ACTION");
|
||||
}
|
||||
|
||||
async function handleStartNewAgentSession() {
|
||||
mapController.clearScadaAnalysis();
|
||||
await agent.startNewSession();
|
||||
}
|
||||
|
||||
async function handleLoadAgentHistorySession(sessionId: string) {
|
||||
if (sessionId !== agent.sessionId) {
|
||||
mapController.clearScadaAnalysis();
|
||||
}
|
||||
await agent.loadHistorySession(sessionId);
|
||||
}
|
||||
|
||||
async function handleDeleteAgentHistorySession(sessionId: string) {
|
||||
const clearsActiveAnalysis = sessionId === agent.sessionId;
|
||||
const deleted = await agent.deleteHistorySession(sessionId);
|
||||
if (deleted && clearsActiveAnalysis) {
|
||||
mapController.clearScadaAnalysis();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAgentMapAction(action: string, params: unknown, sessionId: string, fallbackText?: string) {
|
||||
const trustedAction = toTrustedMapAction(action, params, fallbackText);
|
||||
if (!trustedAction) {
|
||||
@@ -701,13 +760,15 @@ export function MapWorkbenchPage() {
|
||||
streamRenderState: agent.streamRenderState,
|
||||
modelOptions: agent.modelOptions,
|
||||
selectedModel: agent.selectedModel,
|
||||
approvalMode: agent.approvalMode,
|
||||
uiResults: activeAgentUiResults,
|
||||
onRefreshHistory: agent.refreshSessionHistory,
|
||||
onStartNewSession: agent.startNewSession,
|
||||
onLoadHistorySession: agent.loadHistorySession,
|
||||
onStartNewSession: handleStartNewAgentSession,
|
||||
onLoadHistorySession: handleLoadAgentHistorySession,
|
||||
onRenameHistorySession: agent.renameHistorySession,
|
||||
onDeleteHistorySession: agent.deleteHistorySession,
|
||||
onDeleteHistorySession: handleDeleteAgentHistorySession,
|
||||
onSelectModel: agent.setSelectedModel,
|
||||
onApprovalModeChange: agent.setApprovalMode,
|
||||
onSubmitPrompt: agent.submitPrompt,
|
||||
onStopPrompt: agent.stopPrompt,
|
||||
onReplyPermission: agent.replyPermission,
|
||||
@@ -715,6 +776,62 @@ export function MapWorkbenchPage() {
|
||||
onRejectQuestion: agent.rejectQuestion
|
||||
};
|
||||
|
||||
const resizeAgentPanel = useCallback((clientX: number) => {
|
||||
setAgentPanelWidth(clampAgentPanelWidth(clientX - agentPanelResizeLeftRef.current, window.innerWidth));
|
||||
}, []);
|
||||
|
||||
const handleAgentPanelResizeStart = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0 || !desktopAgentPanelRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
agentPanelResizeLeftRef.current = desktopAgentPanelRef.current.getBoundingClientRect().left;
|
||||
setAgentPanelResizing(true);
|
||||
resizeAgentPanel(event.clientX);
|
||||
|
||||
agentPanelResizeCleanupRef.current?.();
|
||||
const handlePointerMove = (pointerEvent: PointerEvent) => {
|
||||
pointerEvent.preventDefault();
|
||||
resizeAgentPanel(pointerEvent.clientX);
|
||||
};
|
||||
const stopResizing = () => {
|
||||
agentPanelResizeCleanupRef.current?.();
|
||||
setAgentPanelResizing(false);
|
||||
};
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", stopResizing);
|
||||
window.removeEventListener("pointercancel", stopResizing);
|
||||
agentPanelResizeCleanupRef.current = null;
|
||||
};
|
||||
|
||||
agentPanelResizeCleanupRef.current = cleanup;
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", stopResizing);
|
||||
window.addEventListener("pointercancel", stopResizing);
|
||||
}, [resizeAgentPanel]);
|
||||
|
||||
const handleAgentPanelResizeKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
const currentWidth = desktopAgentPanelRef.current?.getBoundingClientRect().width ?? WORKBENCH_LAYOUT.desktop.agentWidth;
|
||||
let nextWidth = currentWidth;
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
nextWidth -= 16;
|
||||
} else if (event.key === "ArrowRight") {
|
||||
nextWidth += 16;
|
||||
} else if (event.key === "Home") {
|
||||
nextWidth = getWorkbenchViewportLayout(window.innerWidth).agentWidth;
|
||||
} else if (event.key === "End") {
|
||||
nextWidth = window.innerWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setAgentPanelWidth(clampAgentPanelWidth(nextWidth, window.innerWidth));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main
|
||||
className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900 tabular-nums"
|
||||
@@ -738,6 +855,8 @@ export function MapWorkbenchPage() {
|
||||
conditionFeedVisible={shouldShowConditionFeed}
|
||||
taskTickerAvailable={taskTickerAvailable}
|
||||
taskTickerVisible={taskTickerVisible}
|
||||
devPanelEnabled={devPanelEnabled}
|
||||
devPanelOpen={devPanelOpen}
|
||||
onSelectScenario={handleSelectScenario}
|
||||
onSelectAlert={handleSelectAlert}
|
||||
onToggleConditionFeed={() => {
|
||||
@@ -745,6 +864,7 @@ export function MapWorkbenchPage() {
|
||||
setActiveToolId(null);
|
||||
}}
|
||||
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
|
||||
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
|
||||
onPreviewScenario={handlePreviewScenario}
|
||||
onCompareScenario={handleCompareScenario}
|
||||
onExportScenarioReport={handleExportScenarioReport}
|
||||
@@ -755,13 +875,37 @@ export function MapWorkbenchPage() {
|
||||
onExportConfig={handleExportConfig}
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] 2xl:w-[var(--workbench-agent-width-wide)] lg:block">
|
||||
<div
|
||||
ref={desktopAgentPanelRef}
|
||||
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] max-w-[50vw] 2xl:w-[var(--workbench-agent-width-wide)] lg:block"
|
||||
style={agentPanelWidth === null ? undefined : { width: agentPanelWidth }}
|
||||
>
|
||||
{agent.panelOpen ? (
|
||||
<AgentCommandPanel
|
||||
{...agentPanelProps}
|
||||
collapsing={agent.panelCollapsing}
|
||||
onCollapse={agent.collapsePanel}
|
||||
/>
|
||||
<>
|
||||
<AgentCommandPanel
|
||||
{...agentPanelProps}
|
||||
collapsing={agent.panelCollapsing}
|
||||
onCollapse={agent.collapsePanel}
|
||||
/>
|
||||
<div
|
||||
aria-label="调整 Agent 面板宽度"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={Math.round(viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio)}
|
||||
aria-valuemin={getWorkbenchViewportLayout(viewportWidth).agentWidth}
|
||||
aria-valuenow={Math.round(agentPanelWidth ?? getWorkbenchViewportLayout(viewportWidth).agentWidth)}
|
||||
className={cn(
|
||||
"group pointer-events-auto absolute -right-3 top-4 bottom-4 z-10 flex w-6 cursor-ew-resize touch-none items-center justify-center outline-none",
|
||||
agentPanelResizing && "[&>span]:bg-blue-500 [&>span]:opacity-100"
|
||||
)}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
title="拖拽调整 Agent 面板宽度"
|
||||
onKeyDown={handleAgentPanelResizeKeyDown}
|
||||
onPointerDown={handleAgentPanelResizeStart}
|
||||
>
|
||||
<span className="h-14 w-1 rounded-full bg-slate-500/60 opacity-50 shadow-sm transition-[height,background-color,opacity] group-hover:h-20 group-hover:bg-blue-500 group-hover:opacity-100 group-focus-visible:h-20 group-focus-visible:bg-blue-500 group-focus-visible:opacity-100" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<AgentCollapsedRail
|
||||
personaState={agent.personaState}
|
||||
@@ -771,11 +915,11 @@ export function MapWorkbenchPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute right-2 top-24 z-20">
|
||||
{!devPanelOpen ? <div className="absolute right-2 top-24 z-20">
|
||||
<MapToolbar items={toolbarItems} className="hidden lg:block" />
|
||||
</div>
|
||||
</div> : null}
|
||||
|
||||
{conditionFeedMounted ? (
|
||||
{conditionFeedMounted && !devPanelOpen ? (
|
||||
<div className="absolute right-16 top-24 z-20 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
|
||||
<ScheduledConditionFeed
|
||||
conditions={scheduledConditions}
|
||||
@@ -788,15 +932,24 @@ export function MapWorkbenchPage() {
|
||||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||||
onSelectedConditionChange={setSelectedConditionId}
|
||||
onExpandAgent={agent.expandPanel}
|
||||
onLoadHistorySession={agent.loadHistorySession}
|
||||
onLoadHistorySession={handleLoadAgentHistorySession}
|
||||
onSubmitPrompt={agent.submitPrompt}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="absolute right-16 top-24 z-20 hidden lg:block">
|
||||
{!devPanelOpen ? <div className="absolute right-16 top-24 z-20 hidden lg:block">
|
||||
<ToolbarPanel {...toolbarPanelProps} />
|
||||
</div>
|
||||
</div> : null}
|
||||
|
||||
{devPanelEnabled && devPanelOpen ? (
|
||||
<MapDevPanel
|
||||
commands={mapController}
|
||||
controllerState={mapControllerState}
|
||||
detailFeature={detailFeature}
|
||||
onClose={() => setDevPanelOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
|
||||
{agent.mobileOpen ? (
|
||||
|
||||
Reference in New Issue
Block a user