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:
2026-10-19 18:46:58 +08:00
parent 2c5bf15cce
commit ec23d44b02
131 changed files with 6663 additions and 1204 deletions
+150 -31
View File
@@ -13,6 +13,7 @@ import { CalendarClock, Clock3 } from "lucide-react";
import {
AgentCommandPanel,
AgentPersona,
parseFrontendChart,
toTrustedMapAction,
type FrontendActionRequest,
type UIEnvelopePayload,
@@ -26,18 +27,19 @@ import {
MapSourceStatusNotice,
MapToolbar,
MapZoom,
dismissMapNotice,
showMapNotice,
type MapToolbarItem,
type MapSourceStatus
} from "@/features/map/core";
import { env } from "@/shared/config/env";
import { waitForAgentPresentation } from "./map/agent-presentation";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { AgentTaskTicker } from "./components/agent-task-ticker";
import { AnalysisResultsPanel } from "./components/analysis-results-panel";
import { PeripheralSamplePanel } from "./peripheral-sample/peripheral-sample-panel";
import type { PeripheralWorkOrder } from "./peripheral-sample/client";
import { MapDevPanel } from "./components/map-dev-panel";
import { InvestigationPresentationPanel } from "./components/investigation-presentation-panel";
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
import { FeaturePopover } from "./components/feature-popover";
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
@@ -68,11 +70,16 @@ import {
} from "./workspace/workspace-context";
import { WORKBENCH_USER } from "./data/workbench-session";
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
import { useAgentPromptSuggestions } from "./hooks/use-agent-prompt-suggestions";
import { operationalTaskConditionId, useOperationalTasks } from "./hooks/use-operational-tasks";
import { useWorkbenchSync } from "./hooks/use-workbench-sync";
import { useWorkbenchSyncNotice } from "./hooks/use-workbench-sync-notice";
import { useInvestigationPresentation } from "./hooks/use-investigation-presentation";
import type { InvestigationMapTarget } from "./api/investigation-report";
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 { useWorkbenchGeocoding } from "./hooks/use-workbench-geocoding";
import { useWorkbenchProjectPresentation } from "./hooks/use-workbench-project-presentation";
import { useWorkbenchResponsiveLayout } from "./hooks/use-workbench-responsive-layout";
import { formatHeaderDataTime, useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data";
@@ -88,7 +95,7 @@ import {
getWorkbenchLayoutCssVariables,
resolveMobileWorkbenchOverlayLayout
} from "./layout/workbench-layout";
import { getResponsiveWorkbenchPadding } from "./map/camera";
import { getResponsivePadding, getResponsiveWorkbenchPadding } from "./map/camera";
import { exportMapViewImage } from "./map/export-view";
import { parseScadaAnalysisItems } from "./map/scada-analysis";
import {
@@ -135,6 +142,8 @@ export function MapWorkbenchPage({
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
const [devPanelOpen, setDevPanelOpen] = useState(false);
const [investigationFollow, setInvestigationFollow] = useState(true);
const presentationLayerChangesRef = useRef(new Map<string, boolean>());
const [impactVisible, setImpactVisible] = useState(false);
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
const prefersReducedMotion = useReducedMotion();
@@ -158,25 +167,7 @@ export function MapWorkbenchPage({
useState<Record<string, boolean>>(INITIAL_LAYER_VISIBILITY);
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
const workbenchSync = useWorkbenchSync(getAccessToken);
const workbenchSyncReconnecting =
workbenchSync.status === "reconnecting" && workbenchSync.snapshot !== null;
useEffect(() => {
const noticeId = "workbench-sync-reconnecting";
if (!workbenchSyncReconnecting) {
dismissMapNotice(noticeId);
return;
}
showMapNotice({
id: noticeId,
tone: "warning",
title: "业务同步正在重连",
message: "当前数据可能陈旧,连接恢复后将自动更新。",
duration: Infinity
});
return () => dismissMapNotice(noticeId);
}, [workbenchSyncReconnecting]);
useWorkbenchSyncNotice(workbenchSync.status === "reconnecting" && workbenchSync.snapshot !== null);
const {
headerDataTime,
scheduledConditions: runtimeScheduledConditions,
@@ -192,6 +183,8 @@ export function MapWorkbenchPage({
operationalCursorTime
} = useWorkbenchRuntimeData(getAccessToken, workbenchSync);
const operationalTasks = useOperationalTasks(getAccessToken, workbenchSync);
const selectedInvestigationTask = operationalTasks.tasks.find((task) =>
task.task_type === "event_investigation" && operationalTaskConditionId(task.task_id) === selectedConditionId) ?? null;
const baseLayerOptions = useMemo(
() =>
BASE_LAYER_OPTIONS.map((option) => ({
@@ -464,6 +457,36 @@ export function MapWorkbenchPage({
void mapController.clearPipeFlowCalculations();
}, [mapController, mapReady, realtimePipeFlows]);
const presentation = useInvestigationPresentation({
enabled: investigationFollow, task: selectedInvestigationTask, projectId: projectPresentation.projectId,
sync: workbenchSync, getAccessToken, mapRef, mapReady, controller: mapController,
onClearLayers: restorePresentationLayers,
getPadding: () => mapRef.current ? getResponsiveWorkbenchPadding(mapRef.current, leftPanelOpen, rightPanelOpen, rightPanelExpanded, agentPanelWidth) : { top: 48, right: 48, bottom: 48, left: 48 }
});
const geocoding = useWorkbenchGeocoding({
active: activeToolId === "geocoding",
projectId: projectPresentation.projectId,
getAccessToken, mapRef, mapReady,
reducedMotion: Boolean(prefersReducedMotion),
onLocate: presentation.pauseCamera,
getPadding: () => {
const map = mapRef.current;
if (!map) return { top: 48, right: 48, bottom: 48, left: 48 };
const padding = getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen, rightPanelExpanded, agentPanelWidth);
return getResponsivePadding(map, isLargeScreen
? { ...padding, right: Math.max(padding.right, 400) }
: { ...padding, bottom: Math.max(padding.bottom, 420) });
}
});
function handleLocateInvestigationTargets(targets: InvestigationMapTarget[]) {
void presentation.locateTargets(targets).catch((error) => showMapNotice({ tone: "warning", title: "关联要素暂不可定位", message: error instanceof Error ? error.message : "地图当前不可用" }));
}
useEffect(() => () => {
restorePresentationLayers();
// Layer restoration belongs to the followed task, not each detail refresh.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedInvestigationTask?.task_id, investigationFollow]);
const activeAgentUiResults = useMemo(
() => agentUiResults.filter((result) => result.sessionId === agent.sessionId),
[agent.sessionId, agentUiResults]
@@ -622,6 +645,7 @@ export function MapWorkbenchPage({
[layerControlItems]
);
function handleToggleLayer(layerControlId: string, visible: boolean) {
presentationLayerChangesRef.current.delete(layerControlId);
const map = mapRef.current;
setLayerVisibility((current) => ({ ...current, [layerControlId]: visible }));
@@ -637,6 +661,20 @@ export function MapWorkbenchPage({
map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
});
}
function handleTogglePresentationLayer(id: string, visible: boolean) {
const before = presentationLayerChangesRef.current.get(id) ?? (layerVisibility[id] !== false);
handleToggleLayer(id, visible);
presentationLayerChangesRef.current.set(id, before);
}
function restorePresentationLayers() {
for (const [id, visible] of presentationLayerChangesRef.current) handleToggleLayer(id, visible);
presentationLayerChangesRef.current.clear();
}
const presentationLayerIds = new Set([
...presentation.highlightLayers,
...(selectedInvestigationTask?.latest_progress?.progress.diagram?.node_refs.flatMap((node) => node.map_targets?.map((target) => target.source_id) ?? []) ?? [])
].flatMap((id) => id === "scada" ? ["scada-pressure", "scada-flow"] : [id]));
const presentationRelatedLayers = layerControlItems.filter((item) => presentationLayerIds.has(item.id)).map((item) => ({ id: item.id, label: item.label, visible: item.visible }));
async function handleExportView(preset: ExportViewPreset) {
const map = mapRef.current;
@@ -731,8 +769,8 @@ export function MapWorkbenchPage({
if (workbenchAlerts.length === 0) {
showMapNotice({
tone: "info",
title: "当前无待复核工况",
message: "当前没有需要汇总的工况提醒。"
title: "当前无待处理事项",
message: "当前没有需要汇总的待办或告警。"
});
return;
}
@@ -756,13 +794,13 @@ export function MapWorkbenchPage({
showMapNotice({
tone: "success",
title: "已发起工况汇总",
message: "已将待复核工况发送到 Agent。"
message: "已将待办与告警发送给 Agent。"
});
} catch (error) {
showMapNotice({
tone: "error",
title: "工况汇总发起失败",
message: error instanceof Error ? error.message : "无法将待复核工况发送到 Agent。"
message: error instanceof Error ? error.message : "无法将待办与告警发送给 Agent。"
});
}
}
@@ -837,6 +875,34 @@ export function MapWorkbenchPage({
}
async function handleFrontendAction(request: FrontendActionRequest, signal: AbortSignal) {
signal.throwIfAborted();
if (request.name === "locate_features") {
const action = toTrustedMapAction("locate_features", request.params);
if (!action || action.type !== "locate_features" || !action.layer) throw new Error("INVALID_MAP_TARGET");
const result = await mapController.locateFeatures(action.layer as Parameters<typeof mapController.locateFeatures>[0], action.featureIds, signal);
return result;
}
if (request.name === "zoom_to_map") {
const action = toTrustedMapAction("zoom_to_map", request.params);
if (!action || action.type !== "zoom_to_map") throw new Error("INVALID_MAP_CAMERA");
const map = mapRef.current;
if (!map || !mapReady) throw new Error("MAP_NOT_READY");
map.jumpTo({ center: action.center, zoom: action.zoom });
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
signal.throwIfAborted();
return { fitted: true };
}
if (request.name === "show_chart" || request.name === "view_history") {
const chart = parseFrontendChart(request.name === "view_history" ? request.params.chart : request.params);
if (!chart) throw new Error("INVALID_CHART_DATA");
const data = { x_data: chart.x_data, series: chart.series };
const envelope: AgentUiResult["envelope"] = request.name === "view_history"
? { kind: "registered_component", schemaVersion: "agent-ui@1", component: "HistoryPanel", surface: "side_panel", props: { feature_id: request.params.element_id, metric: chart.title, start_time: request.params.start, end_time: request.params.end }, data }
: { kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type }, data };
setAgentUiResults((current) => [...current.slice(-5), { id: request.actionId, sessionId: request.sessionId, createdAt: Date.now(), envelope }]);
await waitForAgentPresentation(request.actionId, signal);
return { displayed: true, series_count: chart.series.length };
}
if (request.name === "render_scada_analysis") {
const items = parseScadaAnalysisItems(request.params.items);
if (!items) throw new Error("INVALID_SCADA_ANALYSIS");
@@ -875,8 +941,7 @@ export function MapWorkbenchPage({
async function handleOpenTaskCollaboration(taskId: string, discussion?: InvestigationDiscussion) {
mapController.clearScadaAnalysis();
await agent.openTaskCollaboration(taskId, discussion?.context);
if (discussion?.prompt) await agent.submitPrompt(discussion.prompt);
await agent.openTaskCollaboration(taskId, discussion);
}
async function handleDeleteAgentHistorySession(sessionId: string) {
@@ -995,6 +1060,7 @@ export function MapWorkbenchPage({
}
const toolbarPanelProps: ToolbarPanelProps = {
geocoding,
activeToolId,
activeMeasureModeId,
activeMeasureUnitId,
@@ -1067,6 +1133,20 @@ export function MapWorkbenchPage({
const collaborationReviewTask = agent.sessionPurpose !== "ordinary_chat" && agent.sessionTaskId
? operationalTasks.tasks.find((item) => item.task_id === agent.sessionTaskId)
: undefined;
const promptSuggestions = useAgentPromptSuggestions({
enabled: agent.conversationScope === "project" && (isLargeScreen ? agent.panelOpen : mobileSheet === "agent") && agent.messages.length === 0 && !agent.streaming &&
agent.sessionPurpose === "ordinary_chat" && agent.connectionStatus === "online" && !agent.sessionHistoryLoading &&
!scheduledConditionsLoading && !operationalTasks.loading,
model: agent.selectedModel,
sessionId: agent.sessionId,
request: agent.generatePromptSuggestions,
alerts: workbenchAlerts,
alertDataSource,
conditions: scheduledConditions,
runtimeDataSource,
artifact: latestAnalysisArtifact ?? null,
sessions: agent.sessionHistory,
});
const agentPanelProps: Omit<
ComponentProps<typeof AgentCommandPanel>,
"collapsing" | "onCollapse"
@@ -1075,15 +1155,24 @@ export function MapWorkbenchPage({
sessionTitle: agent.sessionTitle,
sessionHistory: agent.sessionHistory,
sessionHistoryLoading: agent.sessionHistoryLoading,
promptSuggestions: promptSuggestions.suggestions,
promptSuggestionsLoading: promptSuggestions.loading,
promptSuggestionsUnavailable: promptSuggestions.unavailable,
activeSessionId: agent.sessionId,
qaOnly: agent.sessionPurpose === "task_collaboration",
statusLabel: agent.statusLabel,
connectionStatus: agent.connectionStatus,
streaming: agent.streaming,
inputDelivery: agent.inputDelivery,
onInputDeliveryChange: agent.setInputDelivery,
pendingInputs: agent.pendingInputs,
onPendingInputDeliveryChange: agent.updateInputDelivery,
messages: agent.messages,
streamRenderState: agent.streamRenderState,
modelOptions: agent.modelOptions,
selectedModel: agent.selectedModel,
conversationScope: agent.conversationScope,
onConversationScopeChange: agent.changeConversationScope,
approvalMode: agent.approvalMode,
uiResults: activeAgentUiResults,
reviewPanel: collaborationReviewTask ? <TaskConversationReview task={collaborationReviewTask}
@@ -1096,6 +1185,17 @@ export function MapWorkbenchPage({
onRefresh={operationalTasks.refresh} /> : undefined,
workspaceContext: agentWorkspaceContext,
prompt: agent.promptDraft,
promptReference: collaborationReviewTask ? {
title: collaborationReviewTask.title,
placeholder: "可询问调查进展、讨论报告或补充信息",
detail: [
agent.taskDiscussionContext?.report_revision && agent.taskDiscussionReportGeneration
? `第 ${agent.taskDiscussionReportGeneration} 轮` : null,
agent.taskDiscussionContext?.report_revision ? `报告第 ${agent.taskDiscussionContext.report_revision} 版` : null,
agent.taskDiscussionContext?.evidence_id ? "已引用证据" : null,
agent.taskDiscussionContext?.gap_id ? "已引用数据缺口" : null
].filter(Boolean).join(" · ")
} : undefined,
onRefreshHistory: agent.refreshSessionHistory,
onStartNewSession: handleStartNewAgentSession,
onLoadHistorySession: handleLoadAgentHistorySession,
@@ -1104,6 +1204,12 @@ export function MapWorkbenchPage({
onSelectModel: agent.setSelectedModel,
onApprovalModeChange: agent.setApprovalMode,
onPromptChange: agent.setPromptDraft,
promptFocusRequest: agent.promptFocusRequest,
messageInbox: agent.sessionPurpose === "ordinary_chat" ? {
...agent.agentMessageInbox,
onLoadMore: agent.agentMessageInbox.loadMore,
onRefresh: agent.agentMessageInbox.refresh
} : undefined,
onSubmitPrompt: agent.submitPrompt,
onLocateAnswerFeatures: (target) => mapController.locateFeatures(target.layer, target.feature_ids, new AbortController().signal),
onStopPrompt: agent.stopPrompt,
@@ -1259,6 +1365,13 @@ export function MapWorkbenchPage({
}
mapOverlay={
<div className="pointer-events-none absolute inset-x-0 bottom-0 top-14">
{investigationFollow && selectedInvestigationTask && !devPanelOpen ? <InvestigationPresentationPanel
task={selectedInvestigationTask} charts={presentation.charts} overlay={presentation.overlay} cameraPaused={presentation.cameraPaused}
status={presentation.status} error={presentation.error}
width={Math.min(380, Math.max(180, businessWorkspaceWidth - (rightPanelOpen ? (rightPanelExpanded ? (viewportWidth >= 1536 ? 960 : 880) : 432) + 64 : 64) - 24))}
relatedLayers={presentationRelatedLayers} onToggleLayer={handleTogglePresentationLayer}
onResumeCamera={presentation.resumeCamera} onCloseChart={presentation.closeChart} onToggleOverlay={presentation.toggleOverlay} onFitOverlay={presentation.fitOverlay}
/> : null}
{!devPanelOpen ? (
<>
<div className="pointer-events-auto absolute right-3 top-10 hidden lg:block">
@@ -1278,7 +1391,9 @@ export function MapWorkbenchPage({
} as CSSProperties
}
>
<div className="flex items-end gap-2 lg:pr-2">
<div className="flex items-end gap-2 lg:pr-2"
onPointerDownCapture={() => { if (investigationFollow) presentation.pauseCamera(); }}
onKeyDownCapture={() => { if (investigationFollow) presentation.pauseCamera(); }}>
<MapZoom
mapRef={mapRef}
mapReady={mapReady}
@@ -1305,8 +1420,8 @@ export function MapWorkbenchPage({
onWidthCommit={setAgentPanelWidth}
/>
{conditionFeedMounted && !devPanelOpen ? (
<div className="absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
{conditionFeedMounted ? (
<div className="absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]" style={devPanelOpen ? { display: "none" } : undefined}>
<ScheduledConditionFeed
presentation="desktop-floating"
conditions={scheduledConditions}
@@ -1322,6 +1437,7 @@ export function MapWorkbenchPage({
onExpandedChange={handleConditionExpandedChange}
onFocusRequestHandled={handleConditionFocusRequestHandled}
onSelectedConditionChange={setSelectedConditionId}
onLocate={handleLocateInvestigationTargets}
onExpandAgent={openAgentWorkspaceForViewport}
onLoadHistorySession={handleLoadAgentHistorySession}
onSubmitPrompt={agent.submitPrompt}
@@ -1347,6 +1463,8 @@ export function MapWorkbenchPage({
controllerState={mapControllerState}
detailFeature={detailFeature}
flowDataSource={flowDataSource}
investigationFollow={investigationFollow}
onInvestigationFollowChange={setInvestigationFollow}
onClose={() => setDevPanelOpen(false)}
/>
) : null}
@@ -1409,7 +1527,7 @@ export function MapWorkbenchPage({
<MapToolbar
items={toolbarItems}
orientation="horizontal"
className="h-11 justify-center"
className="h-11 min-w-0 flex-1 justify-center [&>div]:min-w-0 [&>div]:flex-1 [&>div>div]:hidden [&_button]:w-full"
/>
) : null}
<button
@@ -1473,6 +1591,7 @@ export function MapWorkbenchPage({
onExpandedChange={handleConditionExpandedChange}
onFocusRequestHandled={handleConditionFocusRequestHandled}
onSelectedConditionChange={setSelectedConditionId}
onLocate={handleLocateInvestigationTargets}
onExpandAgent={openAgentPanelForViewport}
onClose={closeMobileSheet}
onLoadHistorySession={handleLoadAgentHistorySession}