fix: deduplicate workbench requests and remove unused map paths
Generic Container CI/CD / test-build-publish (push) Successful in 2m7s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m7s

This commit is contained in:
2026-11-09 18:49:32 +08:00
parent 038d3458bc
commit d7eb01652a
13 changed files with 403 additions and 321 deletions
+4 -28
View File
@@ -233,35 +233,11 @@ describe("Agent API client sessions", () => {
});
});
it("falls back when a candidate is not the agent service", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ message: "not found" }), { status: 404 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
schema_version: "agent-ui-registry@1",
chart_grammars: [],
components: [],
actions: []
}),
{ status: 200 }
)
);
it("uses the configured Agent address once and propagates its error", async () => {
const fetchMock = vi.fn().mockResolvedValue(Response.json({ message: "unavailable" }, { status: 503 }));
vi.stubGlobal("fetch", fetchMock);
await expect(createAgentApiClient(["http://not-agent.local", "http://agent.local"]).getUiRegistry()).resolves.toEqual({
schema_version: "agent-ui-registry@1",
chart_grammars: [],
components: [],
actions: []
});
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"http://not-agent.local/api/v1/ui-registry",
undefined
);
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/ui-registry", undefined);
await expect(createAgentApiClient("http://agent.local/prefix/").getUiRegistry()).rejects.toThrow("unavailable");
expect(fetchMock).toHaveBeenCalledExactlyOnceWith("http://agent.local/prefix/api/v1/ui-registry", undefined);
});
it("sends title update payloads using backend field names", async () => {
+9 -49
View File
@@ -220,24 +220,16 @@ export type AgentApiClientOptions = {
getProjectId?: () => Promise<string | undefined>;
};
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_URL.replace(/\/$/, "")];
const API_PATH = "/api/v1";
export function createAgentApiClient(
baseUrls: string | string[] = AGENT_API_BASE_URLS,
baseUrl: string = env.TJWATER_AGENT_URL,
options: AgentApiClientOptions = {}
): AgentApiClient {
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
let activeBaseUrl = candidates[0] ?? "";
const setActiveBaseUrl = (baseUrl: string) => {
activeBaseUrl = baseUrl;
};
const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
const request = (path: string, init?: RequestInit) =>
fetchWithFallback(
candidates,
activeBaseUrl,
setActiveBaseUrl,
fetchAgent(
normalizedBaseUrl,
path,
init,
options.getAccessToken,
@@ -265,7 +257,7 @@ export function createAgentApiClient(
return token;
} : undefined;
const answerStateRequest = async (path: string, init?: RequestInit) => {
const response = await fetchWithFallback(candidates, activeBaseUrl, setActiveBaseUrl, path, init,
const response = await fetchAgent(normalizedBaseUrl, path, init,
checkedAnswerStateToken, options.getProjectId);
// An identity change while awaiting the network also invalidates the result.
await checkedAnswerStateToken?.();
@@ -275,7 +267,7 @@ export function createAgentApiClient(
return {
...createInteractionRequestApi(answerStateRequest),
async getHealth(signal) {
const response = await fetchWithFallback(candidates, activeBaseUrl, setActiveBaseUrl, "/health", { signal, cache: "no-store" }, undefined, undefined, "");
const response = await fetchAgent(normalizedBaseUrl, "/health", { signal, cache: "no-store" }, undefined, undefined, "");
if (!response.ok) throw new Error("Agent 服务暂不可用");
const data: unknown = await response.json();
if (!isRecord(data) || data.ok !== true || data.ready !== true) throw new Error("Agent 服务尚未就绪");
@@ -579,19 +571,14 @@ export function createAgentApiClient(
};
}
async function fetchWithFallback(
baseUrls: string[],
activeBaseUrl: string,
setActiveBaseUrl: (baseUrl: string) => void,
async function fetchAgent(
baseUrl: string,
path: string,
init?: RequestInit,
getAccessToken?: AccessTokenProvider,
getProjectId?: () => Promise<string | undefined>,
prefix = API_PATH
) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown;
let lastResponse: Response | null = null;
const accessToken = await getAccessToken?.();
const projectId = await getProjectId?.();
const hasContextHeaders = Boolean(accessToken || projectId);
@@ -600,34 +587,7 @@ async function fetchWithFallback(
if (projectId) headers?.set("X-Project-Id", projectId);
const requestInit = hasContextHeaders ? { ...init, headers } : init;
for (const baseUrl of orderedBaseUrls) {
try {
const response = await fetch(`${baseUrl}${prefix}${path}`, requestInit);
if (response.ok) {
setActiveBaseUrl(baseUrl);
return response;
}
lastResponse = response;
if (shouldFallbackOnHttpStatus(response.status) && baseUrl !== orderedBaseUrls[orderedBaseUrls.length - 1]) {
await response.body?.cancel();
continue;
}
return response;
} catch (error) {
lastError = error;
}
}
if (lastResponse) {
return lastResponse;
}
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
}
function shouldFallbackOnHttpStatus(status: number) {
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
return fetch(`${baseUrl}${prefix}${path}`, requestInit);
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -4,7 +4,7 @@ import type { AgentApiClient } from "../api/client";
export function useAgentMessageInbox(client: AgentApiClient, sessionId: string | null) {
const inbox = useSWR(sessionId ? ["agent-message-inbox", sessionId] : null,
([, id]) => client.listAgentMessages(id), { revalidateOnFocus: true, shouldRetryOnError: false });
([, id]) => client.listAgentMessages(id), { revalidateOnMount: false, revalidateOnFocus: true, shouldRetryOnError: false });
const [loadingMore, setLoadingMore] = useState(false);
const mutate = inbox.mutate;
const refresh = useCallback(() => { void mutate().catch(() => undefined); }, [mutate]);
@@ -15,20 +15,27 @@ export function useAgentMessageInbox(client: AgentApiClient, sessionId: string |
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
let retryDelay = 1500;
let initialized = false;
// Prefer the initial stream notification, but do not wait indefinitely for its first frame.
const initialLoadTimer = setTimeout(() => { initialized = true; refreshRef.current(); }, 1500);
function subscribe() {
void client.streamAgentMessages(sessionId!, { signal: controller.signal, onMessage: () => { retryDelay = 1500; refreshRef.current(); } })
.catch(() => { retryDelay = Math.min(retryDelay * 2, 30_000); })
void client.streamAgentMessages(sessionId!, { signal: controller.signal, onMessage: () => { initialized = true; clearTimeout(initialLoadTimer); retryDelay = 1500; refreshRef.current(); } })
.catch(() => {
// A failed subscription must not prevent the initial list from loading.
if (!initialized && !controller.signal.aborted) { initialized = true; clearTimeout(initialLoadTimer); refreshRef.current(); }
retryDelay = Math.min(retryDelay * 2, 30_000);
})
.finally(() => {
if (!controller.signal.aborted) timer = setTimeout(subscribe, retryDelay);
});
}
subscribe();
return () => { controller.abort(); clearTimeout(timer); };
return () => { controller.abort(); clearTimeout(timer); clearTimeout(initialLoadTimer); };
}, [client, sessionId]);
return {
messages: inbox.data?.items ?? [],
hasMore: Boolean(inbox.data?.next_cursor),
loading: inbox.isLoading,
loading: Boolean(sessionId) && (inbox.isLoading || (!inbox.data && !inbox.error)),
error: inbox.error ? "任务消息暂时无法加载" : null,
refresh,
loadingMore,
@@ -138,8 +138,6 @@ function createCommands() {
clearValueLabel: vi.fn(),
applyLayerGroupStyle: vi.fn(),
resetLayerGroupStyle: vi.fn(),
renderScadaAnalysis: vi.fn(),
clearScadaAnalysis: vi.fn(),
resetAll: vi.fn()
} satisfies WorkbenchMapCommands;
}
@@ -163,7 +161,6 @@ function createControllerState(
invalid: 0
},
styledGroups: [],
scadaAnalysis: null,
...patch
};
}
@@ -79,7 +79,7 @@ export function useOperationalTasks(
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshGeneration, setRefreshGeneration] = useState(0);
const [eventRetryGeneration, setEventRetryGeneration] = useState(0);
const enqueueEvents = useRef<(events: WorkbenchEvent[]) => void>(() => {});
const eventRetryMsRef = useRef(1_000);
const forceRefreshRef = useRef(false);
const detailResetGenerationRef = useRef(sync.detailResetGeneration);
@@ -131,50 +131,76 @@ export function useOperationalTasks(
}, [getAccessToken, refreshGeneration, sync.detailResetGeneration, sync.snapshot]);
useEffect(() => {
const events = syncEvents;
if (events.length === 0) return;
const controller = new AbortController();
let retryTimer: number | undefined;
const lastSequence = events.at(-1)!.event_seq;
const { taskIds, deletedTaskIds, notificationsChanged } = collectOperationalTaskEventChanges(events);
if (deletedTaskIds.size > 0) {
let pendingEvents: WorkbenchEvent[] = [];
let appliedSequence = 0;
let processing = false;
const removeDeletedTasks = (deletedTaskIds: Set<string>) => {
if (deletedTaskIds.size === 0) return;
tasksRef.current = tasksRef.current.filter((task) => !deletedTaskIds.has(task.task_id));
setTasks(tasksRef.current);
setNotifications((current) => current.filter((item) => !deletedTaskIds.has(item.task_id)));
}
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);
}
acknowledgeEventsThrough(lastSequence);
eventRetryMsRef.current = 1_000;
setError(null);
}).catch((nextError) => {
if (!controller.signal.aborted) {
setError(nextError instanceof Error ? nextError.message : "工况任务增量刷新失败。");
retryTimer = window.setTimeout(() => setEventRetryGeneration((current) => current + 1), eventRetryMsRef.current);
eventRetryMsRef.current = Math.min(eventRetryMsRef.current * 2, 30_000);
}
});
};
const load = async () => {
if (processing || controller.signal.aborted) return;
processing = true;
window.clearTimeout(retryTimer);
try {
while (pendingEvents.length > 0 && !controller.signal.aborted) {
const events = pendingEvents;
const lastSequence = events.at(-1)!.event_seq;
const { taskIds, notificationsChanged } = collectOperationalTaskEventChanges(events);
const [nextNotifications, changedTasks] = await Promise.all([
notificationsChanged
? fetchOperationalTaskNotifications(getAccessToken, controller.signal)
: Promise.resolve(null),
Promise.all([...taskIds].map((taskId) =>
fetchOperationalTask(taskId, getAccessToken, controller.signal)
))
]);
if (controller.signal.aborted) return;
// A deletion received during this read must not be undone by its response.
const { deletedTaskIds } = collectOperationalTaskEventChanges(pendingEvents);
if (nextNotifications) setNotifications(nextNotifications.filter((item) => !deletedTaskIds.has(item.task_id)));
const visibleTasks = changedTasks.filter((task) => !deletedTaskIds.has(task.task_id));
if (visibleTasks.length > 0) {
const changed = new Map(visibleTasks.map((task) => [task.task_id, task]));
tasksRef.current = [
...visibleTasks,
...tasksRef.current.filter((task) => !changed.has(task.task_id))
];
setTasks(tasksRef.current);
}
appliedSequence = lastSequence;
pendingEvents = pendingEvents.filter((event) => event.event_seq > appliedSequence);
acknowledgeEventsThrough(lastSequence);
eventRetryMsRef.current = 1_000;
setError(null);
}
} catch (nextError) {
if (!controller.signal.aborted) {
setError(nextError instanceof Error ? nextError.message : "工况任务增量刷新失败。");
retryTimer = window.setTimeout(() => void load(), eventRetryMsRef.current);
eventRetryMsRef.current = Math.min(eventRetryMsRef.current * 2, 30_000);
}
} finally { processing = false; }
};
enqueueEvents.current = (events) => {
pendingEvents = events.filter((event) => event.event_seq > appliedSequence);
removeDeletedTasks(collectOperationalTaskEventChanges(pendingEvents).deletedTaskIds);
void load();
};
return () => {
controller.abort();
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
enqueueEvents.current = () => {};
window.clearTimeout(retryTimer);
};
}, [acknowledgeEventsThrough, eventRetryGeneration, getAccessToken, syncEvents]);
}, [acknowledgeEventsThrough, getAccessToken, sync.detailResetGeneration]);
useEffect(() => {
enqueueEvents.current(syncEvents);
}, [acknowledgeEventsThrough, getAccessToken, sync.detailResetGeneration, syncEvents]);
const conditions = useMemo(() => tasks.map((task) => ({ ...toOperationalTaskCondition(task),
hasNewReport: notifications.some((item) => item.task_id === task.task_id && item.kind === "report_updated"
@@ -531,12 +531,15 @@ export function useWorkbenchAgent({
() => clientRef.current.listResponseInputs(sessionId!),
{ refreshInterval: streaming ? 1000 : 0, shouldRetryOnError: false }
);
const handledRecovery = useRef({ scopeKey, version: 0 });
useEffect(() => {
if (handledRecovery.current.scopeKey === scopeKey && handledRecovery.current.version === recoveryVersion) return;
handledRecovery.current = { scopeKey, version: recoveryVersion };
if (!recoveryVersion) return;
void refreshBootstrap();
void refreshInteractionRequests();
void refreshPendingInputs();
}, [recoveryVersion, refreshBootstrap, refreshInteractionRequests, refreshPendingInputs]);
}, [scopeKey, recoveryVersion, refreshBootstrap, refreshInteractionRequests, refreshPendingInputs]);
const updateInputDelivery = useCallback(async (id: string, delivery: AgentInputDelivery) => {
if (!sessionIdRef.current) return;
try {
@@ -0,0 +1,298 @@
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { SWRConfig } from "swr";
const harness = vi.hoisted(() => ({
api: null as any,
recovery: 0,
flow: vi.fn(),
task: vi.fn(),
check: vi.fn()
}));
vi.mock("@/features/agent", async (original) => ({
...(await original<any>()),
createAgentApiClient: () => harness.api
}));
vi.mock("@/features/agent/hooks/use-agent-connection", () => ({
useAgentConnection: () => ({
connectionStatus: "online",
recoveryVersion: harness.recovery,
recheck: harness.check
})
}));
vi.mock("@/features/workbench/api/realtime-flow-client", () => ({
fetchRealtimePipeFlows: harness.flow
}));
vi.mock("@/features/workbench/api/operational-task-client", async (original) => ({
...(await original<any>()),
fetchOperationalTask: harness.task,
fetchOperationalTaskNotifications: async () => []
}));
import { useWorkbenchRuntimeData } from "@/features/workbench/hooks/use-workbench-runtime-data";
import { useWorkbenchAgent } from "@/features/workbench/hooks/use-workbench-agent";
import { useOperationalTasks } from "@/features/workbench/hooks/use-operational-tasks";
import { WorkbenchSessionProvider } from "@/features/workbench/hooks/workbench-session-provider";
import { useAgentMessageInbox } from "@/features/agent/hooks/use-agent-message-inbox";
const access: any = {
project: { project_id: "p", gs_workspace: "tjwater_next", status: "active" },
getAccessToken: async () => "test"
};
const sync: any = {
snapshot: null,
events: [],
status: "live",
error: null,
detailResetGeneration: 0,
acknowledgeEventsThrough: vi.fn(),
refresh: vi.fn(),
subscribePresentationEvents: () => () => {}
};
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
harness.flow.mockReset();
harness.task.mockReset();
harness.recovery = 0;
});
function wrapperFactory() {
const cache = new Map();
return ({ children }: any) => (
<WorkbenchSessionProvider owner={access}>
<SWRConfig
value={{ provider: () => cache, revalidateOnFocus: false, shouldRetryOnError: false }}
>
{children}
</SWRConfig>
</WorkbenchSessionProvider>
);
}
it("keeps one flow request and one polling timer when visibility changes during a request", async () => {
vi.useFakeTimers();
let visibility = "visible";
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility as any);
const done: Array<() => void> = [];
harness.flow.mockImplementation(
() => new Promise((resolve) => done.push(() => resolve({ records: [], observedAt: null })))
);
renderHook(() => useWorkbenchRuntimeData(access, sync));
expect(harness.flow).toHaveBeenCalledTimes(1);
await act(async () => {
visibility = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
visibility = "visible";
document.dispatchEvent(new Event("visibilitychange"));
});
expect(harness.flow).toHaveBeenCalledTimes(1);
await act(async () => {
done[0]();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(15000);
});
expect(harness.flow).toHaveBeenCalledTimes(2);
await act(async () => {
done[1]();
await vi.advanceTimersByTimeAsync(15000);
});
expect(harness.flow).toHaveBeenCalledTimes(3);
});
it("queues new task events without cancelling or repeating the current detail request", async () => {
const resolve: Array<(value: unknown) => void> = [];
harness.task.mockImplementation(() => new Promise((done) => resolve.push(done)));
const event = (seq: number, id: string) => ({
event_seq: seq,
topic: "operational.progress.changed",
aggregate_type: "operational_task",
aggregate_id: id,
metadata: { task_id: id }
});
const first = event(1, "task-a");
const { result, rerender, unmount } = renderHook(
({ events }) => useOperationalTasks(access, { ...sync, events }),
{ initialProps: { events: [first] } }
);
rerender({ events: [first, event(2, "task-b")] });
expect(harness.task.mock.calls.map((call) => call[0])).toEqual(["task-a"]);
expect(harness.task.mock.calls[0][2].aborted).toBe(false);
await act(async () => {
resolve[0]({
task_id: "task-a",
title: "A",
trigger: { kind: "manual" },
source: { kind: "operator" },
task_type: "event_investigation",
evidence: [],
updated_at: "2026-11-09T00:00:00Z"
});
});
expect(harness.task.mock.calls.map((call) => call[0])).toEqual(["task-a", "task-b"]);
await act(async () => {
resolve[1]({
task_id: "task-b",
title: "B",
trigger: { kind: "manual" },
source: { kind: "operator" },
task_type: "event_investigation",
evidence: [],
updated_at: "2026-11-09T00:00:00Z"
});
});
expect(result.current.tasks.map((task) => task.task_id).sort()).toEqual(["task-a", "task-b"]);
expect(sync.acknowledgeEventsThrough).toHaveBeenLastCalledWith(2);
unmount();
});
it("refreshes once per health recovery and uses normal loads on session changes", async () => {
const api: any = {
getModels: vi.fn(async () => ({ models: [], defaultModel: "" })),
getUiRegistry: vi.fn(async () => ({})),
getFrontendActionRegistry: vi.fn(async () => ({})),
listSessionPage: vi.fn(async () => ({ sessions: [], hasMore: false, nextCursor: null })),
listTopics: vi.fn(async () => []),
listInteractionRequests: vi.fn(async () => ({ items: [] })),
listResponseInputs: vi.fn(async () => []),
listAgentMessages: vi.fn(async () => ({ items: [], next_cursor: null })),
streamAgentMessages: vi.fn(
(_id: any, options: any) =>
new Promise<void>((resolve) =>
options.signal.addEventListener("abort", () => resolve(), { once: true })
)
),
loadSession: vi.fn(async (id: string) => ({
id,
sessionId: id,
title: id,
purpose: "ordinary_chat",
messages: [],
createdAt: 1,
updatedAt: 1,
runStatus: "completed",
isStreaming: false,
isTitleManuallyEdited: false
}))
};
harness.api = api;
const { result, rerender } = renderHook(
() =>
useWorkbenchAgent({
getAccessToken: access,
onUiEnvelope: () => {},
onFrontendAction: async () => ({})
}),
{ wrapper: wrapperFactory() }
);
await waitFor(() => expect(api.getModels).toHaveBeenCalledTimes(1));
await act(async () => {
await result.current.loadHistorySession("a");
});
await waitFor(() =>
expect(api.listInteractionRequests.mock.calls.filter((c: any) => c[0] === "a").length).toBe(1)
);
harness.recovery = 1;
rerender();
await waitFor(() => expect(api.getModels).toHaveBeenCalledTimes(2));
const before = api.getModels.mock.calls.length;
await act(async () => {
await result.current.loadHistorySession("b");
});
await waitFor(() => expect(api.getModels.mock.calls.length).toBe(before));
await waitFor(() =>
expect(api.listInteractionRequests.mock.calls.filter((c: any) => c[0] === "b").length).toBe(1)
);
const pending = api.listResponseInputs.mock.calls.filter((c: any) => c[0] === "b").length;
expect(pending).toBe(1);
});
it("loads the inbox once when the stream is ready and refreshes on later notifications", async () => {
const list = vi.fn(async () => ({ items: [], next_cursor: null }));
const stream = vi.fn((_id: any, options: any) => {
options.onMessage();
return new Promise<void>((resolve) =>
options.signal.addEventListener("abort", () => resolve(), { once: true })
);
});
const api = { listAgentMessages: list, streamAgentMessages: stream } as any;
renderHook(() => useAgentMessageInbox(api, "s"), { wrapper: wrapperFactory() });
await waitFor(() => expect(list).toHaveBeenCalledTimes(1));
await act(async () => {
stream.mock.calls[0][1].onMessage();
});
await waitFor(() => expect(list).toHaveBeenCalledTimes(2));
});
it("keeps newer task changes and does not revive a task deleted during a read", async () => {
const complete: Array<(value: unknown) => void> = [];
harness.task.mockImplementation(() => new Promise((resolve) => complete.push(resolve)));
const event = (seq: number, deleted = false) => ({
event_seq: seq,
topic: "operational.task.changed",
aggregate_type: "operational_task",
aggregate_id: "a",
metadata: { task_id: "a", ...(deleted ? { change: "deleted" } : {}) }
});
const a = event(1);
const { result, rerender } = renderHook(
({ events }) => useOperationalTasks(access, { ...sync, events }),
{
initialProps: { events: [a] }
}
);
rerender({ events: [a, event(2)] });
const task = {
task_id: "a",
title: "A",
trigger: { kind: "manual" },
source: { kind: "operator" },
task_type: "event_investigation",
evidence: [],
updated_at: "2026-11-09T00:00:00Z"
};
await act(async () => {
complete[0](task);
});
expect(harness.task).toHaveBeenCalledTimes(2);
rerender({ events: [a, event(2), event(3, true)] });
expect(result.current.tasks).toEqual([]);
await act(async () => {
complete[1](task);
});
await waitFor(() => expect(sync.acknowledgeEventsThrough).toHaveBeenLastCalledWith(3));
expect(result.current.tasks).toEqual([]);
expect(harness.task).toHaveBeenCalledTimes(2);
});
it("loads the initial inbox even when its stream cannot connect", async () => {
const list = vi.fn(async () => ({ items: [], next_cursor: null }));
const api = {
listAgentMessages: list,
streamAgentMessages: vi.fn(async () => {
throw new Error("offline");
})
} as any;
const { result } = renderHook(() => useAgentMessageInbox(api, "s"), {
wrapper: wrapperFactory()
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(list).toHaveBeenCalledTimes(1);
});
it("loads the inbox when its stream remains pending without a first frame", async () => {
vi.useFakeTimers();
const list = vi.fn(async () => ({ items: [], next_cursor: null }));
const api = {
listAgentMessages: list,
streamAgentMessages: vi.fn(
(_id: string, options: { signal: AbortSignal }) =>
new Promise<void>((resolve) => {
options.signal.addEventListener("abort", () => resolve(), { once: true });
})
)
} as any;
const { result } = renderHook(() => useAgentMessageInbox(api, "s"), {
wrapper: wrapperFactory()
});
expect(list).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(1500);
});
expect(list).toHaveBeenCalledTimes(1);
expect(result.current.loading).toBe(false);
});
@@ -177,12 +177,15 @@ export function useWorkbenchRuntimeData(
const controller = new AbortController();
let timeoutId: number | undefined;
let refreshing = false;
const refreshRealtimePipeFlows = async () => {
if (controller.signal.aborted || refreshing) return;
if (document.visibilityState === "hidden") {
timeoutId = window.setTimeout(refreshRealtimePipeFlows, BACKEND_RUNTIME_REFRESH_INTERVAL_MS);
return;
}
try {
refreshing = true;
const snapshot = await fetchRealtimePipeFlows(getAccessToken, controller.signal);
if (!controller.signal.aborted) {
setRealtimePipeFlows(snapshot.records.length > 0 ? snapshot : null);
@@ -190,6 +193,7 @@ export function useWorkbenchRuntimeData(
} catch {
if (!controller.signal.aborted) setRealtimePipeFlows(null);
} finally {
refreshing = false;
if (!controller.signal.aborted) {
timeoutId = window.setTimeout(
refreshRealtimePipeFlows,
@@ -956,19 +956,15 @@ function MapWorkbenchContent({
}
async function handleStartNewAgentSession() {
mapController.clearScadaAnalysis();
await agent.startNewSession();
}
async function handleLoadAgentHistorySession(sessionId: string) {
const changesSession = sessionId !== agent.sessionId;
const loaded = await agent.loadHistorySession(sessionId);
if (!loaded) throw new Error("会话打开已取消");
if (changesSession) mapController.clearScadaAnalysis();
}
async function handleOpenTaskCollaboration(taskId: string, discussion?: InvestigationDiscussion) {
mapController.clearScadaAnalysis();
const id = await agent.openTaskCollaboration(taskId, discussion);
if (discussion?.reference && id) agent.setContentReference(discussion.reference);
readingWorkspace?.close();
@@ -980,7 +976,6 @@ function MapWorkbenchContent({
target: TaskExecutionHistoryTarget,
knownRunId?: string
) {
mapController.clearScadaAnalysis();
let runId = knownRunId;
if (!runId) {
const runs = await fetchOperationalTaskRuns(taskId, getAccessToken);
@@ -999,12 +994,8 @@ function MapWorkbenchContent({
}
async function handleDeleteAgentHistorySession(sessionId: string) {
const clearsActiveAnalysis = sessionId === agent.sessionId;
const deleted = await agent.deleteHistorySession(sessionId);
if (!deleted) throw new Error("会话删除失败,请重试");
if (deleted && clearsActiveAnalysis) {
mapController.clearScadaAnalysis();
}
}
async function handleAgentMapAction(
+1 -14
View File
@@ -1,5 +1,5 @@
import type { Feature, FeatureCollection, Point } from "geojson";
import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl";
import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
import { MAP_STYLE_TOKENS } from "./map-colors";
export const SCADA_ANALYSIS_SOURCE_ID = "agent-scada-analysis";
@@ -121,19 +121,6 @@ export const scadaAnalysisLayers: Layer[] = [
}
];
export function ensureScadaAnalysisLayers(
map: Pick<MapLibreMap, "addLayer" | "getLayer">
): void {
scadaAnalysisLayers.forEach((layer, index) => {
if (map.getLayer(layer.id)) return;
const beforeId = scadaAnalysisLayers
.slice(index + 1)
.map((candidate) => candidate.id)
.find((candidateId) => Boolean(map.getLayer(candidateId)));
map.addLayer(layer, beforeId);
});
}
export function createEmptyScadaAnalysisCollection(): FeatureCollection<Point> {
return { type: "FeatureCollection", features: [] };
}
-5
View File
@@ -2,7 +2,6 @@ import type { StyleSpecification, VectorSourceSpecification } from "maplibre-gl"
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { SCADA_ANALYSIS_SOURCE_ID, createEmptyScadaAnalysisCollection } from "./scada-analysis";
import { SCADA_SOURCE_ID } from "./scada";
export const WATER_NETWORK_GLOBAL_VIEW = {
@@ -325,10 +324,6 @@ export function createBaseStyle(mapboxToken?: string, tiandituToken?: string): S
type: "geojson",
promoteId: "device_id",
data: { type: "FeatureCollection", features: [] }
},
[SCADA_ANALYSIS_SOURCE_ID]: {
type: "geojson",
data: createEmptyScadaAnalysisCollection()
}
};
const layers: StyleSpecification["layers"] = [
@@ -20,29 +20,23 @@ describe("WorkbenchMapController", () => {
expect(map.easeTo).not.toHaveBeenCalled();
expect(controller.getSnapshot().targets).toEqual([]);
});
it("clears every controller overlay, preserves layer styles, and invalidates pending analysis", async () => {
it("clears controller overlays and preserves layer styles", async () => {
const map = createMap();
map.getLayer.mockReturnValue({} as never);
const setData = vi.fn();
map.getSource.mockReturnValue({ setData });
let resolveFeatures = (_value: FeatureCollection) => {};
const pendingAnalysis = new Promise<FeatureCollection>((resolve) => { resolveFeatures = resolve; });
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap, isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: (source) => source === "scada" ? pendingAnalysis : Promise.resolve(pointCollection)
fetchFeatures: async () => pointCollection
});
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
controller.applyLayerGroupStyle("pipes", { color: "#123456" });
const showingFlow = controller.setFlowVisible(true);
const analysis = controller.renderScadaAnalysis([{ device_id: "J-1", level: "high" }]);
controller.clearOverlays();
await showingFlow;
resolveFeatures(pointCollection);
await expect(analysis).rejects.toThrow("ACTION_SUPERSEDED");
expect(setData).toHaveBeenCalledWith({ type: "FeatureCollection", features: [] });
expect(controller.getSnapshot()).toMatchObject({
target: null, targets: [], targetResult: null, scadaAnalysis: null,
target: null, targets: [], targetResult: null,
flowVisible: false, pending: false, valueLabels: {}, styledGroups: ["pipes"]
});
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({ id: "J-1" }), "highlighted");
@@ -552,37 +546,7 @@ describe("WorkbenchMapController", () => {
expect(map.removeFeatureState).not.toHaveBeenCalled();
});
it("renders SCADA analysis through the registered GeoJSON source", async () => {
const map = createMap();
const source = { setData: vi.fn() };
map.getSource.mockReturnValue(source);
const controller = createController(map, {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Point", coordinates: [120.7, 28] },
properties: { device_id: "MP01" }
}
]
});
await expect(
controller.renderScadaAnalysis([{ device_id: "MP01", level: "high" }])
).resolves.toMatchObject({
rendered_ids: ["MP01"],
fitted: true
});
expect(source.setData).toHaveBeenCalled();
expect(controller.getSnapshot()).toMatchObject({
errorCode: null,
pending: false,
scadaAnalysis: {
rendered_ids: ["MP01"],
fitted: true
}
});
});
});
const pointCollection: FeatureCollection = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { id: "J-1" } }] };
@@ -1,5 +1,5 @@
import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
import type { GeoJSONSource, Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
import { PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
import {
@@ -36,14 +36,7 @@ import {
type ValueLabelSourceId,
type ValueLabelStyle
} from "./value-label";
import {
SCADA_ANALYSIS_SOURCE_ID,
createEmptyScadaAnalysisCollection,
createScadaAnalysisCollection,
ensureScadaAnalysisLayers,
type ScadaAnalysisItem,
type ScadaAnalysisRenderResult
} from "./scada-analysis";
export type FeatureTarget = { sourceId: WaterNetworkSourceId; featureId: string };
export type FeatureBatchTarget = { sourceId: WaterNetworkSourceId; featureIds: string[] };
@@ -56,7 +49,6 @@ export type WorkbenchMapErrorCode =
| "INVALID_FEATURE_IDS"
| "WFS_UNAVAILABLE"
| "SCADA_API_UNAVAILABLE"
| "SCADA_FEATURES_NOT_FOUND"
| "ACTION_SUPERSEDED"
| "FLOW_UNAVAILABLE"
| "INVALID_FLOW_CALCULATION"
@@ -72,7 +64,6 @@ export type WorkbenchMapControllerState = {
flowVisible: boolean;
flowSummary: PipeFlowSummary;
styledGroups: LayerGroupId[];
scadaAnalysis: ScadaAnalysisRenderResult | null;
};
export type ValueLabelRequest = Omit<ValueLabelStyle, "unit"> & { unit?: string };
@@ -89,8 +80,6 @@ export type WorkbenchMapCommands = {
clearValueLabel: (sourceId?: ValueLabelSourceId) => void;
applyLayerGroupStyle: (groupId: LayerGroupId, patch: LayerGroupStylePatch) => void;
resetLayerGroupStyle: (groupId: LayerGroupId) => void;
renderScadaAnalysis: (items: ScadaAnalysisItem[], signal?: AbortSignal) => Promise<ScadaAnalysisRenderResult>;
clearScadaAnalysis: () => { cleared: true };
resetAll: () => void;
};
@@ -114,15 +103,13 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
errorCode: null,
flowVisible: false,
flowSummary: createEmptyPipeFlowSummary(),
styledGroups: [],
scadaAnalysis: null
styledGroups: []
};
private listeners = new Set<() => void>();
private cachedFeatures = new Map<string, Feature>();
private flowState: FlowOverlayState = createFlowOverlayState();
private flowUpdateRevision = 0;
private flowVisibilityRevision = 0;
private scadaAnalysisRevision = 0;
private targetRequestRevision = 0;
private pendingTargetSourceId: WaterNetworkSourceId | null = null;
private availableSourceIds: ReadonlySet<WaterNetworkSourceId> | null = null;
@@ -399,125 +386,13 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
this.patchState({ styledGroups: this.state.styledGroups.filter((item) => item !== groupId), errorCode: null });
}
async renderScadaAnalysis(
items: ScadaAnalysisItem[],
signal?: AbortSignal
): Promise<ScadaAnalysisRenderResult> {
const revision = ++this.scadaAnalysisRevision;
const map = this.options.getMap();
if (!map || !this.options.isReady()) {
this.setError("MAP_NOT_READY");
throw new Error("MAP_NOT_READY");
}
this.patchState({ pending: true, errorCode: null });
let sourceCollection: FeatureCollection;
try {
sourceCollection = await this.fetchFeatures(
"scada",
items.map((item) => item.device_id),
signal
);
} catch {
if (revision === this.scadaAnalysisRevision) {
this.patchState({ pending: false, errorCode: "SCADA_API_UNAVAILABLE" });
}
throw new Error(
revision === this.scadaAnalysisRevision
? "SCADA_API_UNAVAILABLE"
: "ACTION_SUPERSEDED"
);
}
if (revision !== this.scadaAnalysisRevision) {
throw new Error("ACTION_SUPERSEDED");
}
const next = createScadaAnalysisCollection(sourceCollection, items);
if (next.collection.features.length === 0) {
this.patchState({
pending: false,
errorCode: "SCADA_FEATURES_NOT_FOUND"
});
throw new Error("SCADA_FEATURES_NOT_FOUND");
}
const source = map.getSource(
SCADA_ANALYSIS_SOURCE_ID
) as GeoJSONSource | undefined;
if (!source) {
this.patchState({ pending: false, errorCode: "MAP_NOT_READY" });
throw new Error("MAP_NOT_READY");
}
try {
ensureScadaAnalysisLayers(map);
source.setData(next.collection);
} catch {
this.patchState({ pending: false, errorCode: "MAP_NOT_READY" });
throw new Error("MAP_NOT_READY");
}
const result: ScadaAnalysisRenderResult = {
...next.result,
fitted: true
};
this.patchState({
pending: false,
errorCode: null,
scadaAnalysis: result
});
const coordinates = next.collection.features.map(
(feature) => feature.geometry.coordinates
);
if (coordinates.length === 1) {
map.easeTo({
center: [coordinates[0][0], coordinates[0][1]],
zoom: 18,
padding: this.options.getPadding(),
duration: this.options.reducedMotion?.() ? 0 : 600
});
} else {
const bounds = coordinates.reduce<[number, number, number, number]>(
(value, coordinate) => [
Math.min(value[0], coordinate[0]),
Math.min(value[1], coordinate[1]),
Math.max(value[2], coordinate[0]),
Math.max(value[3], coordinate[1])
],
[Infinity, Infinity, -Infinity, -Infinity]
);
map.fitBounds(
[
[bounds[0], bounds[1]],
[bounds[2], bounds[3]]
],
{
padding: this.options.getPadding(),
maxZoom: 18,
duration: this.options.reducedMotion?.() ? 0 : 600
}
);
}
return result;
}
clearScadaAnalysis() {
this.scadaAnalysisRevision += 1;
const map = this.options.getMap();
(map?.getSource(SCADA_ANALYSIS_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
createEmptyScadaAnalysisCollection()
);
this.patchState({ scadaAnalysis: null, pending: false, errorCode: null });
return { cleared: true as const };
}
clearOverlays = () => {
this.clearHighlight();
this.clearValueLabel();
this.clearScadaAnalysis();
this.flowVisibilityRevision += 1;
const map = this.options.getMap();
if (map) hideFlowOverlay(map, this.flowState);
this.patchState({ flowVisible: false });
this.patchState({ flowVisible: false, pending: false, errorCode: null });
};
resetAll = () => {
@@ -536,8 +411,7 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
errorCode: null,
flowVisible: false,
flowSummary: createEmptyPipeFlowSummary(),
styledGroups: [],
scadaAnalysis: null
styledGroups: []
});
};