Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7eb01652a | ||
|
|
038d3458bc | ||
|
|
692bbf609c | ||
|
|
ad1f7ca7a9 | ||
|
|
aa9b12662e | ||
|
|
b948551c1f | ||
|
|
e0fe4a5a10 |
@@ -89,6 +89,7 @@ pnpm format # 使用 Prettier 格式化文件
|
||||
pnpm test # 运行 Vitest 单元测试
|
||||
pnpm test:watch # 监听模式运行单元测试
|
||||
pnpm test:browser # 运行 Playwright 浏览器测试
|
||||
pnpm check:contracts # 在三端集成工作区只读检查前端生成的 Agent/Server 契约
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
@@ -56,7 +56,9 @@ Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、
|
||||
|
||||
通知沿用 `glass-transient` 亚克力透明材质、12px 圆角、背景模糊与浮层阴影,复用工作台透明度、字体和语义色状态图标。标题、正文、操作与关闭区域分开排布,长文本自然换行。操作区使用宝石蓝入口,并提示“保留至手动关闭”;点击查看不移除通知。
|
||||
|
||||
通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。每条通知均可单独关闭;支持 320px 窄屏。普通信息与成功提示默认 3.2 秒后关闭,警告与错误默认手动关闭,加载通知由其所属操作更新或结束。带操作入口时始终提供手动关闭按钮。Dev 的“通知测试”提供五种状态、加载完成、报告与任务入口、长文本、连续六条通知和清除测试通知。
|
||||
通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。通知采用普通文档流纵向排版,间距为 8px,高度随换行自适应;保留 Sonner 淡入淡出,禁用按绝对定位计算的位移变换,避免退出时重复叠加堆叠偏移。每条通知均可单独关闭;支持 320px 窄屏。普通信息与成功提示默认 3.2 秒后关闭,警告与错误默认手动关闭,加载通知由其所属操作更新或结束。带操作入口时始终提供手动关闭按钮。Dev 的“通知测试”提供五种状态、加载完成、报告与任务入口、长文本、连续六条通知和清除测试通知。
|
||||
|
||||
通知表面优先接收鼠标与滚轮,不将操作穿透给下方任务浮动条或地图;不提供滑动移除,关闭通过关闭按钮完成,通知外仍可操作地图。地图拖动、缩放引起的旧瓦片请求取消不视为数据源故障;首次正常就绪保持静默,故障恢复时才显示连接成功提示。同一编号的通知更新内容时保留原位置,不先关闭再重建;所属组件离开或编号变化时才清理。
|
||||
|
||||
## 2. Tailwind 定制方式
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiN
|
||||
const start = index(proposed[0], labels.length);
|
||||
const end = Math.max(start, index(proposed[1], labels.length, labels.length - 1));
|
||||
const hidden = indices(state.hidden, series.length);
|
||||
const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type as "line" | "bar",
|
||||
const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type === "bar" ? "bar" : "line",
|
||||
hidden: hidden.length < series.length ? hidden : [], range: [start, end], point: index(state.point, labels.length), inspecting: state.inspecting === true };
|
||||
const data = answerChartTableData(node, chart);
|
||||
const table = normalizeAnswerNodeState({ type: "Table", props: data, children: [] }, { ...record(state.table), type: "Table" }) as AnswerTableState;
|
||||
|
||||
@@ -64,7 +64,7 @@ export const answerUiCatalog = defineCatalog(schema, {
|
||||
props: z
|
||||
.object({
|
||||
title: shortText,
|
||||
chart_type: z.enum(["line", "bar"]),
|
||||
chart_type: z.enum(["line", "bar", "time_comparison"]),
|
||||
x_axis_name: z.string().max(128).optional(),
|
||||
y_axis_name: z.string().max(128).optional(),
|
||||
x_data: z.array(shortText).min(1).max(100),
|
||||
@@ -82,7 +82,7 @@ export const answerUiCatalog = defineCatalog(schema, {
|
||||
})
|
||||
.strict(),
|
||||
description:
|
||||
"展示查询或用户提供的真实数据;series 必须与 x_data 对齐。浏览者可切换图形与数据表。"
|
||||
"展示真实数据。chart_type=line 为普通折线,bar 为柱状图,time_comparison 为当前曲线与历史散点的同期对比。同期对比的 x_data 为 UTC+8 的 HH:mm 或 HH:mm:ss 共享时刻槽,series.type 使用 line/scatter,每组 sample_times 与 data 等长,保留真实带时区日期,缺测均填 null;历史点放入对应时刻槽。浏览者可切换图形与数据表。"
|
||||
},
|
||||
Evidence: {
|
||||
props: z
|
||||
|
||||
@@ -16,7 +16,7 @@ import { AnswerInteractionContext } from "./answer-ui-interaction-context";
|
||||
|
||||
export type AnswerChartProps = {
|
||||
title: string;
|
||||
chart_type: "line" | "bar";
|
||||
chart_type: "line" | "bar" | "time_comparison";
|
||||
x_data: string[];
|
||||
series: SafeChartSeries[];
|
||||
x_axis_name?: string;
|
||||
@@ -100,7 +100,7 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
|
||||
const numberLabel = chartValueLabel;
|
||||
const option = (type: "line" | "bar") => {
|
||||
const horizontal = type === "bar" && horizontalBars;
|
||||
const seriesType = (series: SafeChartSeries) => view === props.chart_type ? series.type ?? type : type;
|
||||
const seriesType = (series: SafeChartSeries) => view === (props.chart_type === "bar" ? "bar" : "line") ? series.type ?? type : type;
|
||||
const includesBars = visible.some((series) => seriesType(series) === "bar");
|
||||
const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar",
|
||||
axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -55,6 +55,7 @@ export {
|
||||
cancelRunningTodos,
|
||||
completeRunningProgress,
|
||||
finalizeAssistantMessageAfterAbort,
|
||||
finalizeAssistantMessageAfterError,
|
||||
toTodoUpdate,
|
||||
upsertPermission,
|
||||
upsertProgress,
|
||||
|
||||
@@ -15,7 +15,7 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
|
||||
const chartSchema = z
|
||||
.object({
|
||||
title: z.string().max(200),
|
||||
chart_type: z.enum(["line", "bar"]),
|
||||
chart_type: z.enum(["line", "bar", "time_comparison"]),
|
||||
x_axis_name: z.string().max(128).optional(),
|
||||
y_axis_name: z.string().max(128).optional(),
|
||||
x_data: z.array(z.string().max(128)).min(1).max(100),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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");
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,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: [] };
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useId, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
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 };
|
||||
|
||||
@@ -48,16 +49,31 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
|
||||
};
|
||||
|
||||
useEffect(() => () => removeFloatingWindow(id), [id]);
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const offset = (floatingWindowOrder().filter((windowId) => windowId !== id).length % 5) * 24;
|
||||
setBounds((current) => fitWindow({ ...current, x: current.x + offset, y: current.y + offset }));
|
||||
const others = floatingWindowOrder().filter((windowId) => windowId !== id);
|
||||
const headingHeight = Array.from(floatingWindowHost().querySelectorAll<HTMLElement>(".floating-window-heading"))
|
||||
.reduce((height, element) => Math.max(height, element.offsetHeight), 48);
|
||||
const step = headingHeight + 8;
|
||||
const minHeight = Math.min(260, window.innerHeight - 16);
|
||||
const slots = Math.max(1, Math.floor((window.innerHeight - 64 - minHeight - 8) / step) + 1);
|
||||
const slot = others.length % slots;
|
||||
const y = Math.max(8, Math.min(64 + slot * step, window.innerHeight - minHeight - 8));
|
||||
// Keep the preceding headings exposed. Shorter viewports reduce content
|
||||
// height instead of clamping several windows onto the same title bar.
|
||||
// Absolute placement also makes StrictMode's repeated setup idempotent.
|
||||
setBounds(fitWindow({ x: (window.innerWidth - 800) / 2 + slot * 24, y,
|
||||
width: 800, height: Math.min(560, window.innerHeight - y - 8) }));
|
||||
raiseFloatingWindow(id);
|
||||
if (focusOnOpen.current) heading.current?.focus({ preventScroll: true });
|
||||
focusOnOpen.current = false;
|
||||
const resize = () => {
|
||||
setViewport({ width: window.innerWidth, height: window.innerHeight });
|
||||
setBounds((current) => fitWindow(current));
|
||||
setBounds((current) => {
|
||||
const minimumHeight = Math.min(260, window.innerHeight - 16);
|
||||
const y = Math.max(8, Math.min(current.y, window.innerHeight - minimumHeight - 8));
|
||||
return fitWindow({ ...current, y, height: Math.min(current.height, window.innerHeight - y - 8) });
|
||||
});
|
||||
};
|
||||
window.addEventListener("resize", resize);
|
||||
return () => {
|
||||
@@ -69,15 +85,16 @@ 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"
|
||||
aria-label={triggerLabel ?? `放大查看 ${title}`} title="放大查看" aria-haspopup="dialog" aria-expanded={open}
|
||||
onClick={() => {
|
||||
if (!open) {
|
||||
const offset = (order.length % 5) * 24;
|
||||
setViewport({ width: window.innerWidth, height: window.innerHeight });
|
||||
setBounds(fitWindow({ x: (window.innerWidth - 800) / 2 + offset, y: 64 + offset, width: 800, height: 560 }));
|
||||
setMaximized(false);
|
||||
focusOnOpen.current = true;
|
||||
setManuallyOpen(true);
|
||||
@@ -89,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" }}
|
||||
@@ -110,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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,180 @@ 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) => {
|
||||
await page.setViewportSize({ width: 1440, height });
|
||||
const task = investigationFixture(1, "多图窗口拖动核查");
|
||||
const mock = await mockInvestigationPresentation(page, [task]);
|
||||
await selectInvestigation(page, task.title);
|
||||
for (let index = 1; index <= 4; index++) {
|
||||
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));
|
||||
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"
|
||||
});
|
||||
for (const index of [1, 4, 2]) {
|
||||
const popup = page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true });
|
||||
const before = (await popup.boundingBox())!;
|
||||
const heading = (await popup.locator(".floating-window-heading").boundingBox())!;
|
||||
const start = { x: heading.x + heading.width - 110, y: heading.y + heading.height / 2 };
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(start.x - 120, start.y - 32, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(async () => Math.round((await popup.boundingBox())!.x - before.x)).toBe(-120);
|
||||
await expect.poll(async () => Math.round((await popup.boundingBox())!.y - before.y)).toBe(-32);
|
||||
expect((await popup.boundingBox())!.width).toBe(before.width);
|
||||
expect((await popup.boundingBox())!.height).toBe(before.height);
|
||||
}
|
||||
await testInfo.attach(`multiple-charts-after-drag-${height}`, {
|
||||
body: await page.screenshot({ path: testInfo.outputPath(`multiple-charts-after-drag-${height}.png`) }), contentType: "image/png"
|
||||
});
|
||||
expect(mock.mutations).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("live analysis opens a window, preserves its position and respects closing and task switches", async ({ page }, testInfo) => {
|
||||
const task = investigationFixture(1, "图表自动展示核查");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,8 +9,8 @@ const times = Array.from({ length: 13 }, (_, index) => `${String(9 + Math.floor(
|
||||
const block = {
|
||||
version: "answer-ui@1", block_id: "scada-week", phase: "final", fallback_text: "合成 SCADA 七日对照",
|
||||
spec: { root: "chart", elements: { chart: { type: "Chart", children: [], props: {
|
||||
title: "压力与前七天同时刻对照(m)", chart_type: "line", y_axis_name: "压力(m)", x_axis_name: "时间(UTC0)",
|
||||
x_data: times.map((time) => new Date(`2026-06-14T${time}:00+08:00`).toISOString().slice(11, 16)),
|
||||
title: "压力与前七天同时刻对照(m)", chart_type: "time_comparison", y_axis_name: "压力(m)", x_axis_name: "时间",
|
||||
x_data: times,
|
||||
series: [{ name: "当日三小时", type: "line", data: [34, 34.2, 33.8, 34, 33.5, 33, 32.5, 32, 31, 30, 29, 27, 25],
|
||||
sample_times: times.map((time) => new Date(`2026-06-14T${time}:00+08:00`).toISOString().replace(".000Z", "Z")) },
|
||||
...Array.from({ length: 7 }, (_, index) => ({ name: `6月${13 - index}日`, type: "scatter",
|
||||
@@ -45,7 +45,7 @@ for (const width of [1440, 375, 320]) {
|
||||
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
const ui = panel.locator(".answer-ui-chart");
|
||||
await expect(ui).toBeVisible();
|
||||
await expect(ui).toContainText("时间(UTC+8)");
|
||||
await expect(ui.locator("svg text").filter({ hasText: /^时间$/ })).toBeVisible();
|
||||
await expect(ui.getByRole("tab", { name: "复合", exact: true })).toBeVisible();
|
||||
await expect(ui.getByLabel("显示的数据序列").getByRole("button")).toHaveCount(8);
|
||||
// Each historical day has exactly three unconnected SVG scatter symbols.
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user