feat(agent): support Responses steering and project-aware tools
Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running turns active while users steer or queue follow-ups and edit pending delivery. Refresh the model catalog before generation and preserve approval mode selection. Integrate scoped investigation messaging/presentation, geographic place selection, current Agent composer styles and supporting workbench contracts. Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and production build. Preserve direct Server/Agent routing and project authentication.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import type { FeatureCollection } from "geojson";
|
||||
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { fetchOperationalTask, fetchOperationalTaskPresentationCursor, type OperationalTask } from "../api/operational-task-client";
|
||||
import type { InvestigationMapTarget } from "../api/investigation-report";
|
||||
import type { InvestigationOverlay, InvestigationPresentationCommand } from "../api/investigation-presentation";
|
||||
import { InvestigationMapPresentation } from "../map/investigation-map-presentation";
|
||||
import type { WorkbenchMapController } from "../map/workbench-map-controller";
|
||||
import type { WorkbenchSyncState } from "./use-workbench-sync";
|
||||
import { InvestigationPresentationFollower } from "./investigation-presentation-follower";
|
||||
|
||||
export type InvestigationChart = Extract<InvestigationPresentationCommand, { action: "open_chart" }>;
|
||||
type Options = {
|
||||
enabled: boolean; task: OperationalTask | null; projectId: string | null;
|
||||
sync: WorkbenchSyncState; getAccessToken?: AccessTokenProvider;
|
||||
mapRef: RefObject<MapLibreMap | null>; mapReady: boolean;
|
||||
controller: WorkbenchMapController; getPadding: () => PaddingOptions;
|
||||
onClearLayers: () => void;
|
||||
};
|
||||
|
||||
function frame(signal: AbortSignal) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
signal.throwIfAborted();
|
||||
const abort = () => { cancelAnimationFrame(id); reject(signal.reason); };
|
||||
const id = requestAnimationFrame(() => { signal.removeEventListener("abort", abort); resolve(); });
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** Separate commands need a rendered frame; an open and close in one SSE batch must not collapse into one React update. */
|
||||
async function waitForChart(chartId: string, signal: AbortSignal) {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
await frame(signal);
|
||||
const element = [...document.querySelectorAll<HTMLElement>("[data-investigation-chart]")].find((item) => item.dataset.investigationChart === chartId);
|
||||
if (element && element.getBoundingClientRect().height > 0) {
|
||||
const visibleUntil = Date.now() + 500;
|
||||
while (Date.now() < visibleUntil) await frame(signal);
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error("图表当前不可见");
|
||||
}
|
||||
|
||||
export function useInvestigationPresentation(options: Options) {
|
||||
const valuesRef = useRef(options);
|
||||
valuesRef.current = options;
|
||||
const [charts, setCharts] = useState<InvestigationChart[]>([]);
|
||||
const [overlay, setOverlay] = useState<InvestigationOverlay | null>(null);
|
||||
const [cameraPaused, setCameraPaused] = useState(false);
|
||||
const cameraPausedRef = useRef(false);
|
||||
const [status, setStatus] = useState<"connecting" | "live" | "disconnected" | "error">("disconnected");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [highlightLayers, setHighlightLayers] = useState<string[]>([]);
|
||||
const followerRef = useRef<InvestigationPresentationFollower | null>(null);
|
||||
const manualRequestRef = useRef<AbortController | null>(null);
|
||||
const scene = useMemo(() => new InvestigationMapPresentation(() => options.mapRef.current, () => valuesRef.current.getPadding()), [options.mapRef]);
|
||||
const taskId = options.task?.task_id ?? null;
|
||||
const subscribe = options.sync.subscribePresentationEvents;
|
||||
|
||||
const clear = useCallback(() => {
|
||||
scene.clear(); setCharts([]); setOverlay(null); setHighlightLayers([]);
|
||||
valuesRef.current.onClearLayers();
|
||||
}, [scene]);
|
||||
|
||||
useEffect(() => {
|
||||
manualRequestRef.current?.abort();
|
||||
cameraPausedRef.current = false;
|
||||
setCameraPaused(false);
|
||||
if (!options.enabled || !taskId) { clear(); return; }
|
||||
const follower = new InvestigationPresentationFollower({
|
||||
taskId, subscribe,
|
||||
fetchCursor: (signal) => fetchOperationalTaskPresentationCursor(taskId, options.getAccessToken, signal),
|
||||
fetchIdentity: (signal) => fetchOperationalTask(taskId, options.getAccessToken, signal),
|
||||
clear,
|
||||
stopPulse: () => scene.stopPulse(),
|
||||
onStatus: (next, message) => { setStatus(next); setError(message ?? null); },
|
||||
execute: async (command, signal) => {
|
||||
signal.throwIfAborted();
|
||||
if (command.action === "highlight_features") {
|
||||
const collection = await valuesRef.current.controller.queryPresentationFeatures(command.layer, command.feature_ids, signal);
|
||||
signal.throwIfAborted();
|
||||
scene.highlight(collection, command.effect === "pulse", !cameraPausedRef.current);
|
||||
setHighlightLayers([command.layer]);
|
||||
} else if (command.action === "clear_highlights") {
|
||||
scene.clearHighlights(); setHighlightLayers([]);
|
||||
} else if (command.action === "open_chart") {
|
||||
setCharts((current) => [...current.filter((item) => item.chart_id !== command.chart_id).slice(-5), command]);
|
||||
await waitForChart(command.chart_id, signal);
|
||||
} else if (command.action === "close_chart") {
|
||||
setCharts((current) => current.filter((item) => item.chart_id !== command.chart_id));
|
||||
await frame(signal);
|
||||
} else if (command.action === "set_overlay") {
|
||||
const next = { ...command, visible: true };
|
||||
scene.setOverlay(next, !cameraPausedRef.current); setOverlay(next);
|
||||
} else {
|
||||
scene.clearOverlay(command.overlay_id);
|
||||
setOverlay((current) => current?.overlay_id === command.overlay_id ? null : current);
|
||||
}
|
||||
}
|
||||
});
|
||||
followerRef.current = follower;
|
||||
follower.start();
|
||||
return () => { follower.stop(); if (followerRef.current === follower) followerRef.current = null; };
|
||||
}, [clear, options.enabled, options.getAccessToken, options.projectId, scene, subscribe, taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (options.task) followerRef.current?.updateIdentity(options.task);
|
||||
}, [options.task]);
|
||||
|
||||
const pauseCamera = useCallback(() => {
|
||||
cameraPausedRef.current = true; setCameraPaused(true);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const map = options.mapRef.current;
|
||||
if (!map || !options.mapReady || !options.enabled) return;
|
||||
const canvas = map.getCanvasContainer();
|
||||
// Canvas input excludes programmatic fitBounds/easeTo and also covers touch/keyboard navigation.
|
||||
canvas.addEventListener("pointerdown", pauseCamera);
|
||||
canvas.addEventListener("wheel", pauseCamera, { passive: true });
|
||||
canvas.addEventListener("keydown", pauseCamera);
|
||||
return () => { canvas.removeEventListener("pointerdown", pauseCamera); canvas.removeEventListener("wheel", pauseCamera); canvas.removeEventListener("keydown", pauseCamera); };
|
||||
}, [options.enabled, options.mapReady, options.mapRef, pauseCamera]);
|
||||
useEffect(() => () => { manualRequestRef.current?.abort(); scene.destroy(); }, [scene]);
|
||||
|
||||
const locateTargets = useCallback(async (targets: InvestigationMapTarget[]) => {
|
||||
manualRequestRef.current?.abort();
|
||||
const controller = new AbortController(); manualRequestRef.current = controller;
|
||||
pauseCamera();
|
||||
const groups = new Map<InvestigationMapTarget["source_id"], string[]>();
|
||||
for (const target of targets) groups.set(target.source_id, [...groups.get(target.source_id) ?? [], target.feature_id]);
|
||||
const collections = await Promise.all([...groups].map(([source, ids]) => valuesRef.current.controller.queryPresentationFeatures(source, ids, controller.signal)));
|
||||
controller.signal.throwIfAborted();
|
||||
const data: FeatureCollection = { type: "FeatureCollection", features: collections.flatMap((collection) => collection.features) };
|
||||
if (!data.features.length) throw new Error("未找到关联地图要素");
|
||||
scene.highlight(data, false, true); setHighlightLayers([...groups.keys()]);
|
||||
}, [pauseCamera, scene]);
|
||||
|
||||
return {
|
||||
charts, overlay, cameraPaused, status, error, highlightLayers,
|
||||
pauseCamera,
|
||||
resumeCamera: () => { cameraPausedRef.current = false; setCameraPaused(false); },
|
||||
locateTargets,
|
||||
closeChart: (id: string) => setCharts((current) => current.filter((item) => item.chart_id !== id)),
|
||||
toggleOverlay: () => { if (overlay) { scene.showOverlay(!overlay.visible); setOverlay({ ...overlay, visible: !overlay.visible }); } },
|
||||
fitOverlay: () => { pauseCamera(); scene.fitOverlay(); }
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user