Compare commits

..
Author SHA1 Message Date
jiang d7eb01652a 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
2026-11-09 18:49:32 +08:00
jiang 038d3458bc feat(map): manage agent overlays in layer panel 2026-11-09 17:59:36 +08:00
jiang 692bbf609c refactor: consolidate requests and finalize agent tool states
Share project JSON transport, interaction replies and report selectors.
Settle tool progress consistently for direct streams, reconnects and history, preserving failure reasons and stopped state.
2026-11-09 17:55:00 +08:00
jiang ad1f7ca7a9 fix(charts): move floating windows without per-event renders
Generic Container CI/CD / test-build-publish (push) Successful in 1m20s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m20s
The earlier placement fix exposed chart headings but retained react-rnd state updates on every mousemove, so dragging still stalled with several charts open. Move the active window through pointer capture and a frame-scheduled DOM translation, then commit its bounds once on release.

Cover cancellation, resize and restore controls, and eight-window dragging. The regression records 40 React transform writes on the old code and one on the fix while other windows and chart SVGs stay unchanged.
2026-11-08 04:16:42 +08:00
jiang aa9b12662e fix(tasks): accept execution scope in task responses
Generic Container CI/CD / test-build-publish (push) Successful in 1m10s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m10s
Server responses now require execution_scope, but the strict frontend schema rejected it and prevented investigation and appointment loading. Declare it in the shared response schema and align task fixtures; regression coverage exercises summaries, details and both task categories.
2026-11-08 03:44:25 +08:00
jiang b948551c1f fix(map): stabilize notifications during map navigation
Generic Container CI/CD / test-build-publish (push) Successful in 1m13s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m13s
Ignore obsolete tile cancellations and update source notices without dismissing their existing toast. Keep notice input above the task ticker and disable swipe removal.

