feat(workbench): integrate operational workflows and map state
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/features/agent";
|
||||
|
||||
type AgentUiDataParts = {
|
||||
execution_plan: Record<string, unknown>;
|
||||
progress: Record<string, unknown>;
|
||||
todo_update: Record<string, unknown>;
|
||||
permission_request: Record<string, unknown>;
|
||||
@@ -184,6 +185,7 @@ export function applySessionStreamEvent(messages: AgentUiMessage[], event: Agent
|
||||
|
||||
if (
|
||||
event.type === "progress" ||
|
||||
event.type === "execution_plan" ||
|
||||
event.type === "todo_update" ||
|
||||
event.type === "permission_request" ||
|
||||
event.type === "permission_response" ||
|
||||
@@ -275,6 +277,7 @@ function toAgentUiMessageFromLegacy(value: unknown): AgentUiMessage | null {
|
||||
appendLegacyDataParts(parts, "permission_request", message.permissions);
|
||||
appendLegacyDataParts(parts, "question_request", message.questions);
|
||||
appendLegacyDataParts(parts, "todo_update", message.todos);
|
||||
appendLegacyDataParts(parts, "execution_plan", message.executionPlans);
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearMapFeatureInteractionState,
|
||||
isPointOccludedByCityBuilding,
|
||||
setMapFeatureInteractionState,
|
||||
toMapFeatureReference
|
||||
} from "./use-map-interactions";
|
||||
@@ -8,6 +9,7 @@ import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
|
||||
|
||||
describe("map feature interaction state", () => {
|
||||
const feature = { source: "junctions", sourceLayer: "junctions", id: "junction-7" } as const;
|
||||
type CityOcclusionMap = Parameters<typeof isPointOccludedByCityBuilding>[0];
|
||||
|
||||
it("maps selected business features to promoted vector feature references", () => {
|
||||
expect(toMapFeatureReference({ id: "junction-7", layer: "junctions" })).toEqual(feature);
|
||||
@@ -42,4 +44,30 @@ describe("map feature interaction state", () => {
|
||||
"fx_20200423_pipes-hit"
|
||||
]));
|
||||
});
|
||||
|
||||
it("blocks pointer interactions where a 3D building is rendered", () => {
|
||||
const queryRenderedFeatures = vi.fn(() => [{ id: "building-1" }]);
|
||||
const map = {
|
||||
getLayer: vi.fn(() => ({ id: "fengxian-city-buildings" })),
|
||||
getPitch: vi.fn(() => 55),
|
||||
queryRenderedFeatures
|
||||
};
|
||||
|
||||
expect(isPointOccludedByCityBuilding(map as unknown as CityOcclusionMap, [120, 80])).toBe(true);
|
||||
expect(queryRenderedFeatures).toHaveBeenCalledWith([120, 80], {
|
||||
layers: ["fengxian-city-buildings"]
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps 2D pointer interactions independent of the optional city scene", () => {
|
||||
const queryRenderedFeatures = vi.fn();
|
||||
const map = {
|
||||
getLayer: vi.fn(() => ({ id: "fengxian-city-buildings" })),
|
||||
getPitch: vi.fn(() => 0),
|
||||
queryRenderedFeatures
|
||||
};
|
||||
|
||||
expect(isPointOccludedByCityBuilding(map as unknown as CityOcclusionMap, [120, 80])).toBe(false);
|
||||
expect(queryRenderedFeatures).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Map as MapLibreMap, MapLayerMouseEvent } from "maplibre-gl";
|
||||
import type { Map as MapLibreMap, MapLayerMouseEvent, PointLike } from "maplibre-gl";
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
import type { DetailFeature } from "../types";
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
toDetailFeature
|
||||
} from "../map/feature-adapter";
|
||||
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
|
||||
import { CITY_BUILDING_LAYER_ID } from "../map/city-scene-layers";
|
||||
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "../map/sources";
|
||||
|
||||
export type MapFeatureInteractionState = {
|
||||
@@ -46,6 +47,16 @@ export function clearMapFeatureInteractionState(
|
||||
map.removeFeatureState(toFeatureStateTarget(feature), key);
|
||||
}
|
||||
|
||||
type CityOcclusionMap = Pick<
|
||||
MapLibreMap,
|
||||
"getLayer" | "getPitch" | "queryRenderedFeatures"
|
||||
>;
|
||||
|
||||
export function isPointOccludedByCityBuilding(map: CityOcclusionMap, point: PointLike) {
|
||||
if (map.getPitch() <= 0 || !map.getLayer(CITY_BUILDING_LAYER_ID)) return false;
|
||||
return map.queryRenderedFeatures(point, { layers: [CITY_BUILDING_LAYER_ID] }).length > 0;
|
||||
}
|
||||
|
||||
type UseMapInteractionsOptions = {
|
||||
availableSourceIds: readonly WaterNetworkSourceId[];
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
@@ -74,6 +85,11 @@ export function useMapInteractions({
|
||||
};
|
||||
|
||||
const handleMouseMove = (event: MapLayerMouseEvent) => {
|
||||
if (isPointOccludedByCityBuilding(map, event.point)) {
|
||||
clearHoveredFeature();
|
||||
map.getCanvas().style.cursor = "";
|
||||
return;
|
||||
}
|
||||
const feature = event.features?.[0];
|
||||
const source = feature && getWaterNetworkSourceId(feature);
|
||||
const id = feature && getFeatureId(feature);
|
||||
@@ -100,6 +116,7 @@ export function useMapInteractions({
|
||||
};
|
||||
|
||||
const handleClick = (event: MapLayerMouseEvent) => {
|
||||
if (isPointOccludedByCityBuilding(map, event.point)) return;
|
||||
const feature = event.features?.[0];
|
||||
if (feature) onSelectFeature(toDetailFeature(feature));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OperationalTask } from "../api/operational-task-client";
|
||||
import { toOperationalTaskCondition } from "./use-operational-tasks";
|
||||
|
||||
function task(taskType: OperationalTask["task_type"]): OperationalTask {
|
||||
return {
|
||||
project_id: "11111111-1111-4111-8111-111111111111",
|
||||
task_id: "22222222-2222-4222-8222-222222222222",
|
||||
task_type: taskType,
|
||||
workflow: "general",
|
||||
lifecycle_status: "open",
|
||||
active_run: {
|
||||
task_id: "22222222-2222-4222-8222-222222222222",
|
||||
run_id: "55555555-5555-4555-8555-555555555555",
|
||||
generation: 1,
|
||||
execution_request_id: "33333333-3333-4333-8333-333333333333",
|
||||
agent_session_id: null,
|
||||
status: "queued",
|
||||
lease_expires_at: null,
|
||||
heartbeat_at: null,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
failure_code: null,
|
||||
failure_detail: null,
|
||||
dispatch_attempts: 0,
|
||||
next_dispatch_at: "2026-09-23T08:00:00Z",
|
||||
revision: 1,
|
||||
created_at: "2026-09-23T08:00:00Z",
|
||||
updated_at: "2026-09-23T08:00:00Z"
|
||||
},
|
||||
status: "queued",
|
||||
priority: "normal",
|
||||
revision: 2,
|
||||
execution_generation: 1,
|
||||
execution_request_id: "33333333-3333-4333-8333-333333333333",
|
||||
title: "预约维修",
|
||||
summary: "任务当前说明",
|
||||
source: { kind: "external_system", external_id: "work-order-1", external_revision: 2, event_kind: "general" },
|
||||
trigger: { kind: "external_sync", occurred_at: "2026-09-23T08:00:00Z", detail: "初次同步时的旧描述" },
|
||||
scheduled_for: "2026-09-23T10:00:00Z",
|
||||
agent_session_id: null,
|
||||
agent_lease_expires_at: null,
|
||||
current_plan_revision: null,
|
||||
current_report_revision: null,
|
||||
created_at: "2026-09-23T08:00:00Z",
|
||||
updated_at: "2026-09-23T09:00:00Z",
|
||||
latest_plan: null,
|
||||
latest_report: null,
|
||||
evidence: [],
|
||||
actions: [],
|
||||
permissions: {
|
||||
can_publish_artifacts: false,
|
||||
can_propose_actions: false,
|
||||
can_sync_external_feedback: false,
|
||||
can_decide_work_orders: false,
|
||||
can_execute_work_orders: false,
|
||||
can_decide_commands: false,
|
||||
can_execute_commands: false
|
||||
},
|
||||
work_order_snapshot: {
|
||||
work_order_id: "44444444-4444-4444-8444-444444444444",
|
||||
revision: 2,
|
||||
title: "预约维修",
|
||||
summary: "编辑后的工单说明",
|
||||
status: "open",
|
||||
priority: "normal",
|
||||
assignee: "维修班组",
|
||||
scheduled_at: "2026-09-23T10:00:00Z",
|
||||
work_order_type: "general",
|
||||
location: "测试点",
|
||||
scenario_code: null,
|
||||
subtasks: null,
|
||||
origin: "synthetic",
|
||||
updated_at: "2026-09-23T09:00:00Z"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("operational task condition mapper", () => {
|
||||
it("uses the latest work-order snapshot instead of the immutable trigger description", () => {
|
||||
expect(toOperationalTaskCondition(task("external_work_order")).detail).toBe("编辑后的工单说明");
|
||||
});
|
||||
|
||||
it("does not render an open external work order without an Agent run as running", () => {
|
||||
expect(toOperationalTaskCondition({
|
||||
...task("external_work_order"),
|
||||
active_run: null
|
||||
}).status).toBe("pending");
|
||||
});
|
||||
|
||||
it("falls back to the current task summary when an external work order has no snapshot", () => {
|
||||
expect(toOperationalTaskCondition({
|
||||
...task("external_work_order"),
|
||||
work_order_snapshot: null
|
||||
}).detail).toBe("任务当前说明");
|
||||
});
|
||||
|
||||
it("keeps the event trigger detail for investigations", () => {
|
||||
expect(toOperationalTaskCondition(task("event_investigation")).detail).toBe("初次同步时的旧描述");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import {
|
||||
acknowledgeOperationalTaskNotification,
|
||||
createOperationalRequestId,
|
||||
createOperationalTask,
|
||||
decideOperationalTaskAction,
|
||||
executeOperationalTaskAction,
|
||||
fetchOperationalTask,
|
||||
fetchOperationalTaskEvidenceSnapshot,
|
||||
fetchOperationalTaskNotifications,
|
||||
reconcileOperationalTaskAction,
|
||||
type OperationalTask,
|
||||
type OperationalTaskDecision,
|
||||
type OperationalTaskNotification
|
||||
} from "../api/operational-task-client";
|
||||
import type { ScheduledConditionRecord, ScheduledConditionRiskLevel, ScheduledConditionStatus } from "../types";
|
||||
import type { WorkbenchSyncState } from "./use-workbench-sync";
|
||||
|
||||
|
||||
export type ManualInvestigationKind = "water_quality_complaint" | "burst_report";
|
||||
export type CreateInvestigationInput = {
|
||||
kind: ManualInvestigationKind;
|
||||
title: string;
|
||||
summary: string;
|
||||
detail: string;
|
||||
occurredAt: string;
|
||||
priority?: OperationalTask["priority"];
|
||||
source?: OperationalTask["source"];
|
||||
triggerKind?: "event" | "manual";
|
||||
};
|
||||
|
||||
export function useOperationalTasks(
|
||||
getAccessToken: AccessTokenProvider | undefined,
|
||||
sync: WorkbenchSyncState
|
||||
) {
|
||||
const syncEvents = sync.events;
|
||||
const acknowledgeEventsThrough = sync.acknowledgeEventsThrough;
|
||||
const [tasks, setTasks] = useState<OperationalTask[]>([]);
|
||||
const [notifications, setNotifications] = useState<OperationalTaskNotification[]>([]);
|
||||
const [available, setAvailable] = useState(false);
|
||||
const activatedRef = useRef(false);
|
||||
const tasksRef = useRef<OperationalTask[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshGeneration, setRefreshGeneration] = useState(0);
|
||||
const forceRefreshRef = useRef(false);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
forceRefreshRef.current = true;
|
||||
setRefreshGeneration((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const load = async () => {
|
||||
const summaries = sync.snapshot?.tasks ?? [];
|
||||
try {
|
||||
const current = new Map(tasksRef.current.map((task) => [task.task_id, task]));
|
||||
const changed = forceRefreshRef.current
|
||||
? summaries
|
||||
: summaries.filter((summary) => current.get(summary.task_id)?.revision !== summary.revision);
|
||||
const details = await Promise.all(changed.map((task) =>
|
||||
fetchOperationalTask(task.task_id, getAccessToken, controller.signal)
|
||||
));
|
||||
if (controller.signal.aborted) return;
|
||||
const detailMap = new Map(details.map((task) => [task.task_id, task]));
|
||||
const nextTasks = summaries.flatMap((summary) => {
|
||||
const detail = detailMap.get(summary.task_id) ?? current.get(summary.task_id);
|
||||
return detail ? [detail] : [];
|
||||
});
|
||||
tasksRef.current = nextTasks;
|
||||
forceRefreshRef.current = false;
|
||||
setTasks(nextTasks);
|
||||
setNotifications((sync.snapshot?.notifications ?? []).filter((item) => item.status === "unread"));
|
||||
setAvailable(true);
|
||||
activatedRef.current = true;
|
||||
setError(null);
|
||||
setLastSyncedAt(new Date().toISOString());
|
||||
} catch (nextError) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (!activatedRef.current) setAvailable(false);
|
||||
setError(nextError instanceof Error ? nextError.message : "工况任务服务暂时不可用。");
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (sync.snapshot) void load();
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [getAccessToken, refreshGeneration, sync.snapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
const events = syncEvents;
|
||||
if (events.length === 0) return;
|
||||
const controller = new AbortController();
|
||||
const lastSequence = events.at(-1)!.event_seq;
|
||||
const taskIds = new Set<string>();
|
||||
let notificationsChanged = false;
|
||||
for (const event of events) {
|
||||
if (event.topic === "operational.notification.changed") notificationsChanged = true;
|
||||
if (!event.topic.startsWith("operational.") || event.topic === "operational.notification.changed") continue;
|
||||
const taskId = typeof event.metadata.task_id === "string"
|
||||
? event.metadata.task_id
|
||||
: event.aggregate_type === "operational_task" ? event.aggregate_id : null;
|
||||
if (taskId) taskIds.add(taskId);
|
||||
}
|
||||
void Promise.all([
|
||||
notificationsChanged
|
||||
? fetchOperationalTaskNotifications(getAccessToken, controller.signal)
|
||||
: Promise.resolve(null),
|
||||
Promise.all([...taskIds].map((taskId) =>
|
||||
fetchOperationalTask(taskId, getAccessToken, controller.signal)
|
||||
))
|
||||
]).then(([nextNotifications, changedTasks]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (nextNotifications) setNotifications(nextNotifications);
|
||||
if (changedTasks.length > 0) {
|
||||
const changed = new Map(changedTasks.map((task) => [task.task_id, task]));
|
||||
tasksRef.current = [
|
||||
...changedTasks,
|
||||
...tasksRef.current.filter((task) => !changed.has(task.task_id))
|
||||
];
|
||||
setTasks(tasksRef.current);
|
||||
setLastSyncedAt(new Date().toISOString());
|
||||
}
|
||||
acknowledgeEventsThrough(lastSequence);
|
||||
}).catch((nextError) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(nextError instanceof Error ? nextError.message : "工况任务增量刷新失败。");
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [acknowledgeEventsThrough, getAccessToken, syncEvents]);
|
||||
|
||||
const conditions = useMemo(() => tasks.map(toOperationalTaskCondition), [tasks]);
|
||||
|
||||
const createInvestigation = useCallback(async (input: CreateInvestigationInput) => {
|
||||
const task = await createOperationalTask({
|
||||
request_id: createOperationalRequestId(),
|
||||
task_type: "event_investigation",
|
||||
workflow: input.kind,
|
||||
title: input.title,
|
||||
summary: input.summary,
|
||||
priority: input.priority ?? (input.kind === "burst_report" ? "urgent" : "normal"),
|
||||
source: input.source ?? { kind: "operator", external_id: null, external_revision: null, event_kind: input.kind },
|
||||
trigger: {
|
||||
kind: input.triggerKind ?? "manual",
|
||||
occurred_at: input.occurredAt,
|
||||
detail: input.detail
|
||||
}
|
||||
}, getAccessToken);
|
||||
tasksRef.current = [task, ...tasksRef.current.filter((item) => item.task_id !== task.task_id)];
|
||||
setTasks(tasksRef.current);
|
||||
activatedRef.current = true;
|
||||
setAvailable(true);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setLastSyncedAt(new Date().toISOString());
|
||||
return task;
|
||||
}, [getAccessToken]);
|
||||
|
||||
const decideAction = useCallback(async (
|
||||
taskId: string,
|
||||
actionId: string,
|
||||
expectedVersion: number,
|
||||
decision: OperationalTaskDecision,
|
||||
comment?: string
|
||||
) => {
|
||||
const action = await decideOperationalTaskAction(taskId, actionId, {
|
||||
request_id: createOperationalRequestId(),
|
||||
expected_version: expectedVersion,
|
||||
decision,
|
||||
comment
|
||||
}, getAccessToken);
|
||||
setTasks((current) => current.map((item) => item.task_id === taskId ? {
|
||||
...item,
|
||||
actions: item.actions.map((currentAction) => currentAction.action_id === action.action_id ? action : currentAction)
|
||||
} : item));
|
||||
return action;
|
||||
}, [getAccessToken]);
|
||||
|
||||
const executeAction = useCallback(async (
|
||||
taskId: string,
|
||||
actionId: string,
|
||||
expectedVersion: number
|
||||
) => {
|
||||
const action = await executeOperationalTaskAction(taskId, actionId, {
|
||||
request_id: createOperationalRequestId(),
|
||||
expected_version: expectedVersion
|
||||
}, getAccessToken);
|
||||
setTasks((current) => current.map((item) => item.task_id === taskId ? {
|
||||
...item,
|
||||
actions: item.actions.map((currentAction) => currentAction.action_id === action.action_id ? action : currentAction)
|
||||
} : item));
|
||||
return action;
|
||||
}, [getAccessToken]);
|
||||
|
||||
const reconcileAction = useCallback(async (taskId: string, actionId: string, expectedVersion: number) => {
|
||||
const action = await reconcileOperationalTaskAction(taskId, actionId, {
|
||||
request_id: createOperationalRequestId(),
|
||||
expected_version: expectedVersion
|
||||
}, getAccessToken);
|
||||
setTasks((current) => current.map((item) => item.task_id === taskId ? {
|
||||
...item,
|
||||
actions: item.actions.map((currentAction) => currentAction.action_id === action.action_id ? action : currentAction)
|
||||
} : item));
|
||||
return action;
|
||||
}, [getAccessToken]);
|
||||
|
||||
const acknowledgeNotification = useCallback(async (notificationId: string, version: number) => {
|
||||
await acknowledgeOperationalTaskNotification(
|
||||
notificationId,
|
||||
version,
|
||||
createOperationalRequestId(),
|
||||
getAccessToken
|
||||
);
|
||||
setNotifications((current) => current.filter((item) => item.notification_id !== notificationId));
|
||||
}, [getAccessToken]);
|
||||
|
||||
const loadEvidenceSnapshot = useCallback((taskId: string, evidenceId: string) =>
|
||||
fetchOperationalTaskEvidenceSnapshot(taskId, evidenceId, getAccessToken), [getAccessToken]);
|
||||
|
||||
return {
|
||||
available,
|
||||
error: error ?? sync.error,
|
||||
syncStatus: sync.status,
|
||||
lastSyncedAt,
|
||||
loading,
|
||||
tasks,
|
||||
conditions,
|
||||
notifications,
|
||||
refresh,
|
||||
createInvestigation,
|
||||
decideAction,
|
||||
executeAction,
|
||||
reconcileAction,
|
||||
acknowledgeNotification,
|
||||
loadEvidenceSnapshot
|
||||
};
|
||||
}
|
||||
|
||||
export function toOperationalTaskCondition(task: OperationalTask): ScheduledConditionRecord {
|
||||
return {
|
||||
id: operationalTaskConditionId(task.task_id),
|
||||
kind: "condition",
|
||||
taskId: "operational-task",
|
||||
dataSource: "operational_tasks",
|
||||
sessionId: task.active_run?.agent_session_id ?? task.agent_session_id ?? "",
|
||||
title: task.title,
|
||||
summary: task.summary,
|
||||
status: toConditionStatus(task),
|
||||
riskLevel: toRiskLevel(task),
|
||||
scheduledAt: task.scheduled_for ?? task.trigger.occurred_at,
|
||||
updatedAt: Date.parse(task.updated_at),
|
||||
detail: task.task_type === "external_work_order"
|
||||
? task.work_order_snapshot?.summary ?? task.summary
|
||||
: task.trigger.detail,
|
||||
recommendation: task.latest_report?.report.current_conclusion || undefined,
|
||||
evidence: task.evidence.map((item) => item.summary),
|
||||
modelName: "TJWater operational task",
|
||||
operationalTask: task
|
||||
};
|
||||
}
|
||||
|
||||
export function operationalTaskConditionId(taskId: string) {
|
||||
return `operational-task:${taskId}`;
|
||||
}
|
||||
|
||||
function toConditionStatus(task: OperationalTask): ScheduledConditionStatus {
|
||||
const status = task.lifecycle_status;
|
||||
if (status === "completed") return "completed";
|
||||
if (status === "failed") return "error";
|
||||
if (status === "cancelled" || status === "waiting_human") return "warning";
|
||||
if (task.task_type === "external_work_order") {
|
||||
const externalStatus = task.work_order_snapshot?.status;
|
||||
if (externalStatus === "completed") return "completed";
|
||||
if (externalStatus === "cancelled" || externalStatus === "awaiting_reply") return "warning";
|
||||
if (externalStatus === "in_progress") return "running";
|
||||
return "pending";
|
||||
}
|
||||
return task.active_run?.status === "queued" ? "pending" : "running";
|
||||
}
|
||||
|
||||
function toRiskLevel(task: OperationalTask): ScheduledConditionRiskLevel {
|
||||
const { priority, lifecycle_status: status } = task;
|
||||
if (status === "failed" || priority === "urgent") return "critical";
|
||||
if (status === "waiting_human") return "attention";
|
||||
return "normal";
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type AgentPermissionRequest,
|
||||
type AgentQuestionRequest,
|
||||
type AgentSessionStreamEvent,
|
||||
type AgentSessionPurpose,
|
||||
type AgentStreamRenderState,
|
||||
type FrontendActionRequest,
|
||||
type UIEnvelopePayload,
|
||||
@@ -87,6 +88,7 @@ export function useWorkbenchAgent({
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [sessionPurpose, setSessionPurpose] = useState<AgentSessionPurpose>("ordinary_chat");
|
||||
const [registry, setRegistry] = useState<UIRegistry | null>(null);
|
||||
const [sessionTitle, setSessionTitle] = useState("新对话");
|
||||
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
|
||||
@@ -546,6 +548,7 @@ export function useWorkbenchAgent({
|
||||
}
|
||||
|
||||
applySessionId(loadedSession.sessionId);
|
||||
setSessionPurpose(loadedSession.purpose);
|
||||
setSessionTitle(loadedSession.title);
|
||||
setStatusLabel("Agent 历史会话已打开");
|
||||
setPermissionOverrides({});
|
||||
@@ -569,6 +572,20 @@ export function useWorkbenchAgent({
|
||||
[applySessionId, chat, refreshSessionHistory, startSessionStreamSubscription, stopSessionStreamSubscription, streaming]
|
||||
);
|
||||
|
||||
const openTaskCollaboration = useCallback(async (taskId: string) => {
|
||||
const normalizedTaskId = taskId.trim();
|
||||
if (!normalizedTaskId) {
|
||||
throw new Error("工况任务 ID 不能为空。");
|
||||
}
|
||||
const collaboration = await clientRef.current.createTaskCollaborationSession(normalizedTaskId);
|
||||
const collaborationSessionId = collaboration.session_id || collaboration.id;
|
||||
if (!collaborationSessionId) {
|
||||
throw new Error("Agent 未返回任务协作会话。");
|
||||
}
|
||||
await loadHistorySession(collaborationSessionId);
|
||||
return collaborationSessionId;
|
||||
}, [loadHistorySession]);
|
||||
|
||||
const renameHistorySession = useCallback(
|
||||
async (nextSessionId: string, title: string) => {
|
||||
const normalizedTitle = title.trim();
|
||||
@@ -614,6 +631,7 @@ export function useWorkbenchAgent({
|
||||
(nextStatusLabel = "Agent 新对话已准备") => {
|
||||
clearSessionId();
|
||||
setSessionTitle("新对话");
|
||||
setSessionPurpose("ordinary_chat");
|
||||
setStatusLabel(nextStatusLabel);
|
||||
setConnectionStatus("online");
|
||||
setPermissionOverrides({});
|
||||
@@ -875,6 +893,7 @@ export function useWorkbenchAgent({
|
||||
}, []);
|
||||
|
||||
return {
|
||||
apiClient: clientRef.current,
|
||||
approvalMode,
|
||||
collapsePanel,
|
||||
expandPanel,
|
||||
@@ -891,11 +910,13 @@ export function useWorkbenchAgent({
|
||||
refreshSessionHistory,
|
||||
startNewSession,
|
||||
loadHistorySession,
|
||||
openTaskCollaboration,
|
||||
renameHistorySession,
|
||||
deleteHistorySession,
|
||||
sessionHistory,
|
||||
sessionHistoryLoading: sessionHistoryLoading || sessionHistoryValidating,
|
||||
sessionId,
|
||||
sessionPurpose,
|
||||
sessionTitle,
|
||||
statusLabel,
|
||||
streamRenderState,
|
||||
|
||||
@@ -272,6 +272,7 @@ export function useWorkbenchMap({
|
||||
pitch: 0,
|
||||
bearing: 0,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
antialias: true,
|
||||
preserveDrawingBuffer: true,
|
||||
attributionControl: false
|
||||
});
|
||||
@@ -281,7 +282,6 @@ export function useWorkbenchMap({
|
||||
|
||||
map.on("load", async () => {
|
||||
map.resize();
|
||||
removeCityScene = installCityScene(map);
|
||||
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
|
||||
scadaCollectionRef.current
|
||||
);
|
||||
@@ -328,6 +328,7 @@ export function useWorkbenchMap({
|
||||
REQUIRED_GEOSERVER_SOURCE_IDS
|
||||
).forEach((layer) => map.addLayer(layer));
|
||||
scadaHitLayers.forEach((layer) => map.addLayer(layer));
|
||||
removeCityScene = installCityScene(map);
|
||||
setSimulationLayersVisibility(map, impactVisibleRef.current);
|
||||
setMapReady(true);
|
||||
updateSourceStatus(
|
||||
|
||||
@@ -109,6 +109,16 @@ describe("workbench runtime data", () => {
|
||||
expect(resolved.workOrderDataSource).toBe("none");
|
||||
});
|
||||
|
||||
it("keeps explicit demo work orders visible without treating them as backend fallback", () => {
|
||||
const demoWorkOrder = { ...mockWorkOrder, dataSource: "mock" as const };
|
||||
const resolved = resolveWorkbenchRuntimeData([mockCondition, demoWorkOrder], null, {
|
||||
operationalDate: SEPTEMBER_17
|
||||
});
|
||||
|
||||
expect(resolved.scheduledConditions).toEqual([mockCondition, demoWorkOrder]);
|
||||
expect(resolved.workOrderDataSource).toBe("none");
|
||||
});
|
||||
|
||||
it("keeps mock mode separate from the selected backend history day", () => {
|
||||
const backendAlert = {
|
||||
id: "scheduled-condition-alert-schedule-run-1",
|
||||
@@ -266,7 +276,7 @@ describe("workbench runtime data", () => {
|
||||
expect(resolved.runtimeDataSource).toBe("mock");
|
||||
});
|
||||
|
||||
it("treats an empty backend day as authoritative when backend mode is explicit", () => {
|
||||
it("keeps explicit demo appointments when an explicit backend day has no work orders", () => {
|
||||
const resolved = resolveWorkbenchRuntimeData([mockCondition, mockWorkOrder], {
|
||||
availability: "ready",
|
||||
source: "none",
|
||||
@@ -284,7 +294,7 @@ describe("workbench runtime data", () => {
|
||||
workOrders: []
|
||||
}, { operationalDate: SEPTEMBER_17, runtimeMode: "backend" });
|
||||
|
||||
expect(resolved.scheduledConditions).toEqual([mockCondition]);
|
||||
expect(resolved.scheduledConditions).toEqual([mockCondition, mockWorkOrder]);
|
||||
expect(resolved.runtimeDataSource).toBe("mock");
|
||||
expect(resolved.workOrderDataSource).toBe("backend");
|
||||
});
|
||||
@@ -369,7 +379,7 @@ describe("workbench runtime data", () => {
|
||||
|
||||
expect(resolved.workbenchAlerts).toEqual([backendAlert]);
|
||||
expect(resolved.alertDataSource).toBe("backend");
|
||||
expect(resolved.scheduledConditions).toEqual([mockCondition, backendWorkOrder]);
|
||||
expect(resolved.scheduledConditions).toEqual([mockCondition, mockWorkOrder, backendWorkOrder]);
|
||||
expect(resolved.workOrderDataSource).toBe("backend");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useState } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { env } from "@/shared/config/env";
|
||||
import {
|
||||
fetchWorkbenchRuntimeSnapshot,
|
||||
type WorkbenchRuntimeSnapshot
|
||||
} from "../api/workbench-runtime-client";
|
||||
import { fetchRealtimePipeFlows } from "../api/realtime-flow-client";
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
import { WORKBENCH_SCENARIOS } from "../data/workbench-session";
|
||||
import type { ScheduledConditionItem } from "../types";
|
||||
import { createScheduledConditionAlerts } from "../utils/scheduled-condition-alerts";
|
||||
import type { WorkbenchSyncState } from "./use-workbench-sync";
|
||||
|
||||
const HEADER_DATA_TIME_STEP_MINUTES = 5;
|
||||
const INITIAL_HEADER_DATA_TIME = "同步中";
|
||||
@@ -80,6 +80,10 @@ export function resolveWorkbenchRuntimeData(
|
||||
(condition) => condition.kind === "condition"
|
||||
&& isSameLocalOperationalDate(condition.scheduledAt, operationalDate)
|
||||
);
|
||||
const demoWorkOrders = mockConditions.filter(
|
||||
(condition) => condition.kind === "work_order"
|
||||
&& isSameLocalOperationalDate(condition.scheduledAt, operationalDate)
|
||||
);
|
||||
const backendWorkOrders = (backendSnapshot?.workOrders ?? []).filter(
|
||||
(workOrder) => isSameLocalOperationalDate(workOrder.scheduledAt, operationalDate)
|
||||
);
|
||||
@@ -103,7 +107,7 @@ export function resolveWorkbenchRuntimeData(
|
||||
);
|
||||
|
||||
return {
|
||||
scheduledConditions: [...fallbackConditions, ...resolvedWorkOrders],
|
||||
scheduledConditions: [...fallbackConditions, ...demoWorkOrders, ...resolvedWorkOrders],
|
||||
workbenchAlerts: backendAlertsConfigured
|
||||
? backendSnapshot!.alerts
|
||||
: createScheduledConditionAlerts(fallbackConditions),
|
||||
@@ -128,10 +132,13 @@ function isSameLocalOperationalDate(value: string, operationalDate: Date) {
|
||||
&& candidate.getDate() === operationalDate.getDate();
|
||||
}
|
||||
|
||||
export function useWorkbenchRuntimeData(getAccessToken?: AccessTokenProvider) {
|
||||
export function useWorkbenchRuntimeData(
|
||||
getAccessToken: AccessTokenProvider | undefined,
|
||||
sync: WorkbenchSyncState
|
||||
) {
|
||||
const [headerDataTime, setHeaderDataTime] = useState(INITIAL_HEADER_DATA_TIME);
|
||||
const [mockConditions, setMockConditions] = useState<ScheduledConditionItem[]>([]);
|
||||
const [backendSnapshot, setBackendSnapshot] = useState<WorkbenchRuntimeSnapshot | null>(null);
|
||||
const backendSnapshot: WorkbenchRuntimeSnapshot | null = sync.snapshot?.runtime ?? null;
|
||||
const [realtimePipeFlows, setRealtimePipeFlows] = useState<Awaited<
|
||||
ReturnType<typeof fetchRealtimePipeFlows>
|
||||
> | null>(null);
|
||||
@@ -168,40 +175,15 @@ export function useWorkbenchRuntimeData(getAccessToken?: AccessTokenProvider) {
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [backendSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
const refreshBackendSnapshot = async () => {
|
||||
try {
|
||||
const snapshot = await fetchWorkbenchRuntimeSnapshot(getAccessToken, controller.signal);
|
||||
if (!controller.signal.aborted) {
|
||||
setBackendSnapshot(snapshot);
|
||||
}
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setBackendSnapshot(null);
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
timeoutId = window.setTimeout(
|
||||
refreshBackendSnapshot,
|
||||
BACKEND_RUNTIME_REFRESH_INTERVAL_MS
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void refreshBackendSnapshot();
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
const refreshRealtimePipeFlows = async () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
timeoutId = window.setTimeout(refreshRealtimePipeFlows, BACKEND_RUNTIME_REFRESH_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const snapshot = await fetchRealtimePipeFlows(getAccessToken, controller.signal);
|
||||
if (!controller.signal.aborted) {
|
||||
@@ -219,9 +201,17 @@ export function useWorkbenchRuntimeData(getAccessToken?: AccessTokenProvider) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
|
||||
void refreshRealtimePipeFlows();
|
||||
};
|
||||
|
||||
void refreshRealtimePipeFlows();
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
controller.abort();
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [getAccessToken]);
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useWorkbenchScenarioAnalysis } from "./use-workbench-scenario-analysis";
|
||||
|
||||
const projectPage = {
|
||||
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
|
||||
total: 1,
|
||||
limit: 1000,
|
||||
offset: 0
|
||||
};
|
||||
|
||||
function analysis(runId: string) {
|
||||
return {
|
||||
run_id: runId,
|
||||
name: "历史爆管定位",
|
||||
run_type: "burst_location",
|
||||
run_type_label: "爆管定位",
|
||||
status: "completed",
|
||||
status_label: "已完成",
|
||||
created_by: "alice",
|
||||
created_at: "2026-09-17T02:20:00Z",
|
||||
started_at: "2026-09-17T02:10:00Z",
|
||||
results: [],
|
||||
map_payload: {
|
||||
type: "FeatureCollection",
|
||||
features: [{
|
||||
type: "Feature",
|
||||
properties: { label: "历史运行几何" },
|
||||
geometry: { type: "Point", coordinates: [121.4, 30.8] }
|
||||
}]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("selected workbench scenario analysis", () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("loads geometry for the selected backend run", async () => {
|
||||
const runId = "86fd7c19-5b75-434f-999c-ec8fdf67d10b";
|
||||
vi.stubGlobal("fetch", vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(analysis(runId)), { status: 200 })));
|
||||
|
||||
const { result, unmount } = renderHook(() => useWorkbenchScenarioAnalysis(runId));
|
||||
|
||||
await waitFor(() => expect(result.current.status).toBe("backend"));
|
||||
expect(result.current.projection?.runId).toBe(runId);
|
||||
expect(result.current.projection?.simulationOverlay?.features[0]?.properties).toEqual({
|
||||
label: "历史运行几何"
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("falls back independently when the selected run cannot be read", async () => {
|
||||
const runId = "86fd7c19-5b75-434f-999c-ec8fdf67d10b";
|
||||
vi.stubGlobal("fetch", vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ detail: "unavailable" }), { status: 503 })));
|
||||
|
||||
const { result, unmount } = renderHook(() => useWorkbenchScenarioAnalysis(runId));
|
||||
|
||||
await waitFor(() => expect(result.current.status).toBe("fallback"));
|
||||
expect(result.current.projection).toBeNull();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import {
|
||||
fetchWorkbenchAnalysisRun,
|
||||
type WorkbenchAnalysisProjection
|
||||
} from "../api/workbench-runtime-client";
|
||||
|
||||
const SCENARIO_ANALYSIS_REFRESH_INTERVAL_MS = 15_000;
|
||||
|
||||
type ScenarioAnalysisState = {
|
||||
runId: string;
|
||||
projection: WorkbenchAnalysisProjection | null;
|
||||
failed: boolean;
|
||||
};
|
||||
|
||||
export type WorkbenchScenarioAnalysis = {
|
||||
projection: WorkbenchAnalysisProjection | null;
|
||||
status: "disabled" | "loading" | "backend" | "fallback";
|
||||
};
|
||||
|
||||
export function useWorkbenchScenarioAnalysis(
|
||||
runId: string | null,
|
||||
getAccessToken?: AccessTokenProvider
|
||||
): WorkbenchScenarioAnalysis {
|
||||
const [state, setState] = useState<ScenarioAnalysisState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runId) {
|
||||
setState(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
let timeoutId: number | undefined;
|
||||
setState((current) => current?.runId === runId
|
||||
? current
|
||||
: { runId, projection: null, failed: false });
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const projection = await fetchWorkbenchAnalysisRun(
|
||||
runId,
|
||||
getAccessToken,
|
||||
controller.signal
|
||||
);
|
||||
if (!controller.signal.aborted) {
|
||||
setState({ runId, projection, failed: false });
|
||||
}
|
||||
} catch {
|
||||
if (!controller.signal.aborted) {
|
||||
setState({ runId, projection: null, failed: true });
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
timeoutId = window.setTimeout(refresh, SCENARIO_ANALYSIS_REFRESH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void refresh();
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [getAccessToken, runId]);
|
||||
|
||||
if (!runId) return { projection: null, status: "disabled" };
|
||||
if (!state || state.runId !== runId) return { projection: null, status: "loading" };
|
||||
if (state.failed) return { projection: null, status: "fallback" };
|
||||
if (state.projection) return { projection: state.projection, status: "backend" };
|
||||
return { projection: null, status: "loading" };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import {
|
||||
consumeWorkbenchEvents,
|
||||
fetchWorkbenchSyncSnapshot,
|
||||
type WorkbenchEvent,
|
||||
type WorkbenchSyncSnapshot
|
||||
} from "../api/workbench-sync-client";
|
||||
|
||||
const RECONCILE_INTERVAL_MS = 60_000;
|
||||
const RUNTIME_TOPICS = new Set<WorkbenchEvent["topic"]>([
|
||||
"workbench.alert.changed",
|
||||
"workbench.work_order.changed",
|
||||
"analysis.run.changed"
|
||||
]);
|
||||
|
||||
export type WorkbenchSyncState = {
|
||||
snapshot: WorkbenchSyncSnapshot | null;
|
||||
events: WorkbenchEvent[];
|
||||
status: "connecting" | "live" | "reconnecting";
|
||||
error: string | null;
|
||||
acknowledgeEventsThrough: (eventSequence: number) => void;
|
||||
};
|
||||
|
||||
export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): WorkbenchSyncState {
|
||||
const [snapshot, setSnapshot] = useState<WorkbenchSyncSnapshot | null>(null);
|
||||
const [events, setEvents] = useState<WorkbenchEvent[]>([]);
|
||||
const [status, setStatus] = useState<WorkbenchSyncState["status"]>("connecting");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const cursorRef = useRef(0);
|
||||
const statusRef = useRef<WorkbenchSyncState["status"]>("connecting");
|
||||
const runtimeRefreshRef = useRef<number | undefined>(undefined);
|
||||
const acknowledgeEventsThrough = useCallback((eventSequence: number) => {
|
||||
setEvents((current) => current.filter((event) => event.event_seq > eventSequence));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let reconnectTimer: number | undefined;
|
||||
let reconcileTimer: number | undefined;
|
||||
let retryMs = 1_000;
|
||||
let initialized = false;
|
||||
|
||||
const loadSnapshot = async () => {
|
||||
const next = await fetchWorkbenchSyncSnapshot(getAccessToken, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
cursorRef.current = next.eventCursor;
|
||||
setSnapshot(next);
|
||||
setError(null);
|
||||
initialized = true;
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
if (!initialized) await loadSnapshot();
|
||||
if (controller.signal.aborted) return;
|
||||
statusRef.current = "live";
|
||||
setStatus("live");
|
||||
retryMs = 1_000;
|
||||
await consumeWorkbenchEvents({
|
||||
after: cursorRef.current,
|
||||
getAccessToken,
|
||||
signal: controller.signal,
|
||||
onReset: loadSnapshot,
|
||||
onEvents: (nextEvents) => {
|
||||
const unseen = nextEvents.filter((event) => event.event_seq > cursorRef.current);
|
||||
if (unseen.length === 0) return;
|
||||
cursorRef.current = unseen.at(-1)!.event_seq;
|
||||
setEvents((current) => [...current, ...unseen]);
|
||||
if (unseen.some((event) => RUNTIME_TOPICS.has(event.topic))) {
|
||||
if (runtimeRefreshRef.current !== undefined) window.clearTimeout(runtimeRefreshRef.current);
|
||||
runtimeRefreshRef.current = window.setTimeout(() => {
|
||||
void loadSnapshot().catch((nextError) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(nextError instanceof Error ? nextError.message : "工作台快照刷新失败");
|
||||
}
|
||||
});
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!controller.signal.aborted) {
|
||||
statusRef.current = "reconnecting";
|
||||
setStatus("reconnecting");
|
||||
}
|
||||
} catch (nextError) {
|
||||
if (controller.signal.aborted) return;
|
||||
statusRef.current = "reconnecting";
|
||||
setStatus("reconnecting");
|
||||
setError(nextError instanceof Error ? nextError.message : "工作台事件连接中断");
|
||||
}
|
||||
if (!controller.signal.aborted) {
|
||||
reconnectTimer = window.setTimeout(() => { void connect(); }, retryMs);
|
||||
retryMs = Math.min(retryMs * 2, 30_000);
|
||||
}
|
||||
};
|
||||
|
||||
void connect();
|
||||
reconcileTimer = window.setInterval(() => {
|
||||
if (statusRef.current !== "live") void loadSnapshot().catch(() => undefined);
|
||||
}, RECONCILE_INTERVAL_MS);
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
|
||||
if (reconcileTimer !== undefined) window.clearInterval(reconcileTimer);
|
||||
if (runtimeRefreshRef.current !== undefined) window.clearTimeout(runtimeRefreshRef.current);
|
||||
};
|
||||
}, [getAccessToken]);
|
||||
|
||||
return { snapshot, events, status, error, acknowledgeEventsThrough };
|
||||
}
|
||||
Reference in New Issue
Block a user