The remaining vertical jitter came from Sonner exit offsets being applied on top of flow positioning. Let flow own geometry and retain opacity transitions; add frame-sampled pan and recovery regression coverage.
2026-11-08 03:23:00 +08:00
75 changed files with 1714 additions and 946 deletions
+1
View File
@@ -89,6 +89,7 @@ pnpm format # 使用 Prettier 格式化文件
pnpm test # 运行 Vitest 单元测试
pnpm test:watch # 监听模式运行单元测试
pnpm test:browser # 运行 Playwright 浏览器测试
pnpm check:contracts # 在三端集成工作区只读检查前端生成的 Agent/Server 契约
```
## Docker
+3 -1
View File
@@ -56,7 +56,9 @@ Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、
通知沿用 `glass-transient` 亚克力透明材质、12px 圆角、背景模糊与浮层阴影,复用工作台透明度、字体和语义色状态图标。标题、正文、操作与关闭区域分开排布,长文本自然换行。操作区使用宝石蓝入口,并提示“保留至手动关闭”;点击查看不移除通知。
通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。每条通知均可单独关闭;支持 320px 窄屏。普通信息与成功提示默认 3.2 秒后关闭,警告与错误默认手动关闭,加载通知由其所属操作更新或结束。带操作入口时始终提供手动关闭按钮。Dev 的“通知测试”提供五种状态、加载完成、报告与任务入口、长文本、连续六条通知和清除测试通知。
通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。通知采用普通文档流纵向排版,间距为 8px,高度随换行自适应;保留 Sonner 淡入淡出,禁用按绝对定位计算的位移变换,避免退出时重复叠加堆叠偏移。每条通知均可单独关闭;支持 320px 窄屏。普通信息与成功提示默认 3.2 秒后关闭,警告与错误默认手动关闭,加载通知由其所属操作更新或结束。带操作入口时始终提供手动关闭按钮。Dev 的“通知测试”提供五种状态、加载完成、报告与任务入口、长文本、连续六条通知和清除测试通知。
通知表面优先接收鼠标与滚轮,不将操作穿透给下方任务浮动条或地图;不提供滑动移除,关闭通过关闭按钮完成,通知外仍可操作地图。地图拖动、缩放引起的旧瓦片请求取消不视为数据源故障;首次正常就绪保持静默,故障恢复时才显示连接成功提示。同一编号的通知更新内容时保留原位置,不先关闭再重建;所属组件离开或编号变化时才清理。
## 2. Tailwind 定制方式
+1
View File
@@ -10,6 +10,7 @@
"scripts": {
"dev": "vite",
"export:answer-ui-contract": "node scripts/export-answer-ui-contract.mjs",
"check:contracts": "node scripts/export-answer-ui-contract.mjs --check && node scripts/export-view-command-contract.mjs --check",
"build": "tsc --noEmit && vite build && pnpm build:verify-css && pnpm build:tts",
"build:verify-css": "node scripts/verify-production-css.mjs",
"build:tts": "esbuild server/edge-tts-server.ts --bundle --platform=node --format=cjs --outfile=dist-server/edge-tts-server.cjs",
+12 -5
View File
@@ -1,11 +1,13 @@
import process from "node:process";
import { writeFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { zodToJsonSchema } from "zod-to-json-schema";
import { answerUiCatalog, ANSWER_UI_VERSION } from "../src/features/agent/answer-ui/catalog.ts";
const targets = process.argv[2]
? [resolve(process.argv[2])]
const check = process.argv.includes("--check");
const destination = process.argv.slice(2).find((arg) => arg !== "--check");
const targets = destination
? [resolve(destination)]
: [
resolve("../next-tjwater-agent/src/chat/answer-ui-catalog.json"),
resolve("../next-tjwater-server/contracts/answer-ui-catalog.json")
@@ -23,7 +25,12 @@ const contract = {
])
)
};
const content = `${JSON.stringify(contract, null, 2)}\n`;
for (const target of targets) {
await writeFile(target, `${JSON.stringify(contract, null, 2)}\n`);
process.stdout.write(`Exported ${contract.components.length} components to ${target}\n`);
if (check) {
if (await readFile(target, "utf8") !== content) throw new Error(`Answer UI contract differs: ${target}`);
} else {
await writeFile(target, content);
}
process.stdout.write(`${check ? "Checked" : "Exported"} ${contract.components.length} components ${check ? "in" : "to"} ${target}\n`);
}
+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 () => {
+13 -49
View File
@@ -121,6 +121,7 @@ export type AgentSessionStreamDataEventType =
| "todo_update"
| "permission_request"
| "permission_response"
| "run_status"
| "interaction_request"
| "question_request"
| "question_response"
@@ -131,6 +132,7 @@ export type AgentSessionStreamDataEventType =
| "ui_envelope"
| "session_title"
| "done"
| "aborted"
| "error"
| "auth_required"
| "credential_refresh_required"
@@ -218,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,
@@ -263,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?.();
@@ -273,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 服务尚未就绪");
@@ -577,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);
@@ -598,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> {
@@ -837,6 +799,7 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
value === "execution_plan" ||
value === "permission_request" ||
value === "permission_response" ||
value === "run_status" ||
value === "interaction_request" ||
value === "question_request" ||
value === "question_response" ||
@@ -847,6 +810,7 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
value === "ui_envelope" ||
value === "session_title" ||
value === "done" ||
value === "aborted" ||
value === "error" ||
value === "credential_refresh_required" ||
value === "credential_refreshed" ||
@@ -1066,6 +1066,11 @@ function BackendMessage({
<AgentUserMessageText content={message.content} />
)
) : null}
{message.role === "assistant" && message.runError && message.content !== `错误:${message.runError}` ? (
<p role="status" className="whitespace-pre-wrap break-words text-sm text-destructive">
错误:{message.runError}
</p>
) : null}
{message.role === "assistant" && processComplete && hasProcessError ? (
<p role="status" className="text-xs text-[var(--status-warning-foreground)]">
部分查询或工具未完成,可展开处理过程查看原因。
@@ -1,9 +1,10 @@
import { CheckCircle2, LoaderCircle, XCircle } from "lucide-react";
import { Ban, CheckCircle2, LoaderCircle, XCircle } from "lucide-react";
import { StatusIcon, type StatusTone } from "@/shared/ui/status";
import type { AgentChatProgress } from "../types";
const states = {
completed: { label: "已完成", tone: "success", Icon: CheckCircle2 },
cancelled: { label: "已中止", tone: "neutral", Icon: Ban },
error: { label: "失败", tone: "danger", Icon: XCircle },
running: { label: "进行中", tone: "info", Icon: LoaderCircle }
} satisfies Record<
@@ -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,
+1
View File
@@ -55,6 +55,7 @@ export {
cancelRunningTodos,
completeRunningProgress,
finalizeAssistantMessageAfterAbort,
finalizeAssistantMessageAfterError,
toTodoUpdate,
upsertPermission,
upsertProgress,
+3 -1
View File
@@ -73,6 +73,8 @@ describe("agent session state", () => {
requestId: "permission-1",
status: "approved_once"
});
expect(applyPermissionResponse(permissions, { request_id: "permission-1", status: "error", error: "native unavailable" })?.[0])
.toMatchObject({ status: "error", error: "native unavailable" });
});
it("dedupes question requests by tool call id", () => {
@@ -152,7 +154,7 @@ describe("agent session state", () => {
const next = finalizeAssistantMessageAfterAbort(message);
expect(next.content).toBe("请求已中止。");
expect(next.progress?.[0].status).toBe("completed");
expect(next.progress?.[0].status).toBe("cancelled");
expect(next.permissions?.[0].status).toBe("aborted");
});
});
+21 -6
View File
@@ -47,7 +47,10 @@ export function upsertProgress(progress: AgentChatProgress[] | undefined, data:
return next;
}
export function completeRunningProgress(progress: AgentChatProgress[] | undefined) {
export function completeRunningProgress(
progress: AgentChatProgress[] | undefined,
status: "completed" | "error" | "cancelled" = "completed"
) {
return progress?.map((item) => {
if (item.status !== "running") {
return item;
@@ -56,7 +59,7 @@ export function completeRunningProgress(progress: AgentChatProgress[] | undefine
const endedAt = Date.now();
return {
...item,
status: "completed" as const,
status,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
@@ -154,9 +157,9 @@ export function applyPermissionResponse(
permission.requestId === requestId
? {
...permission,
status: toPermissionStatus(readString(payload.reply)),
status: payload.status === "error" ? "error" as const : toPermissionStatus(readString(payload.reply)),
repliedAt: Date.now(),
error: undefined
error: payload.status === "error" ? readString(payload.error) : undefined
}
: permission
);
@@ -235,7 +238,7 @@ export function applyQuestionResponse(questions: AgentQuestionRequest[] | undefi
}
export function finalizeAssistantMessageAfterAbort(message: AgentChatMessage): AgentChatMessage {
const progress = completeRunningProgress(message.progress);
const progress = completeRunningProgress(message.progress, "cancelled");
const todos = cancelRunningTodos(message.todos);
return {
@@ -266,6 +269,18 @@ export function finalizeAssistantMessageAfterAbort(message: AgentChatMessage): A
};
}
export function finalizeAssistantMessageAfterError(message: AgentChatMessage, reason: string): AgentChatMessage {
const settled = finalizeAssistantMessageAfterAbort(message);
return {
...settled,
content: message.content || `错误:${reason}`,
runError: reason,
progress: settled.progress?.map((item, index) => message.progress?.[index].status === "running"
? { ...item, status: "error", detail: item.detail ? `${item.detail}\n${reason}` : reason }
: item)
};
}
function toPermissionStatus(reply: string | undefined): AgentPermissionStatus {
if (reply === "always") {
return "approved_always";
@@ -340,7 +355,7 @@ function toToolRef(value: unknown): AgentInteractionToolRef | undefined {
}
function readProgressStatus(value: unknown): AgentChatProgress["status"] {
return value === "completed" || value === "error" ? value : "running";
return value === "completed" || value === "error" || value === "cancelled" ? value : "running";
}
function readTodoStatus(value: unknown): AgentTodoItem["status"] | undefined {
+2 -1
View File
@@ -2,6 +2,7 @@ export type AgentChatMessage = {
id: string;
role: "user" | "assistant";
content: string;
runError?: string;
answerParts?: import("./answer-ui/contract").AnswerUiPart[];
finalAnswer?: { content: string; uiBlockIds: string[] };
progress?: AgentChatProgress[];
@@ -24,7 +25,7 @@ export type AgentChatProgress = {
tool?: { name: string; callId?: string; input?: Record<string, unknown> };
id: string;
phase: string;
status: "running" | "completed" | "error";
status: "running" | "completed" | "error" | "cancelled";
title: string;
detail?: string;
startedAt?: number;
@@ -46,17 +46,10 @@ type LayerVisibilitySectionProps = {
function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySectionProps) {
const groups = groupLayerItems(items);
const [openGroupIds, setOpenGroupIds] = useState(
() => new Set(groups.filter((group) => group.defaultOpen).map((group) => group.id))
);
const [groupOpenStates, setGroupOpenStates] = useState<Record<string, boolean>>({});
const handleGroupToggle = (groupId: string, open: boolean) => {
setOpenGroupIds((current) => {
const next = new Set(current);
if (open) next.add(groupId);
else next.delete(groupId);
return next;
});
setGroupOpenStates((current) => ({ ...current, [groupId]: open }));
};
return (
@@ -64,7 +57,7 @@ function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySection
{groups.map((group) => (
<details
key={group.id}
open={openGroupIds.has(group.id)}
open={groupOpenStates[group.id] ?? group.defaultOpen}
onToggle={(event) => handleGroupToggle(group.id, event.currentTarget.open)}
className="surface-well group/layer overflow-hidden rounded-xl border"
>
@@ -0,0 +1,52 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { dismissMapNotice, showMapNotice } from "./notice-actions";
import { MapErrorNotice, MapLoadingNotice, MapSourceStatusNotice } from "./notice";
vi.mock("./notice-actions", () => ({ showMapNotice: vi.fn(), dismissMapNotice: vi.fn() }));
afterEach(() => { cleanup(); vi.clearAllMocks(); });
it("keeps initial healthy sources quiet and announces recovery once", () => {
const { rerender, unmount } = render(<MapSourceStatusNotice sourceName="管网" status="online" message="已就绪" />);
expect(showMapNotice).not.toHaveBeenCalled();
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="瓦片已加载" />);
expect(showMapNotice).not.toHaveBeenCalled();
rerender(<MapSourceStatusNotice sourceName="管网" status="offline" message="连接失败" />);
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "error", message: "连接失败" }));
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="已恢复" />);
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "success", duration: 2600 }));
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="全部瓦片已加载" />);
expect(showMapNotice).toHaveBeenCalledTimes(2);
expect(dismissMapNotice).not.toHaveBeenCalled();
unmount();
expect(dismissMapNotice).toHaveBeenCalledOnce();
});
it("updates a degraded source in place and only removes it when unmounted", () => {
const { rerender, unmount } = render(<MapSourceStatusNotice id="source" sourceName="管网" status="degraded" message="部分不可用" />);
rerender(<MapSourceStatusNotice id="source" sourceName="管网" status="degraded" message="正在重试" />);
rerender(<MapSourceStatusNotice id="source" sourceName="管网" status="online" message="已恢复" />);
expect(showMapNotice).toHaveBeenCalledTimes(3);
expect(dismissMapNotice).not.toHaveBeenCalled();
unmount();
expect(dismissMapNotice).toHaveBeenCalledWith("source");
});
it("updates map errors without cancelling the updated notice and cleans up a replaced id", () => {
const { rerender, unmount } = render(<MapErrorNotice id="old" message="连接失败" />);
rerender(<MapErrorNotice id="old" message="正在重新连接" />);
expect(dismissMapNotice).not.toHaveBeenCalled();
rerender(<MapErrorNotice id="new" message="另一项错误" />);
expect(dismissMapNotice).toHaveBeenCalledExactlyOnceWith("old");
unmount();
expect(dismissMapNotice).toHaveBeenLastCalledWith("new");
});
it("keeps loading visible while its title or message changes", () => {
const { rerender, unmount } = render(<MapLoadingNotice id="loading" message="加载底图" />);
rerender(<MapLoadingNotice id="loading" title="准备管网" message="加载业务图层" />);
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ id: "loading", title: "准备管网", duration: Infinity }));
expect(dismissMapNotice).not.toHaveBeenCalled();
unmount();
expect(dismissMapNotice).toHaveBeenCalledWith("loading");
});
+20 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, type CSSProperties } from "react";
import { useEffect, useRef, type CSSProperties } from "react";
import { Toaster } from "sonner";
import { dismissMapNotice, showMapNotice } from "./notice-actions";
import type { MapNoticeTone } from "./notice-types";
@@ -17,10 +17,11 @@ export function MapToaster() {
position="top-center"
offset={{ top: 84, right: 16, bottom: 48, left: 16 }}
mobileOffset={{ top: 84, right: 12, bottom: 36, left: 12 }}
swipeDirections={[]}
toastOptions={{
unstyled: true,
classNames: {
toast: "group pointer-events-none",
toast: "group pointer-events-auto",
closeButton:
"pointer-events-auto absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-[var(--ink-muted)] after:absolute after:-inset-1 after:content-[''] hover:bg-[var(--surface-reading)]/70 hover:text-slate-700"
}
@@ -30,6 +31,7 @@ export function MapToaster() {
}
export function MapErrorNotice({ message, id = "map-error" }: { message: string; id?: string | number }) {
useNoticeCleanup(id);
useEffect(() => {
showMapNotice({
id,
@@ -38,8 +40,6 @@ export function MapErrorNotice({ message, id = "map-error" }: { message: string;
message,
duration: Infinity
});
return () => dismissMapNotice(id);
}, [id, message]);
return null;
@@ -54,6 +54,7 @@ export function MapLoadingNotice({
title?: string;
id?: string | number;
}) {
useNoticeCleanup(id);
useEffect(() => {
showMapNotice({
id,
@@ -63,8 +64,6 @@ export function MapLoadingNotice({
duration: Infinity,
dismissible: false
});
return () => dismissMapNotice(id);
}, [id, message, title]);
return null;
@@ -89,7 +88,16 @@ export function MapSourceStatusNotice({
actionLabel,
onAction
}: MapSourceStatusNoticeProps) {
const previous = useRef<{ id: string | number; status: MapSourceStatus } | null>(null);
useNoticeCleanup(id);
useEffect(() => {
const last = previous.current;
previous.current = { id, status };
// Initial healthy sources and repeated tile-load messages stay quiet.
// Success is announced when the same source recovers from a problem.
if (status === "online" && !(actionLabel && onAction)
&& (!last || last.id !== id || last.status === "online")) return;
showMapNotice({
id,
tone: getSourceStatusTone(status),
@@ -99,13 +107,17 @@ export function MapSourceStatusNotice({
actionLabel,
onAction
});
return () => dismissMapNotice(id);
}, [actionLabel, id, message, onAction, sourceName, status]);
return null;
}
function useNoticeCleanup(id: string | number) {
// Content updates reuse the toast id. Dismissing during an update races
// Sonner's asynchronous removal against the newly published content.
useEffect(() => () => dismissMapNotice(id), [id]);
}
function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone {
if (status === "online") {
return "success";
@@ -1,13 +1,7 @@
import { getWorkbenchAccessToken } from "./request-context";
import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import type { WaterNetworkSourceId } from "../map/sources";
import {
createWorkbenchHeaders,
findOwnedWorkbenchProject,
getServerError
} from "./project-client";
const gisAttributeGroupSchema = z.object({
dataset_key: z.string().min(1),
@@ -43,7 +37,6 @@ const sourceGisFeaturePropertiesSchema = z.object({
export type GisAttributeGroup = z.infer<typeof gisAttributeGroupSchema>;
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function fetchElementGisProperties(
elementType: WaterNetworkSourceId,
@@ -52,26 +45,15 @@ export async function fetchElementGisProperties(
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise<GisAttributeGroup[]> {
const ownedProject = projectId
? null
: await findOwnedWorkbenchProject(getAccessToken, signal);
const accessToken = ownedProject?.accessToken ?? await getWorkbenchAccessToken(getAccessToken);
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到 GIS 台账所属项目");
const path = [
"/api/v1/elements",
encodeURIComponent(elementType),
encodeURIComponent(elementId),
"gis-properties"
].join("/");
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, {
headers: createWorkbenchHeaders(accessToken, resolvedProjectId),
signal
const payload = await requestWorkbenchJson(path, "GIS 台账属性查询", {
access: getAccessToken, projectId, init: { signal }
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "GIS 台账属性查询"));
}
const result = elementGisPropertiesSchema.parse(payload);
if (result.element_type !== elementType || result.element_id !== elementId) {
throw new Error("GIS 台账属性与所选元素不一致");
@@ -86,12 +68,6 @@ export async function fetchSourceGisFeatureProperties(
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise<GisAttributeGroup[]> {
const ownedProject = projectId
? null
: await findOwnedWorkbenchProject(getAccessToken, signal);
const accessToken = ownedProject?.accessToken ?? await getWorkbenchAccessToken(getAccessToken);
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到 GIS 台账所属项目");
const path = [
"/api/v1/source-gis/layers",
encodeURIComponent(layerKey),
@@ -99,14 +75,9 @@ export async function fetchSourceGisFeatureProperties(
encodeURIComponent(String(sourceFid)),
"properties"
].join("/");
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, {
headers: createWorkbenchHeaders(accessToken, resolvedProjectId),
signal
const payload = await requestWorkbenchJson(path, "源 GIS 属性查询", {
access: getAccessToken, projectId, init: { signal }
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "源 GIS 属性查询"));
}
const result = sourceGisFeaturePropertiesSchema.parse(payload);
if (result.layer_key !== layerKey || result.source_fid !== sourceFid) {
throw new Error("源 GIS 属性与所选要素不一致");
@@ -21,6 +21,7 @@ function task(generation: number | null | undefined = 2) {
const snapshot = { revision: 1, source_session_id: "session", published_at: time, published_by: "agent",
...(generation === undefined ? {} : { execution_generation: generation }) };
return {
execution_scope: "production",
project_id: id, task_id: id, task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null,
status: "waiting_human", priority: "normal", revision: 1, execution_generation: 2,
@@ -42,6 +43,28 @@ function task(generation: number | null | undefined = 2) {
afterEach(() => vi.unstubAllGlobals());
describe("operational task contract", () => {
it.each(["event_investigation", "external_work_order", "scheduled_flow"])(
"loads %s summaries and details with the Server execution scope", (taskType) => {
const detail = { ...task(), task_type: taskType, execution_scope: "production" };
const {
latest_plan: _latestPlan, latest_report: _latestReport, evidence: _evidence,
actions: _actions, permissions: _permissions, ...summary
} = detail;
expect(parseOperationalTaskSummary(summary)).toHaveProperty("execution_scope", "production");
expect(parseOperationalTask(detail)).toHaveProperty("execution_scope", "production");
}
);
it.each([undefined, null, "", 1])("rejects an invalid execution scope %j", (scope) => {
const detail = { ...task(), execution_scope: scope };
const {
latest_plan: _latestPlan, latest_report: _latestReport, evidence: _evidence,
actions: _actions, permissions: _permissions, ...summary
} = detail;
expect(() => parseOperationalTaskSummary(summary)).toThrow();
expect(() => parseOperationalTask(detail)).toThrow();
});
describe.each(["event_investigation", "external_work_order", "scheduled_flow"])("%s replay context", (taskType) => {
const replayContext = { replay_id: id, knowledge_cutoff: time, batch_sequence: 1 };
@@ -170,6 +193,7 @@ describe("operational task contract", () => {
it("keeps unknown fields and invalid generations rejected", () => {
expect(() => parseOperationalTask(task(0))).toThrow();
const value = task();
expect(() => parseOperationalTask({ ...value, unexpected: true })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_plan: { ...value.latest_plan, unexpected: true } })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, unexpected: true } })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, execution_generation: -1 } })).toThrow();
@@ -1,9 +1,8 @@
import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import { investigationReportSchema, investigationDiagramSchema } from "./investigation-report";
import { investigationPresentationCursorSchema, investigationPresentationHistoryPageSchema, type InvestigationPresentationHistoryEvent } from "./investigation-presentation";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const taskStatusSchema = z.enum(["queued", "running", "waiting_human", "completed", "failed", "cancelled"]);
const taskPrioritySchema = z.enum(["normal", "urgent"]);
@@ -50,6 +49,7 @@ const operationalTaskRunPageSchema = z.object({
next_cursor: z.string().min(1).nullable()
}).strict();
const taskSummarySchema = z.object({
execution_scope: z.string().min(1),
handling_context: z.record(z.unknown()).optional(),
project_id: z.string().uuid(),
task_id: z.string().uuid(),
@@ -431,16 +431,9 @@ export type CreateOperationalTaskInput = {
agent_session_id?: string;
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
async function operationalTaskRequest(path: string, getAccessToken?: WorkbenchAccess, init?: RequestInit) {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, init?.signal ?? undefined);
const headers = new Headers(createWorkbenchHeaders(accessToken, project.project_id));
if (init?.body) headers.set("Content-Type", "application/json");
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1${path}`, { ...init, headers });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "工况任务请求"));
return payload;
return requestWorkbenchJson(`/api/v1${path}`, "工况任务请求", { access: getAccessToken, init });
}
export async function fetchOperationalTask(taskId: string, getAccessToken?: WorkbenchAccess, signal?: AbortSignal) {
@@ -1,8 +1,7 @@
import { getWorkbenchAccessToken } from "./request-context";
import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError, type PlaceSearchConfiguration } from "./project-client";
import { type PlaceSearchConfiguration } from "./project-client";
export const MAX_PLACE_SEARCH_RESULTS = 10;
@@ -71,21 +70,8 @@ async function requestPlaceData(
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise<unknown> {
const requestSignal = signal
? AbortSignal.any([signal, AbortSignal.timeout(20_000)])
: AbortSignal.timeout(20_000);
const ownedProject = projectId ? null : await findOwnedWorkbenchProject(getAccessToken, requestSignal);
const accessToken = ownedProject?.accessToken ?? await getWorkbenchAccessToken(getAccessToken);
requestSignal.throwIfAborted();
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到地名搜索所属项目");
const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/${path}`, {
method: "POST",
headers: createWorkbenchHeaders(accessToken, resolvedProjectId, true),
body: JSON.stringify(body),
signal: requestSignal
return requestWorkbenchJson(`/api/v1/${path}`, "地名搜索", {
access: getAccessToken, projectId, timeoutMs: 20_000,
init: { method: "POST", body: JSON.stringify(body), signal }
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "地名搜索"));
return payload;
}
@@ -48,18 +48,19 @@ export async function withProjectRequest<T>(
baseUrl: string,
operation: string,
request: (signal: AbortSignal) => Promise<T>,
signal?: AbortSignal
signal?: AbortSignal,
timeoutMs: number | null = PROJECT_REQUEST_TIMEOUT_MS
): Promise<T> {
const deadline = AbortSignal.timeout(PROJECT_REQUEST_TIMEOUT_MS);
const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
const deadline = timeoutMs == null ? undefined : AbortSignal.timeout(timeoutMs);
const combined = signal && deadline ? AbortSignal.any([signal, deadline]) : signal ?? deadline ?? new AbortController().signal;
try {
combined.throwIfAborted();
return await request(combined);
} catch (error) {
if (signal?.aborted && !isTimeout(signal.reason)) throw signal.reason;
if (isTimeout(error) || (combined.aborted && isTimeout(combined.reason))) {
if (timeoutMs != null && (isTimeout(error) || (combined.aborted && isTimeout(combined.reason)))) {
const diagnosis = await describeServerReadiness(baseUrl);
throw new Error(`${operation}超时(已等待 15 秒)。${diagnosis}`, { cause: error });
throw new Error(`${operation}超时(已等待 ${timeoutMs / 1000} 秒)。${diagnosis}`, { cause: error });
}
if (error instanceof TypeError || (error instanceof DOMException && error.name === "NetworkError")) {
throw new Error(`${operation}失败:无法连接后端服务。请检查网络和后端服务状态后重试。`, { cause: error });
@@ -1,8 +1,7 @@
import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import type { PipeFlowCalculation } from "../map/flow-overlay";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const realtimeLinkSchema = z.object({
time: z.string().datetime({ offset: true }),
@@ -11,7 +10,6 @@ const realtimeLinkSchema = z.object({
});
const realtimeLinksSchema = z.array(realtimeLinkSchema);
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
const FLOW_LOOKBACK_MS = 15 * 60 * 1000;
export type RealtimePipeFlowSnapshot = {
@@ -24,22 +22,13 @@ export async function fetchRealtimePipeFlows(
signal?: AbortSignal,
now = new Date()
): Promise<RealtimePipeFlowSnapshot> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const query = new URLSearchParams({
start_time: new Date(now.getTime() - FLOW_LOOKBACK_MS).toISOString(),
end_time: now.toISOString()
});
const response = await fetch(
`${SERVER_API_BASE_URL}/api/v1/timeseries/realtime/links?${query.toString()}`,
{
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal
}
);
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "实时管道流速请求"));
}
const payload = await requestWorkbenchJson(`/api/v1/timeseries/realtime/links?${query}`, "实时管道流速请求", {
access: getAccessToken, init: { signal }
});
const rows = realtimeLinksSchema.parse(payload);
const latestByPipe = new Map<string, z.infer<typeof realtimeLinkSchema>>();
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { findOwnedWorkbenchProject } from "./project-client";
import { requestWorkbenchJson } from "./request-json";
vi.mock("./project-client", async (original) => ({
...await original<typeof import("./project-client")>(), findOwnedWorkbenchProject: vi.fn()
}));
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.clearAllMocks(); });
describe("project JSON transport", () => {
it("reuses resolved credentials and preserves request headers without choosing another project", async () => {
const fetcher = vi.fn().mockResolvedValue(Response.json({ ok: true }));
vi.stubGlobal("fetch", fetcher);
await expect(requestWorkbenchJson("/api/v1/probe", "查询", {
credentials: { projectId: "project-a", accessToken: "token-a" },
init: { headers: { "X-Request-Id": "request-a" } }
})).resolves.toEqual({ ok: true });
const headers = new Headers(fetcher.mock.calls[0][1].headers);
expect(headers.get("Authorization")).toBe("Bearer token-a");
expect(headers.get("X-Project-Id")).toBe("project-a");
expect(headers.get("X-Request-Id")).toBe("request-a");
expect(findOwnedWorkbenchProject).not.toHaveBeenCalled();
});
it("rejects an explicitly empty project instead of falling back to a default", async () => {
const fetcher = vi.fn(); vi.stubGlobal("fetch", fetcher);
await expect(requestWorkbenchJson("/api/v1/probe", "查询", { projectId: "" })).rejects.toThrow("所属项目不能为空");
expect(findOwnedWorkbenchProject).not.toHaveBeenCalled();
expect(fetcher).not.toHaveBeenCalled();
});
it("preserves caller cancellation before fetching and while reading the body", async () => {
const controller = new AbortController();
const reason = new DOMException("changed project", "AbortError");
const fetcher = vi.fn().mockImplementation(async () => ({ ok: true, json: async () => {
controller.abort(reason); throw reason;
} }));
vi.stubGlobal("fetch", fetcher);
const options = { credentials: { projectId: "project-a", accessToken: null }, init: { signal: controller.signal } };
await expect(requestWorkbenchJson("/api/v1/probe", "查询", options)).rejects.toBe(reason);
await expect(requestWorkbenchJson("/api/v1/probe", "查询", options)).rejects.toBe(reason);
expect(fetcher).toHaveBeenCalledTimes(1);
});
it("keeps deadlines opt-in and never retries a failed write", async () => {
const deadline = vi.spyOn(AbortSignal, "timeout");
const fetcher = vi.fn().mockResolvedValue(Response.json({ detail: "服务繁忙" }, { status: 503 }));
vi.stubGlobal("fetch", fetcher);
await expect(requestWorkbenchJson("/api/v1/probe", "提交", {
credentials: { projectId: "project-a", accessToken: null }, init: { method: "POST", body: "{}" }
})).rejects.toThrow("服务繁忙");
expect(fetcher).toHaveBeenCalledTimes(1);
expect(deadline).not.toHaveBeenCalled();
fetcher.mockResolvedValue(Response.json({}));
await requestWorkbenchJson("/api/v1/probe", "地名搜索", {
credentials: { projectId: "project-a", accessToken: null }, timeoutMs: 20_000
});
expect(deadline).toHaveBeenCalledWith(20_000);
});
});
@@ -0,0 +1,38 @@
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
import { withProjectRequest } from "./project-request";
import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context";
type ProjectCredentials = { projectId: string | undefined; accessToken: string | null | undefined };
/** Project-scoped JSON transport. Business validation and write retries belong to callers. */
export async function requestWorkbenchJson(path: string, operation: string, options: {
access?: WorkbenchAccess;
projectId?: string | null;
credentials?: ProjectCredentials;
init?: RequestInit;
timeoutMs?: number;
} = {}): Promise<unknown> {
const baseUrl = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
return withProjectRequest(baseUrl, operation, async (signal) => {
let credentials = options.credentials;
if (!credentials) {
if (options.projectId != null) {
if (!options.projectId) throw new Error(`${operation}所属项目不能为空`);
credentials = { projectId: options.projectId, accessToken: await getWorkbenchAccessToken(options.access) };
} else {
const { project, accessToken } = await findOwnedWorkbenchProject(options.access, signal);
credentials = { projectId: project.project_id, accessToken };
}
}
signal.throwIfAborted();
const headers = new Headers(options.init?.headers);
new Headers(createWorkbenchHeaders(credentials.accessToken, credentials.projectId)).forEach((value, key) => headers.set(key, value));
if (options.init?.body != null && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
const response = await fetch(`${baseUrl}${path}`, { ...options.init, headers, signal });
const payload: unknown = await response.json().catch(() => null);
signal.throwIfAborted();
if (!response.ok) throw new Error(getServerError(payload, response.status, operation));
return payload;
}, options.init?.signal ?? undefined, options.timeoutMs ?? null);
}
+3 -10
View File
@@ -1,8 +1,8 @@
import { requestWorkbenchJson } from "./request-json";
import type { Feature, FeatureCollection, Point } from "geojson";
import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { z } from "zod";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
import { findOwnedWorkbenchProject } from "./project-client";
const scadaDeviceSchema = z.object({
device_id: z.string().trim().min(1),
@@ -68,7 +68,6 @@ export type ScadaSnapshot = {
telemetryStatus: "ready" | "empty" | "unavailable";
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
const SCADA_LOOKBACK_MS = 15 * 60 * 1000;
const SCADA_READING_BATCH_SIZE = 100;
@@ -244,11 +243,5 @@ async function requestJson(
projectId: string | undefined,
signal: AbortSignal | undefined
) {
const headers = createWorkbenchHeaders(accessToken, projectId);
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, { headers, signal });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "SCADA API请求"));
}
return payload;
return requestWorkbenchJson(path, "SCADA API请求", { credentials: { accessToken, projectId }, init: { signal } });
}
@@ -1,6 +1,6 @@
import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
import { findOwnedWorkbenchProject } from "./project-client";
import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context";
const ruleSchema = z.object({
@@ -128,14 +128,10 @@ export class ScadaReplayClient {
this.projectId ??= findOwnedWorkbenchProject(this.access).then(({ project }) => project.project_id)
.catch((error) => { this.projectId = null; throw error; });
const [projectId, token] = await Promise.all([this.projectId, getWorkbenchAccessToken(this.access)]);
const headers = createWorkbenchHeaders(token, projectId);
if (body !== undefined) headers.set("Content-Type", "application/json");
const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/scada-replays${path}`, {
method: body === undefined ? "GET" : "POST", headers, signal,
...(body === undefined ? {} : { body: JSON.stringify(body) })
return requestWorkbenchJson(`/api/v1/scada-replays${path}`, "SCADA 回放", {
credentials: { projectId, accessToken: token },
init: { method: body === undefined ? "GET" : "POST", signal,
...(body === undefined ? {} : { body: JSON.stringify(body) }) }
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "SCADA 回放"));
return payload;
}
}
@@ -1,7 +1,6 @@
import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const analysisHistoryItemSchema = z.object({
run_id: z.string().trim().min(1),
@@ -41,7 +40,6 @@ export type WorkbenchAnalysisHistoryPage = {
nextOffset: number | null;
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function fetchWorkbenchAnalysisHistory(
options: {
@@ -59,25 +57,13 @@ export async function fetchWorkbenchAnalysisHistory(
if (!Number.isInteger(offset) || offset < 0) {
throw new Error("分析成果历史偏移量无效");
}
const { project, accessToken } = await findOwnedWorkbenchProject(
options.getAccessToken,
options.signal
);
const search = new URLSearchParams({
limit: String(limit),
offset: String(offset)
});
const response = await fetch(
`${SERVER_API_BASE_URL}/api/v1/workbench/analysis-runs?${search}`,
{
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal: options.signal
}
);
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "分析成果历史请求"));
}
const payload = await requestWorkbenchJson(`/api/v1/workbench/analysis-runs?${search}`, "分析成果历史请求", {
access: options.getAccessToken, init: { signal: options.signal }
});
const page = analysisHistoryPageSchema.parse(payload);
return {
items: page.items.map((item) => ({
@@ -1,7 +1,7 @@
import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import type { FeatureCollection, Geometry, Position } from "geojson";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { formatDateTime } from "@/shared/utils/date-time";
import type {
ScheduledConditionRecord,
@@ -10,7 +10,6 @@ import type {
WorkbenchScenario
} from "../types";
import type { AnalysisArtifact, ArtifactLifecycle, ArtifactType } from "../workspace/workspace-model";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
// Keep legacy analysis values parseable while old servers are rolling forward;
// they are filtered before the operational workbench model is created.
@@ -159,7 +158,6 @@ export type WorkbenchAnalysisProjection = {
simulationOverlay: FeatureCollection<Geometry> | null;
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export function parseWorkbenchRuntimeSnapshot(payload: unknown): WorkbenchRuntimeSnapshot {
const snapshot = runtimeSnapshotSchema.parse(payload);
@@ -264,18 +262,9 @@ export async function fetchWorkbenchAnalysisRun(
): Promise<WorkbenchAnalysisProjection> {
const normalizedRunId = runId.trim();
if (!normalizedRunId) throw new Error("分析运行 ID 不能为空");
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const response = await fetch(
`${SERVER_API_BASE_URL}/api/v1/workbench/analysis-runs/${encodeURIComponent(normalizedRunId)}`,
{
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal
}
);
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "分析运行成果请求"));
}
const payload = await requestWorkbenchJson(`/api/v1/workbench/analysis-runs/${encodeURIComponent(normalizedRunId)}`, "分析运行成果请求", {
access: getAccessToken, init: { signal }
});
const analysis = runtimeLatestAnalysisSchema.parse(payload);
if (analysis.run_id !== normalizedRunId) {
throw new Error("分析运行成果与请求 ID 不一致");
@@ -1,3 +1,4 @@
import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
@@ -63,13 +64,9 @@ export async function fetchWorkbenchSyncSnapshot(
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise<WorkbenchSyncSnapshot> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/workbench/sync-snapshot`, {
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal
const payload = await requestWorkbenchJson("/api/v1/workbench/sync-snapshot", "工作台同步快照请求", {
access: getAccessToken, init: { signal }
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "工作台同步快照请求"));
const envelope = snapshotEnvelopeSchema.parse(payload);
return {
eventCursor: envelope.event_cursor,
@@ -62,6 +62,7 @@ describe("execution step cards", () => {
});
const taskBase = {
execution_scope: "production",
project_id: "11111111-1111-4111-8111-111111111111",
task_id: "22222222-2222-4222-8222-222222222222",
task_type: "event_investigation",
@@ -1,3 +1,4 @@
import { getReportPresentationState, snapshotRound } from "../utils/report-presentation-state";
import { InvestigationReportView } from "./investigation-report-view";
import { formatDateTime as formatDisplayDateTime } from "@/shared/utils/date-time";
import { InvestigationTaskReport } from "./investigation-task-report";
@@ -110,18 +111,8 @@ export function AgentExecutionPlanPanel({
/>;
const plan = task.latest_plan?.plan;
const report = task.latest_report?.report;
const planGeneration = task.latest_plan?.execution_generation;
const planState = planGeneration == null
? "historical"
: planGeneration === task.execution_generation
? "current"
: "previous";
const reportGeneration = task.latest_report?.execution_generation;
const reportState = reportGeneration == null
? "historical"
: reportGeneration === task.execution_generation
? "current"
: "previous";
const planState = snapshotRound(task.latest_plan?.execution_generation, task.execution_generation);
const { round: reportState } = getReportPresentationState(task, task.latest_report);
const visiblePlanNodeIds = plan && plan.schema === "execution-plan@2"
? activePlanGraph(plan).visible : null;
const visiblePlanNodes = plan && visiblePlanNodeIds
@@ -1,3 +1,4 @@
import { getReportPresentationState } from "../utils/report-presentation-state";
import { FileText } from "lucide-react";
import { StatusBadge } from "@/shared/ui/status";
import { formatDateTime } from "@/shared/utils/date-time";
@@ -7,9 +8,7 @@ import type { OperationalTask } from "../api/operational-task-client";
export function InvestigationReportView({ task, reportLabel }: { task: OperationalTask; reportLabel: string }) {
const snapshot = task.latest_report;
const report = snapshot?.report;
const insufficient = report?.outcome === "insufficient_data";
const current = snapshot?.execution_generation === task.execution_generation;
const ended = current && task.lifecycle_status === "completed";
const { insufficient, current, ended } = getReportPresentationState(task, snapshot);
const insufficientLabel = ended ? "本轮已结束 · 无法形成结论"
: task.lifecycle_status === "failed" ? "证据不足 · 本轮执行失败"
: task.lifecycle_status === "cancelled" ? "证据不足 · 本轮已取消"
@@ -1,3 +1,4 @@
import { getReportPresentationState } from "../utils/report-presentation-state";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { InvestigationQuestions } from "./investigation-questions";
import { History, LoaderCircle, MessageSquare } from "lucide-react";
@@ -179,22 +180,13 @@ export function InvestigationTaskReport({
// A current final report owns its archived graph. During investigation, a
// progress snapshot owns its whole graph, including an explicit null. A prior
// round's final report must never hide the new round's progress.
const currentFinalReport = snapshot?.execution_generation === task.execution_generation
&& report?.stage === "final";
const { currentFinalReport, previousRound, olderRevision, insufficient } = getReportPresentationState(task, snapshot);
const diagram = live
? progress && !currentFinalReport ? progress.progress.diagram : investigationDiagram(task, snapshot)
: selection.diagram;
const diagramRunning = Boolean(live && !currentFinalReport && progress && task.active_run?.run_id === progress.run_id && task.active_run.status === "running");
const previousRound = Boolean(
snapshot && snapshot.execution_generation !== task.execution_generation
);
const olderRevision = Boolean(
snapshot && task.latest_report && snapshot.revision !== task.latest_report.revision
);
const latestAvailable = !live;
const insufficient = report?.outcome === "insufficient_data";
const currentInsufficient = task.latest_report?.execution_generation === task.execution_generation
&& task.latest_report?.report.outcome === "insufficient_data";
const { currentInsufficient } = getReportPresentationState(task, task.latest_report);
const pendingActions = task.actions.filter(isPendingInvestigationAction);
const completedActions = task.actions.filter((action) => !isPendingInvestigationAction(action));
const evidence = task.evidence.filter((item) => report?.evidence_refs.includes(item.evidence_id));
@@ -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
};
}
@@ -1,73 +0,0 @@
import type { MapAnnotation } from "../types";
export const mapAnnotations: MapAnnotation[] = [
{
id: "burst",
label: "演示爆管位置",
coordinate: [121.5215, 30.9084],
kind: "burst"
},
{
id: "dn600",
label: "演示 DN600",
coordinate: [121.512, 30.9102],
kind: "pipe-label"
},
{
id: "east-gate",
label: "演示片区",
coordinate: [121.545, 30.951],
kind: "district"
},
{
id: "impact",
label: "演示影响范围 约 1.82 km²",
coordinate: [121.567, 30.896],
kind: "tooltip"
}
];
export const impactAreaPolygon = {
type: "FeatureCollection" as const,
features: [
{
type: "Feature" as const,
properties: {
id: "impact-area",
label: "演示影响范围",
area: "1.82 km²"
},
geometry: {
type: "Polygon" as const,
coordinates: [
[
[121.5205, 30.9084],
[121.548, 30.928],
[121.604, 30.918],
[121.626, 30.886],
[121.584, 30.862],
[121.538, 30.873],
[121.506, 30.895],
[121.5205, 30.9084]
]
]
}
}
]
};
export const annotationPoints = {
type: "FeatureCollection" as const,
features: mapAnnotations.map((annotation) => ({
type: "Feature" as const,
properties: {
id: annotation.id,
label: annotation.label,
kind: annotation.kind
},
geometry: {
type: "Point" as const,
coordinates: annotation.coordinate
}
}))
};
@@ -0,0 +1,25 @@
import { expect, it, vi } from "vitest";
import type { AgentQuestionRequest } from "@/features/agent";
import { createAgentInteractionReplies } from "./agent-interaction-replies";
import type { AgentSessionRuntime } from "./agent-session-runtime";
import { WorkbenchSessionResources } from "./workbench-session-resources";
it("keeps a reply bound to its original session when the user switches conversations", async () => {
let resolve: () => void = () => {};
const request = vi.fn(() => new Promise<void>((done) => { resolve = done; }));
const original = { sessionId: "original", client: { replyQuestion: request }, questionOverrides: {} } as unknown as AgentSessionRuntime;
const next = { sessionId: "next", questionOverrides: {} } as AgentSessionRuntime;
const resources = new WorkbenchSessionResources(); resources.activeAgent = original;
const setters = { permission: vi.fn(), question: vi.fn() };
const replies = createAgentInteractionReplies(resources, setters);
const question = { sessionId: "original", requestId: "question" } as AgentQuestionRequest;
const submitted = replies.replyQuestion(question, [["A"]]);
expect(original.questionOverrides.question.status).toBe("submitting");
resources.activeAgent = next; setters.question.mockClear();
resolve(); await submitted;
expect(original.questionOverrides.question).toEqual({ status: "answered", answers: [["A"]] });
expect(next.questionOverrides).toEqual({});
expect(setters.question).not.toHaveBeenCalled();
await replies.replyQuestion(question, [["B"]]);
expect(request).toHaveBeenCalledTimes(1);
});
@@ -0,0 +1,60 @@
import type { AgentPermissionReply, AgentPermissionRequest, AgentQuestionRequest } from "@/features/agent";
import { showMapNotice } from "@/features/map/core";
import { toPermissionStatus, type PermissionOverride, type QuestionOverride } from "./agent-session-message-state";
import type { AgentSessionRuntime } from "./agent-session-runtime";
import type { WorkbenchSessionResources } from "./workbench-session-resources";
export function createAgentInteractionReplies(resources: WorkbenchSessionResources, setters: {
permission: (value: Record<string, PermissionOverride>) => void;
question: (value: Record<string, QuestionOverride>) => void;
}) {
const permissionUpdate = (target: AgentSessionRuntime, id: string, value: PermissionOverride) => {
target.permissionOverrides = { ...target.permissionOverrides, [id]: value };
if (resources.activeAgent === target) setters.permission(target.permissionOverrides);
resources.notify();
};
const questionUpdate = (target: AgentSessionRuntime, id: string, value: QuestionOverride) => {
target.questionOverrides = { ...target.questionOverrides, [id]: value };
if (resources.activeAgent === target) setters.question(target.questionOverrides);
resources.notify();
};
const submit = async <T extends PermissionOverride | QuestionOverride>(
request: { sessionId: string; requestId: string },
update: (target: AgentSessionRuntime, id: string, value: T | { status: "submitting" } | { status: "error"; error: string }) => void,
execute: (target: AgentSessionRuntime) => Promise<unknown>,
success: T,
failure: { id: string; title: string; message: string }
) => {
const target = resources.activeAgent;
if (!target || target.sessionId !== request.sessionId) return;
const apply = (value: T | { status: "submitting" } | { status: "error"; error: string }) => update(target, request.requestId, value);
apply({ status: "submitting" });
try {
await execute(target);
apply(success);
} catch (error) {
const message = error instanceof Error ? error.message : failure.message;
apply({ status: "error", error: message });
showMapNotice({ id: failure.id, tone: "error", title: failure.title, message });
}
};
const questionFailure = { id: "agent-question-status", title: "Agent 问题提交失败", message: "问题回复提交失败。" };
return {
replyPermission: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => submit<PermissionOverride>(
permission, permissionUpdate,
(target) => target.client.replyPermission(permission.requestId, { sessionId: permission.sessionId, reply }),
{ status: toPermissionStatus(reply) },
{ id: "agent-permission-status", title: "Agent 权限提交失败", message: "权限请求提交失败。" }
),
replyQuestion: (question: AgentQuestionRequest, answers: string[][]) => submit<QuestionOverride>(
question, questionUpdate,
(target) => target.client.replyQuestion(question.requestId, { sessionId: question.sessionId, answers }),
{ status: "answered", answers }, questionFailure
),
rejectQuestion: (question: AgentQuestionRequest) => submit<QuestionOverride>(
question, questionUpdate,
(target) => target.client.rejectQuestion(question.requestId, { sessionId: question.sessionId }),
{ status: "rejected" }, { ...questionFailure, message: "问题跳过失败。" }
)
};
}
@@ -125,3 +125,76 @@ it("keeps a malformed persisted data payload from crashing the conversation", ()
expect(toAgentChatMessages(messages, {}, {})[0].content).toBe("这段结构化内容无法显示。");
}
});
it("restores an aborted run's terminal state from canonical history", () => {
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [
{ type: "text", text: "已核查的部分结果" },
{ type: "data-progress", data: { id: "query", title: "查询", status: "running" } },
{ type: "data-run_status", data: { status: "aborted", message: "请求已中断" } }
] }]);
expect(toAgentChatMessages(messages, {}, {})[0]).toMatchObject({ content: "已核查的部分结果", progress: [{ status: "cancelled" }] });
});
describe("terminal tool progress", () => {
const parts = [
{ type: "text", text: "已核查的部分结果" },
{ type: "data-progress", data: { id: "tool", phase: "tool", title: "正在处理", status: "running", tool: { name: "query", input: { id: 12 } } } },
{ type: "data-todo_update", data: { session_id: "s", todos: [{ id: "todo", content: "核查", status: "in_progress" }] } },
{ type: "data-permission_request", data: { request_id: "permission", session_id: "s", permission: "read", patterns: [] } },
{ type: "data-question_request", data: { request_id: "question", session_id: "s", questions: [{ question: "继续吗?", options: [] }] } }
];
const initial = () => toAgentUiMessages([{ id: "answer", role: "assistant", parts }]);
it.each(["done", "error", "aborted"] as const)("settles running tool rows after resumed %s", (type) => {
const messages = applySessionStreamEvent(initial(), { type, data: { message: "模型请求失败" } });
const answer = toAgentChatMessages(messages, {}, {})[0];
expect(answer.progress?.[0]).toMatchObject({ status: type === "done" ? "completed" : type === "aborted" ? "cancelled" : "error", tool: { name: "query", input: { id: 12 } } });
expect(answer.todos?.todos[0].status).toBe("cancelled");
expect(answer.content).toBe("已核查的部分结果");
if (type === "error") expect(answer.runError).toBe("模型请求失败");
});
it("preserves the reason when reopening a failed answer with partial structured output", () => {
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [...parts,
{ type: "data-final_answer", data: { content: "已核查的部分结果" } },
{ type: "data-run_status", data: { status: "error", message: "模型请求失败" } }
] }]);
const answer = toAgentChatMessages(messages, {}, {})[0];
expect(answer).toMatchObject({ content: "已核查的部分结果", runError: "模型请求失败", progress: [{ status: "error", detail: "模型请求失败" }] });
});
it("does not let an old reply override revive stopped cards", () => {
const messages = applySessionStreamEvent(initial(), { type: "run_status", data: { status: "aborted" } });
const answer = toAgentChatMessages(messages, { permission: { status: "error", error: "late HTTP response" } },
{ question: { status: "submitting" } })[0];
expect(answer.permissions?.[0].status).toBe("aborted");
expect(answer.questions?.[0].status).toBe("rejected");
const stopped = applySessionStreamEvent(messages, { type: "error", data: { message: "请求已中断" } });
expect(toAgentChatMessages(stopped, {}, {})[0].runError).toBeUndefined();
expect(toAgentChatMessages(stopped, {}, {})[0].progress?.[0].status).toBe("cancelled");
});
it("settles legacy history using the session status while leaving active tools running", () => {
for (const status of ["completed", "aborted", "error", "waiting_input", "running"] as const) {
const messages = toAgentUiMessages(initial(), { runStatus: status, isStreaming: status === "running" });
expect(toAgentChatMessages(messages, {}, {})[0].progress?.[0].status)
.toBe(status === "running" ? "running" : status === "error" ? "error" : status === "aborted" ? "cancelled" : "completed");
}
const state = applySessionStreamEvent(initial(), { type: "state", sessionId: "s", isStreaming: false, messages: initial() });
expect(toAgentChatMessages(state, {}, {})[0].progress?.[0].status).toBe("completed");
});
it("keeps a saved failure authoritative when a legacy session defaults to completed", () => {
const saved = applySessionStreamEvent(initial(), { type: "error", data: { message: "历史错误" } });
const answer = toAgentChatMessages(toAgentUiMessages(saved, { runStatus: "completed", isStreaming: false }), {}, {})[0];
expect(answer.runError).toBe("历史错误");
});
it("routes historical question replies to the owning assistant without changing the current turn", () => {
const messages = [...initial(), ...toAgentUiMessages([{ id: "current", role: "assistant", parts: [{ type: "text", text: "新一轮" }] }])];
const updated = applySessionStreamEvent(messages, { type: "question_response", data: { request_id: "question", rejected: true } });
const answers = toAgentChatMessages(updated, {}, {});
expect(answers[0].questions?.[0].status).toBe("rejected");
expect(updated[1]).toEqual(messages[1]);
});
});
@@ -2,6 +2,10 @@ import type { UIMessage } from "ai";
import type { Dispatch, SetStateAction } from "react";
import {
parseAnswerUiBlock,
completeRunningProgress,
cancelRunningTodos,
finalizeAssistantMessageAfterAbort,
finalizeAssistantMessageAfterError,
readInteractionReply,
getFinalAnswerTranscript,
type AnswerUiPart,
@@ -17,7 +21,8 @@ import {
type AgentPermissionStatus,
type AgentQuestionRequest,
type AgentSessionStreamEvent,
type AgentStreamRenderState
type AgentStreamRenderState,
type AgentRunStatus
} from "@/features/agent";
type AgentUiDataParts = {
@@ -26,6 +31,7 @@ type AgentUiDataParts = {
task_context: Record<string, unknown>;
execution_plan: Record<string, unknown>;
progress: Record<string, unknown>;
run_status: Record<string, unknown>;
final_answer: Record<string, unknown>;
credential_refresh_required: Record<string, unknown>;
credential_refreshed: Record<string, unknown>;
@@ -137,11 +143,14 @@ export function toAgentChatMessages(
...(answerParts ? { answerParts } : {})
};
let runState: Record<string, unknown> | undefined;
for (const part of message.parts) {
if (!isAgentDataPart(part)) {
continue;
}
if (part.type === "data-interaction_reply" && message.role === "user") {
if (part.type === "data-run_status") {
runState = part.data;
} else if (part.type === "data-interaction_reply" && message.role === "user") {
const reply = readInteractionReply(part.data, message.id);
if (reply) next = { ...next, interactionReply: reply };
} else if (part.type === "data-task_context") {
@@ -193,23 +202,44 @@ export function toAgentChatMessages(
if (next.finalAnswer) {
next.content = getFinalAnswerTranscript(next);
}
if (runState?.status === "aborted") {
next = finalizeAssistantMessageAfterAbort(next);
} else if (runState?.status === "error") {
next = finalizeAssistantMessageAfterError(next, getDataString(runState, "message") ?? "Agent response failed");
} else if (runState?.status === "completed" || runState?.status === "waiting_input") {
next = { ...next, progress: completeRunningProgress(next.progress),
...(runState.status === "completed" ? { todos: cancelRunningTodos(next.todos) } : {}) };
}
return [applyQuestionOverrides(applyPermissionOverrides(next, permissionOverrides), questionOverrides)];
});
}
export function toAgentUiMessages(messages: unknown[]): AgentUiMessage[] {
return messages.flatMap((message) => {
export function toAgentUiMessages(
messages: unknown[],
session?: { runStatus?: AgentRunStatus; isStreaming?: boolean }
): AgentUiMessage[] {
const normalized = messages.flatMap((message) => {
if (isAgentUiMessage(message)) {
return [message];
}
const legacyMessage = toAgentUiMessageFromLegacy(message);
return legacyMessage ? [legacyMessage] : [];
});
if (!session) return normalized;
const lastAssistant = normalized.reduce((last, message, index) => message.role === "assistant" ? index : last, -1);
return normalized.map((message, index) => {
if (message.role !== "assistant" || message.parts.some((part) => part.type === "data-run_status")) return message;
const status = index === lastAssistant
? session.runStatus ?? (session.isStreaming === false ? "completed" : undefined)
: "completed";
if (status === "completed" && !message.parts.some((part) => part.type === "data-progress" || part.type === "data-todo_update")) return message;
return status && status !== "running" ? upsertUiDataPart(message, "run_status", { id: "run-status", status }) : message;
});
}
export function applySessionStreamEvent(messages: AgentUiMessage[], event: AgentSessionStreamEvent): AgentUiMessage[] {
if (event.type === "state") {
return toAgentUiMessages(event.messages);
return toAgentUiMessages(event.messages, event);
}
if (event.type === "user_input") {
@@ -226,15 +256,34 @@ export function applySessionStreamEvent(messages: AgentUiMessage[], event: Agent
return token ? updateLastAssistantUiMessage(messages, (message) => appendTextToUiMessage(message, token)) : messages;
}
if (event.type === "done" || event.type === "error" || event.type === "aborted") {
return updateLastAssistantUiMessage(messages, (message) => {
const previous = [...message.parts].reverse().find((part) => part.type === "data-run_status");
const data = previous && isAgentDataPart(previous) ? previous.data : {};
return upsertUiDataPart(message, "run_status", {
...data, id: "run-status",
status: data.status === "aborted" || event.type === "aborted" || event.data.status === "aborted" ? "aborted"
: event.type === "error" ? "error" : event.data.reason === "waiting_input" ? "waiting_input" : "completed",
...(event.type === "error" || event.type === "aborted" ? { message: getDataString(event.data, "message") ?? getDataString(data, "message") ?? "Agent stream failed" } : {})
});
});
}
if (event.type === "permission_response" || event.type === "question_response") {
const requestType = event.type === "permission_response" ? "data-permission_request" : "data-question_request";
return messages.map((message) => message.role === "assistant" && message.parts.some((part) =>
part.type === requestType && isAgentDataPart(part) && part.data.request_id === event.data.request_id)
? upsertUiDataPart(message, event.type, event.data) : message);
}
if (
event.type === "run_status" ||
event.type === "final_answer" ||
event.type === "progress" ||
event.type === "execution_plan" ||
event.type === "todo_update" ||
event.type === "permission_request" ||
event.type === "permission_response" ||
event.type === "question_request" ||
event.type === "question_response" ||
event.type === "answer_ui" ||
event.type === "ui_envelope" ||
event.type === "session_title"
@@ -244,13 +293,6 @@ export function applySessionStreamEvent(messages: AgentUiMessage[], event: Agent
);
}
if (event.type === "error") {
const message = getDataString(event.data, "message") ?? "Agent stream failed";
return updateLastAssistantUiMessage(messages, (item) =>
appendTextToUiMessage(item, item.parts.some((part) => part.type === "text") ? `\n\n错误:${message}` : `错误:${message}`)
);
}
return messages;
}
@@ -478,7 +520,7 @@ function applyPermissionOverrides(
...message,
permissions: message.permissions.map((permission) => {
const override = permissionOverrides[permission.requestId];
return override
return override && ["pending", "submitting", "error"].includes(permission.status)
? {
...permission,
status: override.status,
@@ -502,7 +544,7 @@ function applyQuestionOverrides(
...message,
questions: message.questions.map((question) => {
const override = questionOverrides[question.requestId];
return override
return override && ["checking", "pending", "submitting", "error"].includes(question.status)
? {
...question,
status: override.status,
@@ -3,11 +3,42 @@ import type { ChatTransport, UIMessageChunk } from "ai";
import { AgentSessionStreamError, AgentStreamDisconnectedError, type AgentApiClient } from "@/features/agent";
import { WorkbenchSessionResources } from "./workbench-session-resources";
import { createAgentRuntime, mergeHistoryMessages, resumeAgentRuntime, runtimeRunning } from "./agent-session-runtime";
import type { AgentUiMessage } from "./agent-session-message-state";
import { toAgentChatMessages, type AgentUiMessage } from "./agent-session-message-state";
const message = (id: string, text = id): AgentUiMessage => ({ id, role: "assistant", parts: [{ type: "text", text }] });
describe("independent conversation runtimes", () => {
it.each(["completed", "error", "aborted"] as const)("settles direct tool progress when the response is %s without a final progress event", async (status) => {
const resources = new WorkbenchSessionResources();
let controller!: ReadableStreamDefaultController<UIMessageChunk>;
const runtime = createAgentRuntime(resources, "project:a", {} as AgentApiClient, {
sendMessages: async ({ abortSignal }) => new ReadableStream<UIMessageChunk>({ start(stream) {
controller = stream;
abortSignal?.addEventListener("abort", () => stream.error(new DOMException("Aborted", "AbortError")), { once: true });
} }),
reconnectToStream: async () => null,
});
resources.activeAgent = runtime;
try {
const request = runtime.chat.sendMessage({ text: "核查工具状态" });
await vi.waitFor(() => expect(controller).toBeDefined());
controller.enqueue({ type: "start", messageId: "answer" });
controller.enqueue({ type: "data-progress", data: { id: "tool", phase: "tool", title: "正在处理", status: "running" } });
await vi.waitFor(() => expect(toAgentChatMessages(runtime.chat.messages, {}, {}).at(-1)?.progress?.[0].status).toBe("running"));
if (status === "aborted") await runtime.chat.stop();
else {
if (status === "error") controller.enqueue({ type: "error", errorText: "工具执行失败" });
controller.enqueue({ type: "finish", finishReason: status === "error" ? "error" : "stop" });
controller.close();
}
await request;
const answer = toAgentChatMessages(runtime.chat.messages, {}, {}).at(-1);
expect(answer?.progress?.[0].status).toBe(status === "aborted" ? "cancelled" : status);
expect(runtime.runStatus).toBe(status);
if (status === "error") expect(answer?.runError).toBe("工具执行失败");
} finally { resources.dispose(); }
});
it("retries a disconnected subscription and preserves the draft, pages and reading position", async () => {
vi.useFakeTimers();
const resources = new WorkbenchSessionResources();
@@ -141,7 +172,9 @@ describe("independent conversation runtimes", () => {
});
resumeAgentRuntime(resources, runtime);
await vi.waitFor(() => expect(runtime.controller).toBeUndefined());
expect(runtime.chat.messages).toEqual([message("a"), message("b", "new"), message("c")]);
expect(toAgentChatMessages(runtime.chat.messages, {}, {})).toMatchObject([
{ id: "a", content: "a" }, { id: "b", content: "new" }, { id: "c", content: "c" }
]);
expect(submit).toHaveBeenCalledWith("a", "action-a", expect.objectContaining({
status: "failed", error: expect.objectContaining({ code: "SESSION_NOT_ACTIVE" }),
}));
@@ -91,7 +91,7 @@ export function createAgentRuntime(resources: WorkbenchSessionResources, scope:
const active = () => resources.activeAgent === runtime;
const notify = () => queueMicrotask(() => resources.notify());
runtime.chat = new Chat<AgentUiMessage>({
id: runtime.key, transport, messages: toAgentUiMessages(loaded?.messages ?? []),
id: runtime.key, transport, messages: toAgentUiMessages(loaded?.messages ?? [], loaded),
onData: (part) => {
if (resources.disposed) return;
const data = part as AgentDataPart;
@@ -99,6 +99,10 @@ export function createAgentRuntime(resources: WorkbenchSessionResources, scope:
if (sessionId && runtime.sessionId && sessionId !== runtime.sessionId) return;
if (sessionId) runtime.sessionId = sessionId;
if (data.type === "data-interaction_request") runtime.runStatus = "waiting_input";
if (data.type === "data-run_status") {
const status = data.data.status;
if (status === "completed" || status === "error" || status === "aborted" || status === "waiting_input") runtime.runStatus = status;
}
if (data.type === "data-session_title") runtime.title = getDataString(data.data, "title") ?? runtime.title;
if (data.type === "data-credential_refresh_required") {
void refreshRuntimeCredential(runtime, data.data);
@@ -118,10 +122,16 @@ export function createAgentRuntime(resources: WorkbenchSessionResources, scope:
if (resources.disposed) return;
runtime.loadedAt = Date.now();
runtime.updatedAt = Date.now();
runtime.runStatus = isAbort ? "aborted" : isError
runtime.runStatus = isAbort || runtime.runStatus === "aborted" ? "aborted" : isError
? isPendingInteractionError(runtime.error) ? "waiting_input" : "error"
: runtime.runStatus === "waiting_input" ? "waiting_input" : "completed";
const recovering = isError && !isAbort && runtime.transportInterrupted && runtime.sessionId;
if (!recovering) {
runtime.chat.messages = applySessionStreamEvent(runtime.chat.messages, {
type: runtime.runStatus === "error" || runtime.runStatus === "aborted" ? "error" : "done",
data: { status: runtime.runStatus, reason: runtime.runStatus, ...(runtime.error ? { message: runtime.error } : {}) }
});
}
if (active()) { if (!recovering) runtime.handlers?.onFinish(runtime.runStatus); }
else if (!recovering) notifyBackgroundCompletion(runtime);
if (recovering) {
@@ -173,7 +183,7 @@ export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime
const sessionId = event.type === "state" ? event.sessionId : event.sessionId ?? getDataString(event.data, "session_id");
if (sessionId && sessionId !== runtime.sessionId) return;
if (event.type === "state") {
const tail = toAgentUiMessages(event.messages);
const tail = toAgentUiMessages(event.messages, event);
const prior = runtime.chat.messages;
// Start filling a disconnected interval before reconciling the live tail.
if (prior.length && tail.length && !tail.some((message) => prior.some((old) => old.id === message.id))) {
@@ -191,11 +201,16 @@ export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime
if (event.type === "credential_refresh_required") void refreshRuntimeCredential(runtime, event.data);
else if (resources.activeAgent !== runtime && event.type === "frontend_action") void rejectBackgroundAction(resources, runtime, event.data);
runtime.chat.messages = applySessionStreamEvent(runtime.chat.messages, event);
if (event.type === "run_status") {
const status = event.data.status;
if (status === "aborted" || status === "error" || status === "completed" || status === "waiting_input") runtime.runStatus = status;
}
if (event.type === "session_title") runtime.title = getDataString(event.data, "title") ?? runtime.title;
if (event.type === "done" || event.type === "error") {
if (event.type === "done" || event.type === "error" || event.type === "aborted") {
terminal = true;
runtime.resumed = false;
runtime.runStatus = event.type === "error" ? "error" : event.data.reason === "waiting_input" ? "waiting_input" : "completed";
runtime.runStatus = event.type === "aborted" || event.data.status === "aborted" || runtime.runStatus === "aborted" ? "aborted"
: event.type === "error" ? "error" : event.data.reason === "waiting_input" ? "waiting_input" : "completed";
runtime.updatedAt = Date.now();
}
}
@@ -9,6 +9,7 @@ import {
function task(taskType: OperationalTask["task_type"]): OperationalTask {
return {
execution_scope: "production",
project_id: "11111111-1111-4111-8111-111111111111",
task_id: "22222222-2222-4222-8222-222222222222",
task_type: taskType,
@@ -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"
@@ -1,3 +1,4 @@
import { createAgentInteractionReplies } from "./agent-interaction-replies";
import { getWorkbenchAccessToken, getWorkbenchTokenProvider, getWorkbenchProject } from "../api/request-context";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useChat } from "@ai-sdk/react";
@@ -33,9 +34,6 @@ import {
type AgentEventTriggerContext,
type AgentModelOption,
type AgentInputDelivery,
type AgentPermissionReply,
type AgentPermissionRequest,
type AgentQuestionRequest,
type AgentSessionStreamEvent,
type AgentSessionPurpose,
type AgentStreamRenderState,
@@ -52,7 +50,6 @@ import {
readBodyString,
toAgentChatMessages,
toAgentUiMessages,
toPermissionStatus,
type AgentDataPart,
type AgentUiMessage,
type PermissionOverride,
@@ -200,7 +197,7 @@ export function useWorkbenchAgent({
try {
const loaded = await target.client.loadSession(owningSessionId, { messageLimit: 40 });
if (loaded && !resources.disposed && target.client === scopeClient) {
target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages));
target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages, loaded));
resources.notify();
}
} catch { /* A failed reconciliation leaves the accepted answer intact. */ }
@@ -534,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 {
@@ -681,9 +681,9 @@ export function useWorkbenchAgent({
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
setStatusLabel(event.data.reason === "waiting_input" ? "等待补充信息" : "Agent 分析完成");
runtimeRef.current!.loadedAt = Date.now();
} else if (event.type === "error") {
} else if (event.type === "aborted" || event.type === "error") {
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
setStatusLabel("Agent 后端请求失败");
setStatusLabel(event.type === "aborted" || event.data.status === "aborted" ? "Agent 已停止" : "Agent 后端请求失败");
runtimeRef.current!.loadedAt = Date.now();
}
@@ -732,7 +732,7 @@ export function useWorkbenchAgent({
if (generation !== loadingGenerationRef.current) return false;
if (!target) target = createAgentRuntime(resources, scopeKey, scopeClient, transport, loaded);
else {
target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages));
target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages, loaded));
target.title = loaded.title; target.topicId = loaded.topicId; target.loadedAt = Date.now(); target.runStatus = loaded.runStatus;
target.error = undefined;
target.messagePage ??= loaded.messagePage;
@@ -827,7 +827,7 @@ export function useWorkbenchAgent({
);
} else {
target.client = projectClient;
target.chat.messages = toAgentUiMessages(loaded.messages);
target.chat.messages = toAgentUiMessages(loaded.messages, loaded);
target.title = loaded.title;
target.purpose = "task_execution";
target.taskId = normalizedTaskId;
@@ -877,7 +877,7 @@ export function useWorkbenchAgent({
refreshing = false;
return;
}
target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages));
target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages, loaded));
target.title = loaded.title;
target.updatedAt = loaded.updatedAt;
target.loadedAt = Date.now();
@@ -1045,7 +1045,7 @@ export function useWorkbenchAgent({
target.purpose = "task_collaboration";
target.taskId = binding.taskId;
target.taskExecutionHistory = undefined;
target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages));
target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages, loaded));
target.messagePage = loaded.messagePage;
target.loadedAt = Date.now();
target.updatedAt = loaded.updatedAt;
@@ -1139,62 +1139,9 @@ export function useWorkbenchAgent({
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
}, [chat, interactionRequests, refreshPendingInputs, stopSessionStreamSubscription]);
const replyPermission = useCallback(async (permission: AgentPermissionRequest, reply: AgentPermissionReply) => {
const target = resources.activeAgent;
if (!target || target.sessionId !== permission.sessionId) return;
const update = (value: PermissionOverride) => {
target.permissionOverrides = { ...target.permissionOverrides, [permission.requestId]: value };
if (resources.activeAgent === target) setPermissionOverrides(target.permissionOverrides);
resources.notify();
};
update({ status: "submitting" });
try {
await target.client.replyPermission(permission.requestId, { sessionId: permission.sessionId, reply });
update({ status: toPermissionStatus(reply) });
} catch (error) {
const message = error instanceof Error ? error.message : "权限请求提交失败。";
update({ status: "error", error: message });
showMapNotice({ id: "agent-permission-status", tone: "error", title: "Agent 权限提交失败", message });
}
}, [resources]);
const replyQuestion = useCallback(async (question: AgentQuestionRequest, answers: string[][]) => {
const target = resources.activeAgent;
if (!target || target.sessionId !== question.sessionId) return;
const update = (value: QuestionOverride) => {
target.questionOverrides = { ...target.questionOverrides, [question.requestId]: value };
if (resources.activeAgent === target) setQuestionOverrides(target.questionOverrides);
resources.notify();
};
update({ status: "submitting" });
try {
await target.client.replyQuestion(question.requestId, { sessionId: question.sessionId, answers });
update({ status: "answered", answers });
} catch (error) {
const message = error instanceof Error ? error.message : "问题回复提交失败。";
update({ status: "error", error: message });
showMapNotice({ id: "agent-question-status", tone: "error", title: "Agent 问题提交失败", message });
}
}, [resources]);
const rejectQuestion = useCallback(async (question: AgentQuestionRequest) => {
const target = resources.activeAgent;
if (!target || target.sessionId !== question.sessionId) return;
const update = (value: QuestionOverride) => {
target.questionOverrides = { ...target.questionOverrides, [question.requestId]: value };
if (resources.activeAgent === target) setQuestionOverrides(target.questionOverrides);
resources.notify();
};
update({ status: "submitting" });
try {
await target.client.rejectQuestion(question.requestId, { sessionId: question.sessionId });
update({ status: "rejected" });
} catch (error) {
const message = error instanceof Error ? error.message : "问题跳过失败。";
update({ status: "error", error: message });
showMapNotice({ id: "agent-question-status", tone: "error", title: "Agent 问题提交失败", message });
}
}, [resources]);
const { replyPermission, replyQuestion, rejectQuestion } = useMemo(() => createAgentInteractionReplies(resources, {
permission: setPermissionOverrides, question: setQuestionOverrides
}), [resources]);
return {
apiClient: clientRef.current,
@@ -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);
});
@@ -70,7 +70,6 @@ type UseWorkbenchMapOptions = {
onSelectFeature: (feature: DetailFeature) => void;
selectedFeature: DetailFeature | null;
simulationOverlay: FeatureCollection<Geometry> | null;
allowDemoSimulation: boolean;
networkView?: NetworkViewParams | null;
getAccessToken?: WorkbenchAccess;
geoserverWorkspace?: string;
@@ -121,8 +120,6 @@ const SOURCE_GROUP_BY_ID: Record<string, string> = {
...Object.fromEntries(
GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])
),
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source",
[SIMULATION_SOURCE_IDS.backendAnalysis]: "annotation-source"
};
@@ -142,7 +139,6 @@ export function useWorkbenchMap({
onSelectFeature,
selectedFeature,
simulationOverlay,
allowDemoSimulation,
networkView,
geoserverWorkspace,
getAccessToken
@@ -150,11 +146,9 @@ export function useWorkbenchMap({
const mapRef = useRef<MapLibreMap | null>(null);
const impactVisibleRef = useRef(impactVisible);
const simulationOverlayRef = useRef(simulationOverlay);
const allowDemoSimulationRef = useRef(allowDemoSimulation);
const networkViewRef = useRef<NetworkViewParams>(networkView ?? WATER_NETWORK_GLOBAL_VIEW);
const fittedNetworkViewRef = useRef<string | null>(null);
simulationOverlayRef.current = simulationOverlay;
allowDemoSimulationRef.current = allowDemoSimulation;
networkViewRef.current = networkView ?? WATER_NETWORK_GLOBAL_VIEW;
const [mapReady, setMapReady] = useState(false);
const [mapError, setMapError] = useState<string | null>(null);
@@ -315,10 +309,8 @@ export function useWorkbenchMap({
const source = sources[sourceId];
if (source) map.addSource(sourceId, source);
});
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
map.addSource(SIMULATION_SOURCE_IDS.backendAnalysis, simulationSources.backendAnalysis);
syncSimulationSourceData(map, simulationOverlayRef.current, allowDemoSimulationRef.current);
syncSimulationSourceData(map, simulationOverlayRef.current);
await registerSupplyAssetImages(map, scadaAbortController.signal);
if (scadaAbortController.signal.aborted) return;
const scadaRegistration = await registerScadaImages(map, scadaAbortController.signal);
@@ -327,16 +319,13 @@ export function useWorkbenchMap({
scadaRegistration.status === "unavailable"
? scadaFallbackBusinessLayers
: scadaBusinessLayers;
simulationAnnotationLayers
.filter((layer) => layer.id === "simulation-impact-fill")
.forEach((layer) => map.addLayer(layer));
filterWaterNetworkLayersBySourceIds(
waterNetworkBusinessLayers,
REQUIRED_GEOSERVER_SOURCE_IDS
).forEach((layer) => map.addLayer(layer));
activeScadaBusinessLayers.forEach((layer) => map.addLayer(layer));
simulationAnnotationLayers
.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill")
.filter((layer) => layer.type !== "symbol")
.forEach((layer) => map.addLayer(layer));
filterWaterNetworkLayersBySourceIds(
waterNetworkInteractionLayers,
@@ -416,9 +405,8 @@ export function useWorkbenchMap({
updateStatusFromSourceEvent(event, "online", setSourceStatuses);
});
map.on("sourcedataabort", (event) => {
updateStatusFromSourceEvent(event, "degraded", setSourceStatuses);
});
// Moving or zooming cancels obsolete tile requests. Cancellation is part
// of navigation; source failures are reported by the error handler below.
map.on("error", (event) => {
const message = event.error?.message ?? "地图渲染异常";
@@ -485,8 +473,8 @@ export function useWorkbenchMap({
useEffect(() => {
const map = mapRef.current;
if (!map || !mapReady) return;
syncSimulationSourceData(map, simulationOverlay, allowDemoSimulation);
}, [allowDemoSimulation, mapReady, simulationOverlay]);
syncSimulationSourceData(map, simulationOverlay);
}, [mapReady, simulationOverlay]);
function fitToNetworkBounds() {
const map = mapRef.current;
@@ -542,23 +530,12 @@ function getScadaStatusMessage(snapshot: Awaited<ReturnType<typeof fetchScadaSna
function syncSimulationSourceData(
map: MapLibreMap,
backendOverlay: FeatureCollection<Geometry> | null,
allowDemoSimulation: boolean
backendOverlay: FeatureCollection<Geometry> | null
) {
const useBackendOverlay = Boolean(backendOverlay?.features.length);
(map.getSource(SIMULATION_SOURCE_IDS.backendAnalysis) as GeoJSONSource | undefined)?.setData(
useBackendOverlay ? backendOverlay! : EMPTY_SIMULATION_COLLECTION
);
(map.getSource(SIMULATION_SOURCE_IDS.impactArea) as GeoJSONSource | undefined)?.setData(
useBackendOverlay || !allowDemoSimulation
? EMPTY_SIMULATION_COLLECTION
: simulationSources.impactArea.data
);
(map.getSource(SIMULATION_SOURCE_IDS.annotations) as GeoJSONSource | undefined)?.setData(
useBackendOverlay || !allowDemoSimulation
? EMPTY_SIMULATION_COLLECTION
: simulationSources.annotations.data
);
}
function syncDataDependentGeoServerLayers(
@@ -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,
+44 -29
View File
@@ -99,6 +99,7 @@ import {
import { getResponsivePadding, getResponsiveWorkbenchPadding } from "./map/camera";
import { exportMapViewImage } from "./map/export-view";
import { setMapLayersVisibility } from "./map/layer-visibility";
import { MAP_STYLE_TOKENS } from "./map/map-colors";
import {
BASE_LAYER_OPTIONS,
INITIAL_LAYER_VISIBILITY,
@@ -298,9 +299,6 @@ function MapWorkbenchContent({
const artifactActive = artifactWorkspace.activeArtifact !== null;
const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null;
const activeArtifactHasSpatialView = Boolean(activeSpatialOverlay?.features.length);
const allowDemoSimulation =
(!getWorkbenchProject(getAccessToken) || getWorkbenchProject(getAccessToken)?.gs_workspace === "tjwater_next")
&& (artifactWorkspace.activeArtifact === null || artifactWorkspace.activeArtifact.origin === "demo");
const latestAnalysisAvailable = Boolean(
latestAnalysisArtifact?.blocks.some(
(block) =>
@@ -355,11 +353,6 @@ function MapWorkbenchContent({
useEffect(() => {
setImpactVisible(activeArtifactHasSpatialView);
setLayerVisibility((current) =>
current.simulation === activeArtifactHasSpatialView
? current
: { ...current, simulation: activeArtifactHasSpatialView }
);
}, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]);
function openAgentWorkspaceForViewport() {
@@ -458,7 +451,6 @@ function MapWorkbenchContent({
onSelectFeature: handleSelectFeature,
selectedFeature: detailFeature,
simulationOverlay: activeSpatialOverlay,
allowDemoSimulation,
networkView: projectPresentation.networkView,
geoserverWorkspace: getWorkbenchProject(getAccessToken)?.gs_workspace,
getAccessToken
@@ -690,23 +682,55 @@ function MapWorkbenchContent({
[activeToolId, mapReady, toggleToolbarTool]
);
const layerControlItems = useMemo(
() => createLayerControlItems(layerVisibility, availableSourceIds, sourceFeatureCounts),
[availableSourceIds, layerVisibility, sourceFeatureCounts]
const overlayLayers = [
{ scope: "investigation", description: "Agent 调查", surface: presentation },
{ scope: "chat", description: "Agent 对话", surface: chatPresentation },
{ scope: "preview", description: "本地预览", surface: previewPresentation }
].flatMap(({ scope, description, surface }) => surface.overlay ? [{
id: `presentation:${scope}:${surface.overlay.overlay_id}`,
title: surface.overlay.title,
description: `${description} · ${surface.overlay.mode === "heatmap" ? "热力分布" : "空间范围"}`,
featureCount: surface.overlay.data.features.length,
color: MAP_STYLE_TOKENS.state.risk as string,
visible: surface.overlay.visible,
disabled: surface.history.selectedId !== null,
onToggle: (visible: boolean) => {
if (surface.overlay?.visible !== visible) surface.toggleOverlay();
}
}] : []);
if (activeArtifactHasSpatialView && artifactWorkspace.activeArtifact) {
overlayLayers.push({
id: `artifact:${artifactWorkspace.activeArtifact.id}`,
title: artifactWorkspace.activeArtifact.title,
description: "分析成果 · 空间范围",
featureCount: activeSpatialOverlay!.features.length,
color: MAP_STYLE_TOKENS.state.agent,
visible: impactVisible,
disabled: false,
onToggle: setImpactVisible
});
}
const layerControlItems = createLayerControlItems(
layerVisibility, availableSourceIds, sourceFeatureCounts, overlayLayers
);
const visibleLegendItems = useMemo(
() => createVisibleMapLegendItems(layerControlItems),
[layerControlItems]
);
function handleToggleLayer(layerControlId: string, visible: boolean) {
if (layerControlId === "simulation") {
setImpactVisible(visible);
return;
}
const overlay = overlayLayers.find((item) => item.id === layerControlId);
if (overlay) {
if (!overlay.disabled) overlay.onToggle(visible);
return;
}
presentationLayerChangesRef.current.delete(layerControlId);
const map = mapRef.current;
setLayerVisibility((current) => ({ ...current, [layerControlId]: visible }));
if (layerControlId === "simulation") {
setImpactVisible(visible);
}
if (!map || !mapReady) {
return;
}
@@ -932,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();
@@ -956,7 +976,6 @@ function MapWorkbenchContent({
target: TaskExecutionHistoryTarget,
knownRunId?: string
) {
mapController.clearScadaAnalysis();
let runId = knownRunId;
if (!runId) {
const runs = await fetchOperationalTaskRuns(taskId, getAccessToken);
@@ -975,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(
@@ -1079,7 +1094,7 @@ function MapWorkbenchContent({
measurement.clear();
drawing.clear();
geocoding.clear();
handleToggleLayer("simulation", false);
setImpactVisible(false);
showMapNotice({ tone: "success", message: "已清空所有高亮和覆盖层。" });
}
@@ -1471,10 +1486,10 @@ function MapWorkbenchContent({
</div>
{!devPanelOpen ? (
<>
<div className="pointer-events-auto absolute right-3 top-10 hidden lg:block">
<div className="pointer-events-auto absolute right-3 top-10 z-50 hidden lg:block">
<MapToolbar items={toolbarItems} />
</div>
<div className="pointer-events-auto absolute right-16 top-10 hidden lg:block">
<div className="pointer-events-auto absolute right-16 top-10 z-50 hidden lg:block">
<ToolbarPanel {...toolbarPanelProps} />
</div>
</>
@@ -1570,7 +1585,7 @@ function MapWorkbenchContent({
{mobileSheet === null && !readingWorkspace?.active && artifactWorkspace.surfaceMode !== "focus" ? (
<div
className="absolute left-3 right-3 z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden"
className="absolute left-3 right-3 z-50 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden"
style={{
bottom: mobileOverlayLayout.mapControlBottom,
"--mobile-tool-available-height": `calc(100dvh - ${mobileOverlayLayout.mapControlBottom}px - 4.5rem)`
+1 -133
View File
@@ -1,54 +1,18 @@
import type { GeoJSONSourceSpecification, StyleSpecification } from "maplibre-gl";
import { annotationPoints, impactAreaPolygon } from "../data/workbench-simulation";
import { MAP_STYLE_TOKENS } from "./map-colors";
export const SIMULATION_SOURCE_IDS = {
impactArea: "simulation-impact-area",
annotations: "simulation-annotations",
backendAnalysis: "simulation-backend-analysis"
} as const;
const EMPTY_FEATURE_COLLECTION = {
type: "FeatureCollection" as const,
features: []
};
export const simulationSources = {
impactArea: {
type: "geojson",
data: impactAreaPolygon
} satisfies GeoJSONSourceSpecification,
annotations: {
type: "geojson",
data: annotationPoints
} satisfies GeoJSONSourceSpecification,
backendAnalysis: {
type: "geojson",
data: EMPTY_FEATURE_COLLECTION
data: { type: "FeatureCollection", features: [] }
} satisfies GeoJSONSourceSpecification
};
export const simulationAnnotationLayers: StyleSpecification["layers"] = [
{
id: "simulation-impact-fill",
type: "fill",
source: SIMULATION_SOURCE_IDS.impactArea,
paint: {
"fill-color": MAP_STYLE_TOKENS.state.risk,
"fill-opacity": MAP_STYLE_TOKENS.opacity.area
}
},
{
id: "simulation-impact-outline",
type: "line",
source: SIMULATION_SOURCE_IDS.impactArea,
paint: {
"line-color": MAP_STYLE_TOKENS.state.risk,
"line-width": 1.5,
"line-dasharray": [2, 2],
"line-opacity": 0.72
}
},
{
id: "simulation-backend-analysis-fill",
type: "fill",
@@ -82,83 +46,6 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
"circle-stroke-width": 2
}
},
{
id: "simulation-burst-halo",
type: "circle",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-radius": 18,
"circle-stroke-color": MAP_STYLE_TOKENS.state.incident,
"circle-stroke-width": 3,
"circle-opacity": 0.88
}
},
{
id: "simulation-burst-point",
type: "circle",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
paint: {
"circle-color": MAP_STYLE_TOKENS.state.incident,
"circle-radius": 7,
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-stroke-width": 3
}
},
{
id: "simulation-pipe-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "pipe-label"],
layout: {
"text-field": ["get", "label"],
"text-size": 13,
"text-font": ["Noto Sans Regular"],
"text-allow-overlap": true
},
paint: {
"text-color": MAP_STYLE_TOKENS.label.inverse,
"text-halo-color": MAP_STYLE_TOKENS.state.agent,
"text-halo-width": 9
}
},
{
id: "simulation-impact-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "tooltip"],
layout: {
"text-field": ["get", "label"],
"text-size": 14,
"text-font": ["Noto Sans Regular"],
"text-offset": [0, 0],
"text-allow-overlap": true
},
paint: {
"text-color": MAP_STYLE_TOKENS.state.risk,
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
"text-halo-width": 4
}
},
{
id: "simulation-district-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "district"],
layout: {
"text-field": ["get", "label"],
"text-size": 13,
"text-font": ["Noto Sans Regular"],
"text-allow-overlap": true
},
paint: {
"text-color": MAP_STYLE_TOKENS.label.secondary,
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
"text-halo-width": 3
}
},
{
id: "simulation-backend-analysis-label",
type: "symbol",
@@ -176,25 +63,6 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
"text-halo-width": 3
}
},
{
id: "simulation-burst-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
layout: {
"text-field": "演示爆管位置\nDN600 给水管线\n压力:0.18 MPa\n时间:09:00",
"text-size": 12,
"text-font": ["Noto Sans Regular"],
"text-offset": [5.2, -3.4],
"text-anchor": "left",
"text-allow-overlap": true
},
paint: {
"text-color": MAP_STYLE_TOKENS.label.primary,
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
"text-halo-width": 5
}
}
];
@@ -54,8 +54,7 @@ describe("workbench supply layer controls", () => {
"scada-flow",
"water_quality_monitoring_points",
"fx_20200423_pipes",
"fx_20200811_dn100_pipes",
"simulation"
"fx_20200811_dn100_pipes"
])
);
expect(items.find((item) => item.id === "fx_20200423_pipes")).toMatchObject({
@@ -104,12 +103,30 @@ describe("workbench supply layer controls", () => {
"valves",
"reservoirs",
"scada-pressure",
"scada-flow",
"simulation"
"scada-flow"
]);
});
it("defines map-matched symbols for model, source GIS, and analysis layers", () => {
it("lists actual presentation overlays with their visibility and removes cleared entries", () => {
const overlays = [
{ id: "presentation:investigation:area", title: "爆管影响范围", description: "Agent 调查 · 空间范围", featureCount: 1, visible: true },
{ id: "presentation:chat:area", title: "投诉点热力", description: "Agent 对话 · 热力分布", featureCount: 8, visible: false }
];
const items = createLayerControlItems(INITIAL_LAYER_VISIBILITY, undefined, {}, overlays);
expect(items.filter((item) => item.group === "analysis")).toMatchObject([
{ id: overlays[0].id, label: "爆管影响范围", visible: true, featureCount: 1 },
{ id: overlays[1].id, label: "投诉点热力", visible: false, featureCount: 8 }
]);
expect(createVisibleMapLegendItems(items).filter((item) => item.group === "analysis")).toMatchObject([
{ label: "爆管影响范围", layerControlId: overlays[0].id, shape: "area" }
]);
const cleared = createLayerControlItems(INITIAL_LAYER_VISIBILITY);
expect(cleared.filter((item) => item.group === "analysis")).toEqual([]);
expect(createVisibleMapLegendItems(cleared).filter((item) => item.group === "analysis")).toEqual([]);
});
it("defines map-matched symbols for model and source GIS layers", () => {
expect(MAP_LEGEND_ITEMS.map((item) => item.id)).toEqual([
"major-pipe",
"medium-pipe",
@@ -134,8 +151,7 @@ describe("workbench supply layer controls", () => {
"source-hydrant",
"source-connection",
"source-customer-meter",
"source-non-residential-meter",
"impact-area"
"source-non-residential-meter"
]);
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "valve")).toMatchObject({
imageSrc: "/map/valve.png"
@@ -186,11 +202,6 @@ describe("workbench supply layer controls", () => {
).toMatchObject({
imageSrc: "/map/gis-non-residential-meter.svg"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "impact-area")).toMatchObject({
groupLabel: "分析覆盖",
layerControlId: "simulation",
shape: "area"
});
});
it("keeps the legend synchronized with visible layer controls", () => {
@@ -222,8 +233,7 @@ describe("workbench supply layer controls", () => {
const reducedItems = createLayerControlItems({
...INITIAL_LAYER_VISIBILITY,
valves: false,
simulation: false
valves: false
});
const reducedLegendIds = createVisibleMapLegendItems(reducedItems).map((item) => item.id);
@@ -15,19 +15,6 @@ import {
import { SCADA_ICON_PATHS, SCADA_LAYER_CONTROL_ITEMS, SCADA_LAYER_GROUPS } from "./scada";
import { SUPPLY_ASSET_ICON_PATHS } from "./supply-icons";
export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
simulation: [
"simulation-impact-fill",
"simulation-impact-outline",
"simulation-burst-halo",
"simulation-burst-point",
"simulation-pipe-label",
"simulation-impact-label",
"simulation-district-label",
"simulation-burst-label"
]
};
export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
...Object.fromEntries(
SUPPLY_LAYER_CATALOG.filter((layer) => layer.id !== "scada").map((layer) => [
@@ -35,8 +22,17 @@ export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
layer.initialVisible
])
),
...Object.fromEntries(SCADA_LAYER_CONTROL_ITEMS.map((item) => [item.id, true])),
simulation: false
...Object.fromEntries(SCADA_LAYER_CONTROL_ITEMS.map((item) => [item.id, true]))
};
export type AnalysisOverlayLayer = {
id: string;
title: string;
description: string;
visible: boolean;
disabled?: boolean;
featureCount: number;
color?: string;
};
const SOURCE_CONTROL_LABELS = Object.fromEntries(
@@ -421,25 +417,27 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceNonResidentialMeter,
layerControlId: "fx_20200423_non_residential_meters",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "impact-area",
label: "影响范围",
description: "事故或分析结果",
color: MAP_STYLE_TOKENS.state.incident,
shape: "area",
layerControlId: "simulation",
...ANALYSIS_LEGEND_GROUP
}
];
export function createVisibleMapLegendItems(
layerItems: readonly Pick<MapLayerControlItem, "id" | "visible">[]
layerItems: readonly MapLayerControlItem[]
) {
const visibleLayerIds = new Set(layerItems.filter((item) => item.visible).map((item) => item.id));
return MAP_LEGEND_ITEMS.filter((item) =>
item.layerControlId ? visibleLayerIds.has(item.layerControlId) : true
);
return [
...MAP_LEGEND_ITEMS.filter((item) =>
item.layerControlId ? visibleLayerIds.has(item.layerControlId) : true
),
...layerItems.filter((item) => item.group === "analysis" && item.visible).map((item) => ({
id: item.id,
label: item.label,
description: item.description,
color: item.visual?.color ?? MAP_STYLE_TOKENS.state.risk,
shape: "area" as const,
layerControlId: item.id,
...ANALYSIS_LEGEND_GROUP
}))
];
}
export const BASE_LAYER_GROUPS = {
@@ -502,7 +500,8 @@ export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
export function createLayerControlItems(
layerVisibility: Record<string, boolean>,
availableSourceIds: readonly WaterNetworkSourceId[] = AVAILABLE_WATER_NETWORK_SOURCE_IDS,
featureCounts: Partial<Record<WaterNetworkSourceId, number>> = {}
featureCounts: Partial<Record<WaterNetworkSourceId, number>> = {},
analysisOverlays: readonly AnalysisOverlayLayer[] = []
): MapLayerControlItem[] {
const available = new Set<WaterNetworkSourceId>(availableSourceIds);
const catalogItems = SUPPLY_LAYER_CATALOG.filter(
@@ -560,16 +559,18 @@ export function createLayerControlItems(
groupDescription: "源数据快照与专项台账",
groupDefaultOpen: false
})),
{
id: "simulation",
label: "模拟结果",
description: "影响范围与事故标注",
visual: { kind: "area", color: MAP_STYLE_TOKENS.state.incident },
visible: layerVisibility.simulation,
...analysisOverlays.map((overlay) => ({
id: overlay.id,
label: overlay.title,
description: overlay.description,
featureCount: overlay.featureCount,
visual: { kind: "area" as const, color: overlay.color ?? MAP_STYLE_TOKENS.state.risk },
visible: overlay.visible,
disabled: overlay.disabled,
group: "analysis",
groupLabel: "分析覆盖",
groupDescription: "随已打开的空间成果显示"
}
groupDescription: "Agent 生成的范围与分析成果"
}))
];
}
@@ -589,8 +590,7 @@ export function getWorkbenchLayerIds(
.map((layer) => layer.id);
}
const layerIds = WORKBENCH_LAYER_GROUPS[groupId] ?? [];
return layerIds.filter((layerId) => map.getLayer(layerId));
return [];
}
function isWaterNetworkSourceId(value: string): value is WaterNetworkSourceId {
+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: []
});
};
@@ -1,3 +1,4 @@
import { formatLocalMonthDayTime } from "@/shared/utils/date-time";
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowUpRight, CheckCircle2, CircleHelp, Clock3, LoaderCircle, TriangleAlert } from "lucide-react";
@@ -54,7 +55,7 @@ export function ScadaMonitoringNotice({ result, animationId, processing, onOpenI
<span>调查监测</span>
{result ? <time dateTime={result.period_end} className="whitespace-nowrap tabular-nums">
<span className="sr-only">最新监测 </span>
{new Date(result.period_end).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false })}
{formatLocalMonthDayTime(result.period_end)}
</time> : <span>尚无监测结果</span>}
</div>
<div data-slot="scada-notice-content" className="flex min-w-0 items-center gap-3">
@@ -1,3 +1,4 @@
import { formatLocalMonthDayTime as dateTime } from "@/shared/utils/date-time";
import type { ScadaMonitoringCalibration, ScadaMonitoringConfig } from "../api/scada-replay-client";
export function ScadaMonitoringRules({ config, calibration, frozen }: {
@@ -68,7 +69,3 @@ function thresholdLabel(ids: string[], profiles: ScadaMonitoringCalibration["pre
const range = lower === upper ? `${direction}阈值 ${lower}%` : `${direction}阈值 ${lower}%~${upper}%(逐测点)`;
return thresholds.length < ids.length ? `${range} · 已就绪 ${thresholds.length}/${ids.length} 个测点` : range;
}
function dateTime(value: string) {
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
}
@@ -1,3 +1,4 @@
import { formatLocalMonthDayTime as stamp } from "@/shared/utils/date-time";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Activity,
@@ -430,9 +431,6 @@ export function ScadaReplaySection({
);
}
function stamp(value: string) {
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
}
function localTime(time: number) {
const date = new Date(time);
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { getReportPresentationState, snapshotRound } from "./report-presentation-state";
import type { OperationalTaskReport } from "../api/operational-task-client";
const snapshot = (generation: number | null, revision = 1) => ({
revision, execution_generation: generation, report: { stage: "final", outcome: "insufficient_data" }
}) as OperationalTaskReport;
describe("report presentation state", () => {
it("keeps historical unknown generations distinct from current and previous rounds", () => {
expect(snapshotRound(null, 2)).toBe("historical");
expect(snapshotRound(undefined, 2)).toBe("historical");
expect(snapshotRound(1, 2)).toBe("previous");
expect(snapshotRound(2, 2)).toBe("current");
});
it("does not let a previous final report own the current round's progress or conclusion", () => {
const previous = snapshot(1);
const task = { execution_generation: 2, latest_report: previous, lifecycle_status: "completed" as const };
expect(getReportPresentationState(task, previous)).toMatchObject({ previousRound: true, currentFinalReport: false, currentInsufficient: false, ended: false });
expect(getReportPresentationState(task, snapshot(null))).toMatchObject({ round: "historical", previousRound: true, currentFinalReport: false });
expect(getReportPresentationState(task, null)).toMatchObject({ previousRound: false, current: false });
});
it("distinguishes an older revision within the current round and an ended insufficient result", () => {
const task = { execution_generation: 2, latest_report: snapshot(2, 3), lifecycle_status: "completed" as const };
expect(getReportPresentationState(task, snapshot(2, 1))).toMatchObject({ olderRevision: true, currentFinalReport: true, currentInsufficient: true, ended: true });
});
});
@@ -0,0 +1,23 @@
import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client";
export function snapshotRound(generation: number | null | undefined, currentGeneration: number) {
return generation == null ? "historical" : generation === currentGeneration ? "current" : "previous";
}
export function getReportPresentationState(
task: Pick<OperationalTask, "execution_generation" | "latest_report" | "lifecycle_status">,
snapshot: OperationalTaskReport | null | undefined
) {
const round = snapshotRound(snapshot?.execution_generation, task.execution_generation);
const current = round === "current";
const insufficient = snapshot?.report.outcome === "insufficient_data";
return {
round, current, insufficient,
previousRound: Boolean(snapshot && !current),
olderRevision: Boolean(snapshot && task.latest_report && snapshot.revision !== task.latest_report.revision),
currentFinalReport: current && snapshot?.report.stage === "final",
currentInsufficient: current && insufficient,
ended: current && task.lifecycle_status === "completed",
stopped: task.lifecycle_status === "failed" || task.lifecycle_status === "cancelled"
};
}
+7 -3
View File
@@ -4,6 +4,7 @@ import { Rnd } from "react-rnd";
import { Maximize2, Minimize2, X } from "lucide-react";
import { Button } from "./button";
import { floatingWindowHost, floatingWindowOrder, raiseFloatingWindow, removeFloatingWindow, subscribeFloatingWindows } from "./floating-window-layer";
import { useFloatingWindowDrag } from "./use-floating-window-drag";
type Bounds = { x: number; y: number; width: number; height: number };
@@ -84,6 +85,9 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
const current = maximized
? { x: 8, y: 8, width: viewport.width - 16, height: viewport.height - 16 }
: bounds;
const startDrag = useFloatingWindowDrag({ enabled: open && !maximized, position: current,
width: current.width, height: current.height,
onCommit: (position) => setBounds((previous) => fitWindow({ ...previous, ...position })) });
return <>
<Button ref={trigger} type="button" variant="ghost" size="icon-sm" className="shrink-0"
@@ -102,8 +106,7 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
bounds="parent" minWidth={Math.min(320, viewport.width - 16)} minHeight={Math.min(260, viewport.height - 16)}
maxWidth={viewport.width - 16} maxHeight={viewport.height - 16}
dragHandleClassName="floating-window-heading" cancel="button,input,select,textarea,a"
disableDragging={maximized} enableResizing={!maximized}
onDragStop={(_, position) => setBounds((previous) => fitWindow({ ...previous, x: position.x, y: position.y }))}
disableDragging enableResizing={!maximized}
onResizeStop={(_, __, element, ___, position) => setBounds(fitWindow({ ...position, width: element.offsetWidth, height: element.offsetHeight }))}
style={{ zIndex: order.indexOf(id) + 1, display: "flex", flexDirection: "column" }}
resizeHandleClasses={{ bottomRight: "floating-window-resize" }}
@@ -123,7 +126,8 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
if (event.key === "Escape" && !event.defaultPrevented) { event.stopPropagation(); close(); }
}}>
<div ref={heading} tabIndex={0}
className="floating-window-heading flex min-h-12 shrink-0 cursor-move touch-none items-center justify-between gap-3 border-b border-border px-3 py-2 focus-visible:outline-2 focus-visible:outline-ring"
onPointerDown={startDrag}
className="floating-window-heading flex min-h-12 shrink-0 cursor-move touch-none select-none items-center justify-between gap-3 border-b border-border px-3 py-2 focus-visible:outline-2 focus-visible:outline-ring"
aria-label="窗口标题栏,可用方向键移动,Shift 加方向键调整大小"
onKeyDown={(event) => {
if (event.target !== event.currentTarget || maximized || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
+84
View File
@@ -0,0 +1,84 @@
import { useLayoutEffect, useRef, type PointerEvent as ReactPointerEvent } from "react";
import { flushSync } from "react-dom";
type Position = { x: number; y: number };
/** Move only the active DOM layer per frame; persist React bounds on release. */
export function useFloatingWindowDrag({ enabled, position, width, height, onCommit }: {
enabled: boolean;
position: Position;
width: number;
height: number;
onCommit: (position: Position) => void;
}) {
const cancel = useRef<(() => void) | null>(null);
useLayoutEffect(() => {
const stop = () => cancel.current?.();
window.addEventListener("resize", stop);
return () => {
stop();
window.removeEventListener("resize", stop);
};
}, [enabled]);
return (event: ReactPointerEvent<HTMLDivElement>) => {
if (!enabled || !event.isPrimary || event.button !== 0 || cancel.current
|| (event.target as Element).closest("button,input,select,textarea,a")) return;
const heading = event.currentTarget;
const element = heading.closest<HTMLElement>("[data-floating-window]");
if (!element) return;
event.preventDefault();
heading.focus({ preventScroll: true });
const pointerId = event.pointerId;
const startX = event.clientX;
const startY = event.clientY;
const maxX = window.innerWidth - width - 8;
const maxY = window.innerHeight - height - 8;
const previousTranslate = element.style.translate;
const previousWillChange = element.style.willChange;
let next = position;
let frame = 0;
element.style.willChange = "translate";
const updatePosition = (pointer: PointerEvent) => {
next = {
x: Math.max(8, Math.min(position.x + pointer.clientX - startX, maxX)),
y: Math.max(8, Math.min(position.y + pointer.clientY - startY, maxY))
};
};
const move = (pointer: PointerEvent) => {
if (pointer.pointerId !== pointerId) return;
updatePosition(pointer);
if (!frame) frame = requestAnimationFrame(() => {
frame = 0;
element.style.translate = `${next.x - position.x}px ${next.y - position.y}px`;
});
};
const cleanup = () => {
cancel.current = null;
cancelAnimationFrame(frame);
heading.removeEventListener("pointermove", move);
heading.removeEventListener("pointerup", finish);
heading.removeEventListener("pointercancel", abort);
heading.removeEventListener("lostpointercapture", abort);
element.style.translate = previousTranslate;
element.style.willChange = previousWillChange;
if (heading.hasPointerCapture(pointerId)) heading.releasePointerCapture(pointerId);
};
const abort = (pointer: PointerEvent) => {
if (pointer.pointerId === pointerId) cleanup();
};
const finish = (pointer: PointerEvent) => {
if (pointer.pointerId !== pointerId) return;
updatePosition(pointer);
// Commit and remove the temporary offset in one turn to avoid a snap.
if (next.x !== position.x || next.y !== position.y) flushSync(() => onCommit(next));
cleanup();
};
cancel.current = cleanup;
heading.addEventListener("pointermove", move);
heading.addEventListener("pointerup", finish);
heading.addEventListener("pointercancel", abort);
heading.addEventListener("lostpointercapture", abort);
heading.setPointerCapture(pointerId);
};
}
+2 -2
View File
@@ -2445,7 +2445,7 @@ details[open] > summary > .disclosure-accordion-indicator {
height: min(720px, 80dvh);
max-width: 760px;
}
/* Layout only: keep the shared acrylic material and its shadows unclipped. */
/* Flow layout owns stack geometry; keep Sonner opacity transitions without its absolute offsets. */
[data-sonner-toaster].map-notice-stack {
display: flex;
flex-direction: column;
@@ -2461,7 +2461,7 @@ details[open] > summary > .disclosure-accordion-indicator {
bottom: auto;
flex-shrink: 0;
height: auto;
--y: translateY(0);
transform: none;
}
[data-sonner-toaster].map-notice-stack [data-sonner-toast][data-visible="false"] {
display: none;
+69
View File
@@ -0,0 +1,69 @@
import { expect, test } from "@playwright/test";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
for (const status of ["completed", "error", "aborted"] as const) {
test(`tool progress settles after reconnect and reopening (${status})`, async ({ page }, testInfo) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
const sessionId = `tool-terminal-${status}`;
const title = `工具终态核查 ${status}`;
await mockAgentApi(page, { sessions: [{ session_id: sessionId, title, created_at: Date.now(), updated_at: Date.now() }] });
const headers = { "Access-Control-Allow-Origin": "*" };
const messages = [{ id: "tool-answer", role: "assistant", parts: [
{ type: "text", text: "已查到部分压力记录。" },
{ type: "data-progress", data: { id: "tool", phase: "tool", title: "正在处理", status: "running", tool: { name: "query_pressure", input: { node_id: 12 } } } },
] }];
let settled = false;
let finish: (() => void) | undefined;
const server = createServer((_request, response) => {
response.writeHead(200, { ...headers, "Content-Type": "text/event-stream" });
response.write(`event: state\ndata: ${JSON.stringify({ session_id: sessionId, messages, is_streaming: true, run_status: "running" })}\n\n`);
finish = () => {
settled = true;
const type = status === "completed" ? "done" : status === "aborted" ? "aborted" : "error";
const data = { session_id: sessionId, status, message: status === "error" ? "压力查询连接失败" : "请求已中断" };
// Legacy subscriptions may terminate without a separate run_status event.
response.end(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
};
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
await page.route(`**/api/v1/session/${sessionId}?**`, (route) => route.fulfill({ headers, json: {
session_id: sessionId, title, purpose: "ordinary_chat", status: "active",
is_streaming: !settled, run_status: settled ? status : "running", messages,
} }));
await page.route(`**/api/v1/session/${sessionId}/stream**`, (route) => route.continue({ url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` }));
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
const answer = panel.locator('[data-agent-message-id="tool-answer"]');
const icon = answer.locator('[data-agent-tool] [data-progress-status]');
const open = async () => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await panel.getByRole("button", { name: "打开 Agent 历史记录" }).click();
await page.locator("button.agent-history-session-trigger").filter({ hasText: title }).click();
};
try {
await open();
await expect(icon).toHaveAttribute("data-progress-status", "running");
await expect.poll(() => Boolean(finish)).toBe(true);
finish!();
await answer.getByRole("button", { name: "查看处理过程", exact: true }).click();
const expectedStatus = status === "aborted" ? "cancelled" : status;
await expect(icon).toHaveAttribute("data-progress-status", expectedStatus);
await expect(answer.locator('[data-progress-status="running"]')).toHaveCount(0);
if (status === "error") await expect(answer.getByRole("status").filter({ hasText: "压力查询连接失败" })).toBeVisible();
await answer.screenshot({ path: testInfo.outputPath(`${status}-settled.png`), animations: "disabled" });
await open();
await answer.getByRole("button", { name: "查看处理过程", exact: true }).click();
await expect(icon).toHaveAttribute("data-progress-status", expectedStatus);
await expect(answer.locator('[data-progress-status="running"]')).toHaveCount(0);
if (status === "aborted") await expect(icon).toHaveAttribute("aria-label", "已中止");
await answer.screenshot({ path: testInfo.outputPath(`${status}-reopened.png`), animations: "disabled" });
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
}
@@ -0,0 +1,54 @@
import { expect, test } from "@playwright/test";
import { investigationFixture, mockInvestigationPresentation, selectInvestigation } from "./support/mock-investigation-presentation";
test("lists Agent overlays in the layer panel and synchronizes visibility and cleanup", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const task = investigationFixture(1, "覆盖面图层核验");
const mock = await mockInvestigationPresentation(page, [task]);
await page.emulateMedia({ reducedMotion: "reduce" });
await selectInvestigation(page, task.title);
await expect.poll(() => page.evaluate(() => Boolean(window.__waterNetworkMap?.getLayer("simulation-backend-analysis-fill")))).toBe(true);
expect(await page.evaluate(() => Boolean(window.__waterNetworkMap?.getSource("simulation-impact-area")))).toBe(false);
const layerTool = page.getByRole("button", { name: /图层:管理地图图层/ });
await layerTool.click();
const panel = page.getByRole("region", { name: "图层", exact: true });
await expect(panel.getByText("模拟结果", { exact: true })).toHaveCount(0);
await expect(panel.getByText("分析覆盖", { exact: true })).toHaveCount(0);
const command = { action: "set_overlay", overlay_id: "affected-area", title: "爆管影响范围", mode: "geometry", evidence_id: "test:area", data: {
type: "FeatureCollection", features: [{ type: "Feature", properties: {}, geometry: {
type: "Polygon", coordinates: [[[121.48, 31.1], [121.49, 31.1], [121.49, 31.11], [121.48, 31.1]]]
} }]
} };
await mock.emit(task, command);
const row = panel.getByRole("button", { name: /^爆管影响范围,/ });
await expect(row).toBeVisible();
await expect(row).toHaveAttribute("aria-pressed", "true");
const visibility = () => page.evaluate(() => window.__waterNetworkMap?.getLayoutProperty("investigation-presentation-overlay-fill", "visibility"));
await expect.poll(visibility).toBe("visible");
await row.click();
await expect(row).toHaveAttribute("aria-pressed", "false");
await expect.poll(visibility).toBe("none");
await layerTool.click();
const display = page.getByLabel("调查实时展示", { exact: true });
await display.getByRole("button", { name: "显示覆盖层", exact: true }).click();
await expect.poll(visibility).toBe("visible");
await layerTool.click();
await expect(row).toHaveAttribute("aria-pressed", "true");
await page.screenshot({ path: testInfo.outputPath("agent-overlay-layer-desktop.png"), animations: "disabled" });
await page.setViewportSize({ width: 390, height: 844 });
await row.scrollIntoViewIfNeeded();
await expect(row).toBeVisible();
await row.click();
await expect.poll(visibility).toBe("none");
await row.click();
await expect.poll(visibility).toBe("visible");
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
await page.screenshot({ path: testInfo.outputPath("agent-overlay-layer-mobile.png"), animations: "disabled" });
await mock.emit(task, { action: "clear_overlay", overlay_id: "affected-area" });
await expect(row).toHaveCount(0);
await expect(panel.getByText("分析覆盖", { exact: true })).toHaveCount(0);
expect(mock.mutations).toEqual([]);
});
@@ -61,6 +61,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
]
};
let task: OperationalTask = {
execution_scope: "production",
project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null, status: "waiting_human", priority: "urgent",
revision: 2, execution_generation: 1, execution_request_id: "85000000-0000-4000-8000-000000000001",
@@ -8,6 +8,133 @@ const chart = (index: number) => ({
});
test.setTimeout(60_000);
// Keep unrelated avatar animation out of chart geometry and drag measurements.
test.use({ reducedMotion: "reduce" });
test("cancelled drags release capture and preserve restore, resizing and keyboard controls", async ({ page }) => {
const task = investigationFixture(1, "图窗拖动取消核查");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
await mock.emit(task, chart(1));
const popup = page.getByRole("dialog", { name: "合成压力分析 1", exact: true });
await expect(popup).toBeVisible();
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
const heading = popup.locator(".floating-window-heading");
await heading.click({ trial: true, position: { x: 200, y: 24 } });
const original = (await popup.boundingBox())!;
await heading.evaluate((node) => node.addEventListener("pointerdown", (event) => {
(node as HTMLElement).dataset.testPointerId = String((event as PointerEvent).pointerId);
}, { once: true }));
await page.mouse.move(original.x + 200, original.y + 24);
await page.mouse.down();
await page.mouse.move(original.x + 100, original.y + 64, { steps: 4 });
await expect.poll(async () => Math.round((await popup.boundingBox())!.x)).toBe(original.x - 100);
await heading.evaluate((node) => node.dispatchEvent(new PointerEvent("pointercancel", {
pointerId: Number((node as HTMLElement).dataset.testPointerId)
})));
await page.mouse.up();
expect(await popup.boundingBox()).toEqual(original);
expect(await popup.evaluate((node) => (node as HTMLElement).style.willChange)).toBe("");
await popup.getByRole("button", { name: "最大化窗口", exact: true }).click();
expect((await popup.boundingBox())!.x).toBe(8);
await popup.getByRole("button", { name: "还原窗口", exact: true }).click();
expect(await popup.boundingBox()).toEqual(original);
await heading.focus();
await page.keyboard.press("ArrowRight");
expect((await popup.boundingBox())!.x).toBe(original.x + 16);
await page.mouse.move(original.x + 16 + original.width - 3, original.y + original.height - 3);
await page.mouse.down();
await page.mouse.move(original.x + 16 + original.width + 37, original.y + original.height + 27, { steps: 4 });
await page.mouse.up();
expect((await popup.boundingBox())!.width).toBe(original.width + 40);
expect((await popup.boundingBox())!.height).toBe(original.height + 30);
await heading.focus();
await page.keyboard.press("Escape");
await expect(popup).toHaveCount(0);
expect(mock.mutations).toEqual([]);
});
test("profiles a single drag with eight comparison chart windows", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1600, height: 1000 });
const task = investigationFixture(1, "多图拖动性能核查");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
const slots = Array.from({ length: 13 }, (_, index) => `${String(5 + Math.floor(index / 4)).padStart(2, "0")}:${String(index % 4 * 15).padStart(2, "0")}`);
for (let index = 1; index <= 8; index++) {
await mock.emit(task, { action: "open_chart", chart_id: `comparison-${index}`, evidence_refs: ["synthetic:pressure"], chart: {
title: `合成七日同期曲线 ${index}`, chart_type: "time_comparison", x_data: slots,
series: [{ name: "当前", type: "line", data: slots.map((_, point) => 30 - index + point / 10),
sample_times: slots.map((time) => `2026-07-16T${time}:00+08:00`) },
...Array.from({ length: 7 }, (_, day) => ({ name: `前${day + 1}天`, type: "scatter",
data: slots.map((_, point) => point < 10 ? null : 30 + day / 10),
sample_times: slots.map((time, point) => point < 10 ? null : `2026-07-${String(15 - day).padStart(2, "0")}T${time}:00+08:00`) }))]
} });
await expect(page.getByRole("dialog", { name: `合成七日同期曲线 ${index}`, exact: true })).toBeVisible();
}
await expect(page.locator("[data-floating-window]")).toHaveCount(8);
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
await page.evaluate(() => document.fonts.ready);
const popup = page.getByRole("dialog", { name: "合成七日同期曲线 8", exact: true });
const otherWindows = page.locator('[data-floating-window]').filter({ hasNot: page.getByRole("heading", { name: "合成七日同期曲线 8", exact: true }) });
const otherPositions = await otherWindows.evaluateAll((nodes) => nodes.map((node) => {
const { x, y } = node.getBoundingClientRect();
return { x, y };
}));
const heading = (await popup.locator(".floating-window-heading").boundingBox())!;
const before = (await popup.boundingBox())!;
const cdp = await page.context().newCDPSession(page);
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
await cdp.send("Performance.enable");
const first = await cdp.send("Performance.getMetrics");
const events: Array<{ name: string; dur?: number }> = [];
cdp.on("Tracing.dataCollected", ({ value }) => events.push(...value));
await cdp.send("Tracing.start", { categories: "devtools.timeline", transferMode: "ReportEvents" });
await page.evaluate(() => {
Object.assign(window, { dragSvgMutations: 0, dragTransformWrites: 0 });
const observer = new MutationObserver((records) => {
(window as unknown as { dragSvgMutations: number }).dragSvgMutations += records.length;
});
document.querySelectorAll("[data-floating-window] svg").forEach((node) => observer.observe(node, { subtree: true, attributes: true, childList: true }));
const windowObserver = new MutationObserver((records) => {
for (const record of records) {
const previous = document.createElement("div");
previous.setAttribute("style", record.oldValue ?? "");
if (previous.style.transform !== (record.target as HTMLElement).style.transform)
(window as unknown as { dragTransformWrites: number }).dragTransformWrites++;
}
});
document.querySelectorAll("[data-floating-window]").forEach((node) => windowObserver.observe(node, { attributes: true, attributeFilter: ["style"], attributeOldValue: true }));
});
const x = heading.x + heading.width - 110;
const y = heading.y + heading.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x - 100, y - 60, { steps: 20 });
await expect.poll(async () => Math.round((await popup.boundingBox())!.x - before.x)).toBe(-100);
await page.mouse.move(x - 200, y - 120, { steps: 20 });
await page.mouse.up();
await expect.poll(async () => Math.round((await popup.boundingBox())!.x - before.x)).toBe(-200);
await expect.poll(async () => Math.round((await popup.boundingBox())!.y - before.y)).toBe(-120);
const last = await cdp.send("Performance.getMetrics");
const finished = new Promise<void>((resolve) => cdp.once("Tracing.tracingComplete", () => resolve()));
await cdp.send("Tracing.end");
await finished;
const metric = (name: string) => (last.metrics.find((item) => item.name === name)?.value ?? 0) - (first.metrics.find((item) => item.name === name)?.value ?? 0);
const summary = { metrics: Object.fromEntries(["ScriptDuration", "LayoutDuration", "RecalcStyleDuration", "TaskDuration", "LayoutCount", "RecalcStyleCount"].map((name) => [name, metric(name)])),
trace: Object.fromEntries(["Paint", "Layout", "UpdateLayoutTree", "FunctionCall"].map((name) => [name, { count: events.filter((event) => event.name === name).length,
ms: events.filter((event) => event.name === name).reduce((total, event) => total + (event.dur ?? 0), 0) / 1000 }])),
svgMutations: await page.evaluate(() => (window as unknown as { dragSvgMutations: number }).dragSvgMutations),
transformWrites: await page.evaluate(() => (window as unknown as { dragTransformWrites: number }).dragTransformWrites) };
await testInfo.attach("drag-profile", { body: JSON.stringify(summary, null, 2), contentType: "application/json" });
console.info("Window drag metrics", JSON.stringify(summary));
expect(summary.transformWrites).toBeLessThanOrEqual(2);
expect(summary.svgMutations).toBe(0);
expect(await otherWindows.evaluateAll((nodes) => nodes.map((node) => {
const { x, y } = node.getBoundingClientRect();
return { x, y };
}))).toEqual(otherPositions);
await testInfo.attach("multiple-charts-after-drag", { body: await page.screenshot({ path: testInfo.outputPath("multiple-charts-after-drag.png") }), contentType: "image/png" });
});
for (const height of [900, 720]) {
test(`multiple chart headings stay accessible and drag on first press at ${height}px height`, async ({ page }, testInfo) => {
@@ -19,15 +146,18 @@ for (const height of [900, 720]) {
await mock.emit(task, { ...chart(index), chart_id: `pressure-${index}` });
await expect(page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true })).toBeVisible();
}
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
if (height === 900) await page.setViewportSize({ width: 1440, height: 720 });
for (let index = 1; index <= 4; index++) {
const popup = page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true });
const heading = popup.locator(".floating-window-heading");
const box = (await heading.boundingBox())!;
expect((await popup.boundingBox())!.y).toBe(64 + (index - 1) * (box.height + 8));
const target = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
expect(await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.closest("[data-floating-window]")?.getAttribute("aria-labelledby"), target))
.toBe(await popup.getAttribute("aria-labelledby"));
await expect.poll(async () => {
const current = (await heading.boundingBox())!;
return page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.closest("[data-floating-window]")?.getAttribute("aria-labelledby"),
{ x: current.x + current.width / 2, y: current.y + current.height / 2 });
}).toBe(await popup.getAttribute("aria-labelledby"));
}
await testInfo.attach(`multiple-charts-before-drag-${height}`, {
body: await page.screenshot({ path: testInfo.outputPath(`multiple-charts-before-drag-${height}.png`) }), contentType: "image/png"
+193
View File
@@ -0,0 +1,193 @@
import { expect, test, type Page } from "@playwright/test";
import { mockInvestigationPresentation } from "./support/mock-investigation-presentation";
test.beforeEach(async ({ page }) => {
test.setTimeout(60_000);
await mockInvestigationPresentation(page, []);
});
async function openMap(page: Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
}
async function clearInitialNotices(page: Page) {
await page.evaluate(async () => {
const path = "/src/features/map/core/components/notice-actions.tsx";
(await import(path)).dismissMapNotice();
});
await expect(page.locator("[data-sonner-toast]")).toHaveCount(0);
}
test("tile cancellation events during map navigation do not announce source outages", async ({ page }) => {
await openMap(page);
await clearInitialNotices(page);
await page.evaluate(() => {
const map = window.__waterNetworkMap!;
Object.assign(window, { noticeTileAborts: 0, noticeFalseOutages: 0 });
map.on("sourcedataabort", () => {
const state = window as unknown as { noticeTileAborts: number };
state.noticeTileAborts++;
});
const observer = new MutationObserver((records) => {
for (const record of records) for (const node of record.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
for (const notice of [node, ...node.querySelectorAll("[data-sonner-toast]")]) {
if (notice.matches("[data-sonner-toast]") && notice.textContent?.includes("GeoServer MVT 降级运行")) {
(window as unknown as { noticeFalseOutages: number }).noticeFalseOutages++;
}
}
}
});
observer.observe(document.body, { subtree: true, childList: true });
map.jumpTo({ zoom: 14 });
});
for (let i = 0; i < 3; i++) {
await page.mouse.move(220, 420);
await page.mouse.down();
await page.mouse.move(i % 2 ? 450 : 750, 450, { steps: 5 });
await page.mouse.up();
// Deliver the same cancellation event MapLibre emits for obsolete tiles,
// independently of cache timing or how quickly the fixture responds.
await page.evaluate(() => window.__waterNetworkMap!.fire("sourcedataabort", { sourceId: "pipes" }));
await page.waitForTimeout(150);
}
await expect.poll(() => page.evaluate(() => (window as unknown as { noticeTileAborts: number }).noticeTileAborts)).toBeGreaterThan(0);
expect(await page.evaluate(() => (window as unknown as { noticeFalseOutages: number }).noticeFalseOutages)).toBe(0);
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 降级运行" })).toHaveCount(0);
});
test("a failed map source recovers in the same visible notice without entering removal", async ({ page }) => {
await openMap(page);
await page.evaluate(() => window.__waterNetworkMap!.fire("sourcedata", { sourceId: "pipes", isSourceLoaded: true }));
await page.waitForTimeout(350);
await clearInitialNotices(page);
await page.evaluate(() => window.__waterNetworkMap!.fire("error", { sourceId: "pipes", error: new Error("notice-regression-test") }));
const notice = page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 不可用" });
await expect(notice).toBeVisible();
await expect(notice).toHaveCSS("opacity", "1");
const node = await notice.elementHandle();
await page.evaluate(() => window.__waterNetworkMap!.fire("sourcedata", { sourceId: "pipes", isSourceLoaded: true }));
await page.waitForTimeout(350);
expect(await node!.evaluate((element) => ({
connected: element.isConnected,
removed: element.getAttribute("data-removed"),
text: element.textContent
}))).toMatchObject({ connected: true, removed: "false", text: expect.stringContaining("GeoServer MVT 已连接") });
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 已连接" })).toBeVisible();
});
test("notice surfaces capture input above the task ticker while the outside map remains draggable", async ({ page }, testInfo) => {
await openMap(page);
await clearInitialNotices(page);
await page.evaluate(async () => {
const path = "/src/features/map/core/components/notice-actions.tsx";
(await import(path)).showMapNotice({ id: "notice-input-regression", tone: "warning", title: "通知交互回归",
message: "通知覆盖任务浮动条,滚轮与拖动由通知接收。", actionLabel: "查看任务", onAction: () => {} });
});
const notice = page.locator("[data-sonner-toast]").filter({ hasText: "通知交互回归" });
await expect(notice).toHaveCSS("opacity", "1");
const ticker = page.locator(".agent-task-ticker");
await expect(ticker).toBeVisible();
const point = await page.evaluate(() => {
const ticker = document.querySelector(".agent-task-ticker")!;
const r = ticker.getBoundingClientRect();
Object.assign(window, { noticeTickerWheelEvents: 0 });
ticker.addEventListener("wheel", () => {
(window as unknown as { noticeTickerWheelEvents: number }).noticeTickerWheelEvents++;
});
return { x: r.x + 170, y: r.y + 25 };
});
expect(await notice.evaluate((element, point) => element.contains(document.elementFromPoint(point.x, point.y)), point)).toBe(true);
const before = await notice.boundingBox();
await page.mouse.move(point.x, point.y);
await page.mouse.wheel(0, 120);
await page.mouse.down();
await page.mouse.move(point.x + 35, point.y + 15, { steps: 4 });
await page.mouse.up();
await expect(notice).toHaveCount(1);
expect(await notice.boundingBox()).toEqual(before);
expect(await page.evaluate(() => (window as unknown as { noticeTickerWheelEvents: number }).noticeTickerWheelEvents)).toBe(0);
const center = await page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray());
await page.mouse.move(220, 420);
await page.mouse.down();
await page.mouse.move(330, 450, { steps: 6 });
await page.mouse.up();
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray())).not.toEqual(center);
await testInfo.attach("notice-above-ticker", { body: await page.screenshot({ path: testInfo.outputPath("notice-priority.png") }), contentType: "image/png" });
await notice.getByRole("button", { name: "关闭通知" }).click();
await expect(notice).toHaveCount(0);
});
test("multiple notices stay anchored while panning and source recovery exits without a downward jump", async ({ page }, testInfo) => {
await openMap(page);
await clearInitialNotices(page);
await page.evaluate(async () => {
const path = "/src/features/map/core/components/notice-actions.tsx";
const notices = await import(path);
notices.showMapNotice({ id: "scada-position-regression", tone: "error", title: "SCADA 位置回归",
message: "监测设备暂不可用。", duration: Infinity });
notices.showMapNotice({ id: "geoserver-position-regression", tone: "error", title: "GeoServer MVT 不可用",
message: "地图数据源请求失败。", duration: Infinity });
});
const recovery = page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 不可用" });
await expect(recovery).toBeVisible();
await page.evaluate(async () => {
const path = "/src/features/map/core/components/notice-actions.tsx";
(await import(path)).showMapNotice({ id: "report-position-regression", title: "报告位置回归",
message: "报告入口持续保留。", actionLabel: "查看报告", onAction: () => {} });
});
const scada = page.locator("[data-sonner-toast]").filter({ hasText: "SCADA 位置回归" });
const report = page.locator("[data-sonner-toast]").filter({ hasText: "报告位置回归" });
await expect(report).toHaveCSS("opacity", "1");
await expect.poll(() => page.locator("[data-sonner-toaster]").evaluate((element) =>
element.getAnimations({ subtree: true }).filter((animation) => animation.playState === "running").length
)).toBe(0);
const boxes = await Promise.all([report.boundingBox(), recovery.boundingBox(), scada.boundingBox()]);
const center = await page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray());
const track = await recovery.evaluateHandle((element) => {
const cards = ["报告位置回归", "GeoServer MVT 不可用", "SCADA 位置回归"].map((title) =>
[...document.querySelectorAll("[data-sonner-toast]")].find((card) => card.textContent?.includes(title))!);
const state = { stopped: false, panning: true, panY: [] as number[][], liveY: [] as number[], exitY: [] as number[] };
const sample = () => {
if (state.stopped || !element.isConnected) return;
if (state.panning) state.panY.push(cards.map((card) => card.getBoundingClientRect().y));
const y = element.getBoundingClientRect().y;
(element.getAttribute("data-removed") === "true" ? state.exitY : state.liveY).push(y);
requestAnimationFrame(sample);
};
sample();
return state;
});
for (let i = 0; i < 3; i++) {
await page.mouse.move(220, 420);
await page.mouse.down();
await page.mouse.move(i % 2 ? 450 : 750, 450, { steps: 8 });
await page.mouse.up();
}
expect(await page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray())).not.toEqual(center);
expect(await Promise.all([report.boundingBox(), recovery.boundingBox(), scada.boundingBox()])).toEqual(boxes);
await track.evaluate((state) => { state.panning = false; });
await page.evaluate(async () => {
const path = "/src/features/map/core/components/notice-actions.tsx";
(await import(path)).showMapNotice({ id: "geoserver-position-regression", tone: "success", title: "GeoServer MVT 已连接",
message: "地图数据源已恢复正常。", duration: 700 });
});
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 已连接" })).toBeVisible();
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 已连接" })).toHaveCount(0, { timeout: 7000 });
const samples = await track.evaluate((state) => { state.stopped = true; return state; });
expect(samples.panY.length).toBeGreaterThan(0);
for (const positions of samples.panY) {
positions.forEach((y, index) => expect(Math.abs(y - boxes[index]!.y)).toBeLessThan(1));
}
expect(samples.liveY.length).toBeGreaterThan(0);
expect(samples.exitY.length).toBeGreaterThan(0);
for (const y of [...samples.liveY, ...samples.exitY]) expect(y).toBeLessThanOrEqual(boxes[1]!.y + 1);
expect((await report.boundingBox())!.y).toBe(boxes[0]!.y);
const reportBox = (await report.boundingBox())!;
const scadaBox = (await scada.boundingBox())!;
expect(scadaBox.y).toBeCloseTo(reportBox.y + reportBox.height + 8, 0);
await testInfo.attach("notice-positions-after-pan-and-recovery", {
body: await page.screenshot({ path: testInfo.outputPath("notice-positions.png") }), contentType: "image/png"
});
});
@@ -66,6 +66,7 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
execution_detail: null
};
let task: OperationalTask = {
execution_scope: "production",
project_id: projectId,
task_id: taskId,
task_type: "event_investigation",
@@ -3,13 +3,13 @@ import type { OperationalTask, OperationalTaskSummary } from "../../src/features
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
test("调查和预约任务接收 Server 返回的空回放上下文并展示详情", async ({ page }, testInfo) => {
test("调查和预约任务接收 Server 返回的执行范围与空回放上下文并展示详情", async ({ page }, testInfo) => {
await page.emulateMedia({ reducedMotion: "reduce" });
const now = new Date().toISOString();
const id = "72000000-0000-4000-8000-000000000001";
const appointmentId = "72000000-0000-4000-8000-000000000002";
const summary: OperationalTaskSummary = {
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e", task_id: id,
execution_scope: "production", project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e", task_id: id,
task_type: "event_investigation", workflow: "burst_report",
lifecycle_status: "waiting_human", active_run: null, status: "waiting_human",
priority: "urgent", revision: 1, execution_generation: 1, execution_request_id: id,
@@ -65,7 +65,7 @@ test("调查和预约任务接收 Server 返回的空回放上下文并展示详
await panel.getByRole("button", { name: /调查任务/ }).click();
await panel.getByText(summary.title, { exact: true }).first().click();
await expect(panel.getByTestId("scheduled-condition-detail-scroll")).toContainText(summary.summary);
await testInfo.attach("investigation-loaded", { body: await panel.screenshot(), contentType: "image/png" });
await testInfo.attach("investigation-loaded", { body: await panel.screenshot({ path: testInfo.outputPath("investigation-loaded.png") }), contentType: "image/png" });
await panel.getByRole("button", { name: /预约任务/ }).click();
await panel.getByText(appointment.title, { exact: true }).first().click();
@@ -73,5 +73,5 @@ test("调查和预约任务接收 Server 返回的空回放上下文并展示详
await expect(detail).toContainText("核对预约任务详情");
await expect(detail).toContainText("凤阳维修组");
await expect(detail).toContainText("凤阳核验点");
await testInfo.attach("appointment-loaded", { body: await panel.screenshot(), contentType: "image/png" });
await testInfo.attach("appointment-loaded", { body: await panel.screenshot({ path: testInfo.outputPath("appointment-loaded.png") }), contentType: "image/png" });
});
@@ -1267,6 +1267,7 @@ test.describe("外围系统", () => {
if (request.method() === "POST" && url.pathname.endsWith("/operational-tasks")) {
const input = request.postDataJSON() as Record<string, unknown>;
operationalTask = {
execution_scope: "production",
project_id: projectId, task_id: "70000000-0000-4000-8000-000000000001",
task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null,
@@ -17,6 +17,7 @@ async function expectPushedOrder(page: Page, failFirstDetail = false) {
conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: []
};
const task = {
execution_scope: "production",
project_id: TEST_PROJECT_ID, task_id: taskId, task_type: "external_work_order",
workflow: "external-work-order", lifecycle_status: "open", active_run: null,
status: "queued", priority: "normal", revision: 2, execution_generation: 1,
+2 -2
View File
@@ -103,7 +103,7 @@ test("shows data-dependent layer controls only when GeoServer reports features",
const layerIds = globalThis.__waterNetworkMap?.getStyle().layers.map((layer) => layer.id) ?? [];
return {
valveLabel: layerIds.indexOf("workbench-value-label-valves"),
simulationLabel: layerIds.indexOf("simulation-burst-label")
simulationLabel: layerIds.indexOf("simulation-backend-analysis-label")
};
})).toMatchObject({
valveLabel: expect.any(Number),
@@ -113,7 +113,7 @@ test("shows data-dependent layer controls only when GeoServer reports features",
const layerIds = globalThis.__waterNetworkMap?.getStyle().layers.map((layer) => layer.id) ?? [];
return [
layerIds.indexOf("workbench-value-label-valves"),
layerIds.indexOf("simulation-burst-label")
layerIds.indexOf("simulation-backend-analysis-label")
];
});
expect(layerOrder[0]).toBeGreaterThanOrEqual(0);
@@ -11,6 +11,7 @@ export function investigationFixture(index: number, title: string): OperationalT
const runId = `93000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
const requestId = `94000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
return {
execution_scope: "production",
project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "burst_report",
lifecycle_status: "open", status: "running", priority: "normal", revision: 1, execution_generation: 1,
execution_request_id: requestId, title, summary: "仅供浏览器测试的合成调查",
@@ -377,9 +377,8 @@ test("keeps a non-spatial backend result in the analysis workspace without demo
await expect.poll(() => page.evaluate(() => ({
backend: window.__waterNetworkMap
?.querySourceFeatures("simulation-backend-analysis").length ?? 0,
demo: window.__waterNetworkMap
?.querySourceFeatures("simulation-impact-area").length ?? 0
}))).toEqual({ backend: 0, demo: 0 });
demoSource: Boolean(window.__waterNetworkMap?.getSource("simulation-impact-area"))
}))).toEqual({ backend: 0, demoSource: false });
});
test("keeps mock conditions while using backend work orders and alerts", async ({ page }) => {