Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7eb01652a | ||
|
|
038d3458bc | ||
|
|
692bbf609c |
@@ -89,6 +89,7 @@ pnpm format # 使用 Prettier 格式化文件
|
|||||||
pnpm test # 运行 Vitest 单元测试
|
pnpm test # 运行 Vitest 单元测试
|
||||||
pnpm test:watch # 监听模式运行单元测试
|
pnpm test:watch # 监听模式运行单元测试
|
||||||
pnpm test:browser # 运行 Playwright 浏览器测试
|
pnpm test:browser # 运行 Playwright 浏览器测试
|
||||||
|
pnpm check:contracts # 在三端集成工作区只读检查前端生成的 Agent/Server 契约
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"export:answer-ui-contract": "node scripts/export-answer-ui-contract.mjs",
|
"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": "tsc --noEmit && vite build && pnpm build:verify-css && pnpm build:tts",
|
||||||
"build:verify-css": "node scripts/verify-production-css.mjs",
|
"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",
|
"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 process from "node:process";
|
||||||
import { writeFile } from "node:fs/promises";
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||||
import { answerUiCatalog, ANSWER_UI_VERSION } from "../src/features/agent/answer-ui/catalog.ts";
|
import { answerUiCatalog, ANSWER_UI_VERSION } from "../src/features/agent/answer-ui/catalog.ts";
|
||||||
|
|
||||||
const targets = process.argv[2]
|
const check = process.argv.includes("--check");
|
||||||
? [resolve(process.argv[2])]
|
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-agent/src/chat/answer-ui-catalog.json"),
|
||||||
resolve("../next-tjwater-server/contracts/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) {
|
for (const target of targets) {
|
||||||
await writeFile(target, `${JSON.stringify(contract, null, 2)}\n`);
|
if (check) {
|
||||||
process.stdout.write(`Exported ${contract.components.length} components to ${target}\n`);
|
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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,35 +233,11 @@ describe("Agent API client sessions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back when a candidate is not the agent service", async () => {
|
it("uses the configured Agent address once and propagates its error", async () => {
|
||||||
const fetchMock = vi
|
const fetchMock = vi.fn().mockResolvedValue(Response.json({ message: "unavailable" }, { status: 503 }));
|
||||||
.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 }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
await expect(createAgentApiClient("http://agent.local/prefix/").getUiRegistry()).rejects.toThrow("unavailable");
|
||||||
await expect(createAgentApiClient(["http://not-agent.local", "http://agent.local"]).getUiRegistry()).resolves.toEqual({
|
expect(fetchMock).toHaveBeenCalledExactlyOnceWith("http://agent.local/prefix/api/v1/ui-registry", undefined);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends title update payloads using backend field names", async () => {
|
it("sends title update payloads using backend field names", async () => {
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ export type AgentSessionStreamDataEventType =
|
|||||||
| "todo_update"
|
| "todo_update"
|
||||||
| "permission_request"
|
| "permission_request"
|
||||||
| "permission_response"
|
| "permission_response"
|
||||||
|
| "run_status"
|
||||||
| "interaction_request"
|
| "interaction_request"
|
||||||
| "question_request"
|
| "question_request"
|
||||||
| "question_response"
|
| "question_response"
|
||||||
@@ -131,6 +132,7 @@ export type AgentSessionStreamDataEventType =
|
|||||||
| "ui_envelope"
|
| "ui_envelope"
|
||||||
| "session_title"
|
| "session_title"
|
||||||
| "done"
|
| "done"
|
||||||
|
| "aborted"
|
||||||
| "error"
|
| "error"
|
||||||
| "auth_required"
|
| "auth_required"
|
||||||
| "credential_refresh_required"
|
| "credential_refresh_required"
|
||||||
@@ -218,24 +220,16 @@ export type AgentApiClientOptions = {
|
|||||||
getProjectId?: () => Promise<string | undefined>;
|
getProjectId?: () => Promise<string | undefined>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_URL.replace(/\/$/, "")];
|
|
||||||
|
|
||||||
const API_PATH = "/api/v1";
|
const API_PATH = "/api/v1";
|
||||||
|
|
||||||
export function createAgentApiClient(
|
export function createAgentApiClient(
|
||||||
baseUrls: string | string[] = AGENT_API_BASE_URLS,
|
baseUrl: string = env.TJWATER_AGENT_URL,
|
||||||
options: AgentApiClientOptions = {}
|
options: AgentApiClientOptions = {}
|
||||||
): AgentApiClient {
|
): AgentApiClient {
|
||||||
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
|
const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
|
||||||
let activeBaseUrl = candidates[0] ?? "";
|
|
||||||
const setActiveBaseUrl = (baseUrl: string) => {
|
|
||||||
activeBaseUrl = baseUrl;
|
|
||||||
};
|
|
||||||
const request = (path: string, init?: RequestInit) =>
|
const request = (path: string, init?: RequestInit) =>
|
||||||
fetchWithFallback(
|
fetchAgent(
|
||||||
candidates,
|
normalizedBaseUrl,
|
||||||
activeBaseUrl,
|
|
||||||
setActiveBaseUrl,
|
|
||||||
path,
|
path,
|
||||||
init,
|
init,
|
||||||
options.getAccessToken,
|
options.getAccessToken,
|
||||||
@@ -263,7 +257,7 @@ export function createAgentApiClient(
|
|||||||
return token;
|
return token;
|
||||||
} : undefined;
|
} : undefined;
|
||||||
const answerStateRequest = async (path: string, init?: RequestInit) => {
|
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);
|
checkedAnswerStateToken, options.getProjectId);
|
||||||
// An identity change while awaiting the network also invalidates the result.
|
// An identity change while awaiting the network also invalidates the result.
|
||||||
await checkedAnswerStateToken?.();
|
await checkedAnswerStateToken?.();
|
||||||
@@ -273,7 +267,7 @@ export function createAgentApiClient(
|
|||||||
return {
|
return {
|
||||||
...createInteractionRequestApi(answerStateRequest),
|
...createInteractionRequestApi(answerStateRequest),
|
||||||
async getHealth(signal) {
|
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 服务暂不可用");
|
if (!response.ok) throw new Error("Agent 服务暂不可用");
|
||||||
const data: unknown = await response.json();
|
const data: unknown = await response.json();
|
||||||
if (!isRecord(data) || data.ok !== true || data.ready !== true) throw new Error("Agent 服务尚未就绪");
|
if (!isRecord(data) || data.ok !== true || data.ready !== true) throw new Error("Agent 服务尚未就绪");
|
||||||
@@ -577,19 +571,14 @@ export function createAgentApiClient(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchWithFallback(
|
async function fetchAgent(
|
||||||
baseUrls: string[],
|
baseUrl: string,
|
||||||
activeBaseUrl: string,
|
|
||||||
setActiveBaseUrl: (baseUrl: string) => void,
|
|
||||||
path: string,
|
path: string,
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
getAccessToken?: AccessTokenProvider,
|
getAccessToken?: AccessTokenProvider,
|
||||||
getProjectId?: () => Promise<string | undefined>,
|
getProjectId?: () => Promise<string | undefined>,
|
||||||
prefix = API_PATH
|
prefix = API_PATH
|
||||||
) {
|
) {
|
||||||
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
|
|
||||||
let lastError: unknown;
|
|
||||||
let lastResponse: Response | null = null;
|
|
||||||
const accessToken = await getAccessToken?.();
|
const accessToken = await getAccessToken?.();
|
||||||
const projectId = await getProjectId?.();
|
const projectId = await getProjectId?.();
|
||||||
const hasContextHeaders = Boolean(accessToken || projectId);
|
const hasContextHeaders = Boolean(accessToken || projectId);
|
||||||
@@ -598,34 +587,7 @@ async function fetchWithFallback(
|
|||||||
if (projectId) headers?.set("X-Project-Id", projectId);
|
if (projectId) headers?.set("X-Project-Id", projectId);
|
||||||
const requestInit = hasContextHeaders ? { ...init, headers } : init;
|
const requestInit = hasContextHeaders ? { ...init, headers } : init;
|
||||||
|
|
||||||
for (const baseUrl of orderedBaseUrls) {
|
return fetch(`${baseUrl}${prefix}${path}`, requestInit);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
@@ -837,6 +799,7 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
|
|||||||
value === "execution_plan" ||
|
value === "execution_plan" ||
|
||||||
value === "permission_request" ||
|
value === "permission_request" ||
|
||||||
value === "permission_response" ||
|
value === "permission_response" ||
|
||||||
|
value === "run_status" ||
|
||||||
value === "interaction_request" ||
|
value === "interaction_request" ||
|
||||||
value === "question_request" ||
|
value === "question_request" ||
|
||||||
value === "question_response" ||
|
value === "question_response" ||
|
||||||
@@ -847,6 +810,7 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
|
|||||||
value === "ui_envelope" ||
|
value === "ui_envelope" ||
|
||||||
value === "session_title" ||
|
value === "session_title" ||
|
||||||
value === "done" ||
|
value === "done" ||
|
||||||
|
value === "aborted" ||
|
||||||
value === "error" ||
|
value === "error" ||
|
||||||
value === "credential_refresh_required" ||
|
value === "credential_refresh_required" ||
|
||||||
value === "credential_refreshed" ||
|
value === "credential_refreshed" ||
|
||||||
|
|||||||
@@ -1066,6 +1066,11 @@ function BackendMessage({
|
|||||||
<AgentUserMessageText content={message.content} />
|
<AgentUserMessageText content={message.content} />
|
||||||
)
|
)
|
||||||
) : null}
|
) : 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 ? (
|
{message.role === "assistant" && processComplete && hasProcessError ? (
|
||||||
<p role="status" className="text-xs text-[var(--status-warning-foreground)]">
|
<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 { StatusIcon, type StatusTone } from "@/shared/ui/status";
|
||||||
import type { AgentChatProgress } from "../types";
|
import type { AgentChatProgress } from "../types";
|
||||||
|
|
||||||
const states = {
|
const states = {
|
||||||
completed: { label: "已完成", tone: "success", Icon: CheckCircle2 },
|
completed: { label: "已完成", tone: "success", Icon: CheckCircle2 },
|
||||||
|
cancelled: { label: "已中止", tone: "neutral", Icon: Ban },
|
||||||
error: { label: "失败", tone: "danger", Icon: XCircle },
|
error: { label: "失败", tone: "danger", Icon: XCircle },
|
||||||
running: { label: "进行中", tone: "info", Icon: LoaderCircle }
|
running: { label: "进行中", tone: "info", Icon: LoaderCircle }
|
||||||
} satisfies Record<
|
} satisfies Record<
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { AgentApiClient } from "../api/client";
|
|||||||
|
|
||||||
export function useAgentMessageInbox(client: AgentApiClient, sessionId: string | null) {
|
export function useAgentMessageInbox(client: AgentApiClient, sessionId: string | null) {
|
||||||
const inbox = useSWR(sessionId ? ["agent-message-inbox", sessionId] : 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 [loadingMore, setLoadingMore] = useState(false);
|
||||||
const mutate = inbox.mutate;
|
const mutate = inbox.mutate;
|
||||||
const refresh = useCallback(() => { void mutate().catch(() => undefined); }, [mutate]);
|
const refresh = useCallback(() => { void mutate().catch(() => undefined); }, [mutate]);
|
||||||
@@ -15,20 +15,27 @@ export function useAgentMessageInbox(client: AgentApiClient, sessionId: string |
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let retryDelay = 1500;
|
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() {
|
function subscribe() {
|
||||||
void client.streamAgentMessages(sessionId!, { signal: controller.signal, onMessage: () => { retryDelay = 1500; refreshRef.current(); } })
|
void client.streamAgentMessages(sessionId!, { signal: controller.signal, onMessage: () => { initialized = true; clearTimeout(initialLoadTimer); retryDelay = 1500; refreshRef.current(); } })
|
||||||
.catch(() => { retryDelay = Math.min(retryDelay * 2, 30_000); })
|
.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(() => {
|
.finally(() => {
|
||||||
if (!controller.signal.aborted) timer = setTimeout(subscribe, retryDelay);
|
if (!controller.signal.aborted) timer = setTimeout(subscribe, retryDelay);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
subscribe();
|
subscribe();
|
||||||
return () => { controller.abort(); clearTimeout(timer); };
|
return () => { controller.abort(); clearTimeout(timer); clearTimeout(initialLoadTimer); };
|
||||||
}, [client, sessionId]);
|
}, [client, sessionId]);
|
||||||
return {
|
return {
|
||||||
messages: inbox.data?.items ?? [],
|
messages: inbox.data?.items ?? [],
|
||||||
hasMore: Boolean(inbox.data?.next_cursor),
|
hasMore: Boolean(inbox.data?.next_cursor),
|
||||||
loading: inbox.isLoading,
|
loading: Boolean(sessionId) && (inbox.isLoading || (!inbox.data && !inbox.error)),
|
||||||
error: inbox.error ? "任务消息暂时无法加载" : null,
|
error: inbox.error ? "任务消息暂时无法加载" : null,
|
||||||
refresh,
|
refresh,
|
||||||
loadingMore,
|
loadingMore,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export {
|
|||||||
cancelRunningTodos,
|
cancelRunningTodos,
|
||||||
completeRunningProgress,
|
completeRunningProgress,
|
||||||
finalizeAssistantMessageAfterAbort,
|
finalizeAssistantMessageAfterAbort,
|
||||||
|
finalizeAssistantMessageAfterError,
|
||||||
toTodoUpdate,
|
toTodoUpdate,
|
||||||
upsertPermission,
|
upsertPermission,
|
||||||
upsertProgress,
|
upsertProgress,
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ describe("agent session state", () => {
|
|||||||
requestId: "permission-1",
|
requestId: "permission-1",
|
||||||
status: "approved_once"
|
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", () => {
|
it("dedupes question requests by tool call id", () => {
|
||||||
@@ -152,7 +154,7 @@ describe("agent session state", () => {
|
|||||||
const next = finalizeAssistantMessageAfterAbort(message);
|
const next = finalizeAssistantMessageAfterAbort(message);
|
||||||
|
|
||||||
expect(next.content).toBe("请求已中止。");
|
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");
|
expect(next.permissions?.[0].status).toBe("aborted");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ export function upsertProgress(progress: AgentChatProgress[] | undefined, data:
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function completeRunningProgress(progress: AgentChatProgress[] | undefined) {
|
export function completeRunningProgress(
|
||||||
|
progress: AgentChatProgress[] | undefined,
|
||||||
|
status: "completed" | "error" | "cancelled" = "completed"
|
||||||
|
) {
|
||||||
return progress?.map((item) => {
|
return progress?.map((item) => {
|
||||||
if (item.status !== "running") {
|
if (item.status !== "running") {
|
||||||
return item;
|
return item;
|
||||||
@@ -56,7 +59,7 @@ export function completeRunningProgress(progress: AgentChatProgress[] | undefine
|
|||||||
const endedAt = Date.now();
|
const endedAt = Date.now();
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
status: "completed" as const,
|
status,
|
||||||
endedAt,
|
endedAt,
|
||||||
elapsedMs: undefined,
|
elapsedMs: undefined,
|
||||||
elapsedSnapshotAt: undefined,
|
elapsedSnapshotAt: undefined,
|
||||||
@@ -154,9 +157,9 @@ export function applyPermissionResponse(
|
|||||||
permission.requestId === requestId
|
permission.requestId === requestId
|
||||||
? {
|
? {
|
||||||
...permission,
|
...permission,
|
||||||
status: toPermissionStatus(readString(payload.reply)),
|
status: payload.status === "error" ? "error" as const : toPermissionStatus(readString(payload.reply)),
|
||||||
repliedAt: Date.now(),
|
repliedAt: Date.now(),
|
||||||
error: undefined
|
error: payload.status === "error" ? readString(payload.error) : undefined
|
||||||
}
|
}
|
||||||
: permission
|
: permission
|
||||||
);
|
);
|
||||||
@@ -235,7 +238,7 @@ export function applyQuestionResponse(questions: AgentQuestionRequest[] | undefi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function finalizeAssistantMessageAfterAbort(message: AgentChatMessage): AgentChatMessage {
|
export function finalizeAssistantMessageAfterAbort(message: AgentChatMessage): AgentChatMessage {
|
||||||
const progress = completeRunningProgress(message.progress);
|
const progress = completeRunningProgress(message.progress, "cancelled");
|
||||||
const todos = cancelRunningTodos(message.todos);
|
const todos = cancelRunningTodos(message.todos);
|
||||||
|
|
||||||
return {
|
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 {
|
function toPermissionStatus(reply: string | undefined): AgentPermissionStatus {
|
||||||
if (reply === "always") {
|
if (reply === "always") {
|
||||||
return "approved_always";
|
return "approved_always";
|
||||||
@@ -340,7 +355,7 @@ function toToolRef(value: unknown): AgentInteractionToolRef | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readProgressStatus(value: unknown): AgentChatProgress["status"] {
|
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 {
|
function readTodoStatus(value: unknown): AgentTodoItem["status"] | undefined {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type AgentChatMessage = {
|
|||||||
id: string;
|
id: string;
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
|
runError?: string;
|
||||||
answerParts?: import("./answer-ui/contract").AnswerUiPart[];
|
answerParts?: import("./answer-ui/contract").AnswerUiPart[];
|
||||||
finalAnswer?: { content: string; uiBlockIds: string[] };
|
finalAnswer?: { content: string; uiBlockIds: string[] };
|
||||||
progress?: AgentChatProgress[];
|
progress?: AgentChatProgress[];
|
||||||
@@ -24,7 +25,7 @@ export type AgentChatProgress = {
|
|||||||
tool?: { name: string; callId?: string; input?: Record<string, unknown> };
|
tool?: { name: string; callId?: string; input?: Record<string, unknown> };
|
||||||
id: string;
|
id: string;
|
||||||
phase: string;
|
phase: string;
|
||||||
status: "running" | "completed" | "error";
|
status: "running" | "completed" | "error" | "cancelled";
|
||||||
title: string;
|
title: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
startedAt?: number;
|
startedAt?: number;
|
||||||
|
|||||||
@@ -46,17 +46,10 @@ type LayerVisibilitySectionProps = {
|
|||||||
|
|
||||||
function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySectionProps) {
|
function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySectionProps) {
|
||||||
const groups = groupLayerItems(items);
|
const groups = groupLayerItems(items);
|
||||||
const [openGroupIds, setOpenGroupIds] = useState(
|
const [groupOpenStates, setGroupOpenStates] = useState<Record<string, boolean>>({});
|
||||||
() => new Set(groups.filter((group) => group.defaultOpen).map((group) => group.id))
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleGroupToggle = (groupId: string, open: boolean) => {
|
const handleGroupToggle = (groupId: string, open: boolean) => {
|
||||||
setOpenGroupIds((current) => {
|
setGroupOpenStates((current) => ({ ...current, [groupId]: open }));
|
||||||
const next = new Set(current);
|
|
||||||
if (open) next.add(groupId);
|
|
||||||
else next.delete(groupId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -64,7 +57,7 @@ function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySection
|
|||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<details
|
<details
|
||||||
key={group.id}
|
key={group.id}
|
||||||
open={openGroupIds.has(group.id)}
|
open={groupOpenStates[group.id] ?? group.defaultOpen}
|
||||||
onToggle={(event) => handleGroupToggle(group.id, event.currentTarget.open)}
|
onToggle={(event) => handleGroupToggle(group.id, event.currentTarget.open)}
|
||||||
className="surface-well group/layer overflow-hidden rounded-xl border"
|
className="surface-well group/layer overflow-hidden rounded-xl border"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { getWorkbenchAccessToken } from "./request-context";
|
import { requestWorkbenchJson } from "./request-json";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { WorkbenchAccess } from "./request-context";
|
import type { WorkbenchAccess } from "./request-context";
|
||||||
import { env } from "@/shared/config/env";
|
|
||||||
import type { WaterNetworkSourceId } from "../map/sources";
|
import type { WaterNetworkSourceId } from "../map/sources";
|
||||||
import {
|
|
||||||
createWorkbenchHeaders,
|
|
||||||
findOwnedWorkbenchProject,
|
|
||||||
getServerError
|
|
||||||
} from "./project-client";
|
|
||||||
|
|
||||||
const gisAttributeGroupSchema = z.object({
|
const gisAttributeGroupSchema = z.object({
|
||||||
dataset_key: z.string().min(1),
|
dataset_key: z.string().min(1),
|
||||||
@@ -43,7 +37,6 @@ const sourceGisFeaturePropertiesSchema = z.object({
|
|||||||
|
|
||||||
export type GisAttributeGroup = z.infer<typeof gisAttributeGroupSchema>;
|
export type GisAttributeGroup = z.infer<typeof gisAttributeGroupSchema>;
|
||||||
|
|
||||||
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
|
|
||||||
|
|
||||||
export async function fetchElementGisProperties(
|
export async function fetchElementGisProperties(
|
||||||
elementType: WaterNetworkSourceId,
|
elementType: WaterNetworkSourceId,
|
||||||
@@ -52,26 +45,15 @@ export async function fetchElementGisProperties(
|
|||||||
getAccessToken?: WorkbenchAccess,
|
getAccessToken?: WorkbenchAccess,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<GisAttributeGroup[]> {
|
): 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 = [
|
const path = [
|
||||||
"/api/v1/elements",
|
"/api/v1/elements",
|
||||||
encodeURIComponent(elementType),
|
encodeURIComponent(elementType),
|
||||||
encodeURIComponent(elementId),
|
encodeURIComponent(elementId),
|
||||||
"gis-properties"
|
"gis-properties"
|
||||||
].join("/");
|
].join("/");
|
||||||
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, {
|
const payload = await requestWorkbenchJson(path, "GIS 台账属性查询", {
|
||||||
headers: createWorkbenchHeaders(accessToken, resolvedProjectId),
|
access: getAccessToken, projectId, init: { signal }
|
||||||
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);
|
const result = elementGisPropertiesSchema.parse(payload);
|
||||||
if (result.element_type !== elementType || result.element_id !== elementId) {
|
if (result.element_type !== elementType || result.element_id !== elementId) {
|
||||||
throw new Error("GIS 台账属性与所选元素不一致");
|
throw new Error("GIS 台账属性与所选元素不一致");
|
||||||
@@ -86,12 +68,6 @@ export async function fetchSourceGisFeatureProperties(
|
|||||||
getAccessToken?: WorkbenchAccess,
|
getAccessToken?: WorkbenchAccess,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<GisAttributeGroup[]> {
|
): 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 = [
|
const path = [
|
||||||
"/api/v1/source-gis/layers",
|
"/api/v1/source-gis/layers",
|
||||||
encodeURIComponent(layerKey),
|
encodeURIComponent(layerKey),
|
||||||
@@ -99,14 +75,9 @@ export async function fetchSourceGisFeatureProperties(
|
|||||||
encodeURIComponent(String(sourceFid)),
|
encodeURIComponent(String(sourceFid)),
|
||||||
"properties"
|
"properties"
|
||||||
].join("/");
|
].join("/");
|
||||||
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, {
|
const payload = await requestWorkbenchJson(path, "源 GIS 属性查询", {
|
||||||
headers: createWorkbenchHeaders(accessToken, resolvedProjectId),
|
access: getAccessToken, projectId, init: { signal }
|
||||||
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);
|
const result = sourceGisFeaturePropertiesSchema.parse(payload);
|
||||||
if (result.layer_key !== layerKey || result.source_fid !== sourceFid) {
|
if (result.layer_key !== layerKey || result.source_fid !== sourceFid) {
|
||||||
throw new Error("源 GIS 属性与所选要素不一致");
|
throw new Error("源 GIS 属性与所选要素不一致");
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
|
import { requestWorkbenchJson } from "./request-json";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { investigationReportSchema, investigationDiagramSchema } from "./investigation-report";
|
import { investigationReportSchema, investigationDiagramSchema } from "./investigation-report";
|
||||||
import { investigationPresentationCursorSchema, investigationPresentationHistoryPageSchema, type InvestigationPresentationHistoryEvent } from "./investigation-presentation";
|
import { investigationPresentationCursorSchema, investigationPresentationHistoryPageSchema, type InvestigationPresentationHistoryEvent } from "./investigation-presentation";
|
||||||
import type { WorkbenchAccess } from "./request-context";
|
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 taskStatusSchema = z.enum(["queued", "running", "waiting_human", "completed", "failed", "cancelled"]);
|
||||||
const taskPrioritySchema = z.enum(["normal", "urgent"]);
|
const taskPrioritySchema = z.enum(["normal", "urgent"]);
|
||||||
@@ -432,16 +431,9 @@ export type CreateOperationalTaskInput = {
|
|||||||
agent_session_id?: string;
|
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) {
|
async function operationalTaskRequest(path: string, getAccessToken?: WorkbenchAccess, init?: RequestInit) {
|
||||||
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, init?.signal ?? undefined);
|
return requestWorkbenchJson(`/api/v1${path}`, "工况任务请求", { access: getAccessToken, init });
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchOperationalTask(taskId: string, getAccessToken?: WorkbenchAccess, signal?: AbortSignal) {
|
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 { z } from "zod";
|
||||||
import type { WorkbenchAccess } from "./request-context";
|
import type { WorkbenchAccess } from "./request-context";
|
||||||
import { env } from "@/shared/config/env";
|
import { type PlaceSearchConfiguration } from "./project-client";
|
||||||
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError, type PlaceSearchConfiguration } from "./project-client";
|
|
||||||
|
|
||||||
export const MAX_PLACE_SEARCH_RESULTS = 10;
|
export const MAX_PLACE_SEARCH_RESULTS = 10;
|
||||||
|
|
||||||
@@ -71,21 +70,8 @@ async function requestPlaceData(
|
|||||||
getAccessToken?: WorkbenchAccess,
|
getAccessToken?: WorkbenchAccess,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const requestSignal = signal
|
return requestWorkbenchJson(`/api/v1/${path}`, "地名搜索", {
|
||||||
? AbortSignal.any([signal, AbortSignal.timeout(20_000)])
|
access: getAccessToken, projectId, timeoutMs: 20_000,
|
||||||
: AbortSignal.timeout(20_000);
|
init: { method: "POST", body: JSON.stringify(body), signal }
|
||||||
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
|
|
||||||
});
|
});
|
||||||
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,
|
baseUrl: string,
|
||||||
operation: string,
|
operation: string,
|
||||||
request: (signal: AbortSignal) => Promise<T>,
|
request: (signal: AbortSignal) => Promise<T>,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
timeoutMs: number | null = PROJECT_REQUEST_TIMEOUT_MS
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const deadline = AbortSignal.timeout(PROJECT_REQUEST_TIMEOUT_MS);
|
const deadline = timeoutMs == null ? undefined : AbortSignal.timeout(timeoutMs);
|
||||||
const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
|
const combined = signal && deadline ? AbortSignal.any([signal, deadline]) : signal ?? deadline ?? new AbortController().signal;
|
||||||
try {
|
try {
|
||||||
combined.throwIfAborted();
|
combined.throwIfAborted();
|
||||||
return await request(combined);
|
return await request(combined);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (signal?.aborted && !isTimeout(signal.reason)) throw signal.reason;
|
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);
|
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")) {
|
if (error instanceof TypeError || (error instanceof DOMException && error.name === "NetworkError")) {
|
||||||
throw new Error(`${operation}失败:无法连接后端服务。请检查网络和后端服务状态后重试。`, { cause: error });
|
throw new Error(`${operation}失败:无法连接后端服务。请检查网络和后端服务状态后重试。`, { cause: error });
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
|
import { requestWorkbenchJson } from "./request-json";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { WorkbenchAccess } from "./request-context";
|
import type { WorkbenchAccess } from "./request-context";
|
||||||
import { env } from "@/shared/config/env";
|
|
||||||
import type { PipeFlowCalculation } from "../map/flow-overlay";
|
import type { PipeFlowCalculation } from "../map/flow-overlay";
|
||||||
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
|
|
||||||
|
|
||||||
const realtimeLinkSchema = z.object({
|
const realtimeLinkSchema = z.object({
|
||||||
time: z.string().datetime({ offset: true }),
|
time: z.string().datetime({ offset: true }),
|
||||||
@@ -11,7 +10,6 @@ const realtimeLinkSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const realtimeLinksSchema = z.array(realtimeLinkSchema);
|
const realtimeLinksSchema = z.array(realtimeLinkSchema);
|
||||||
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
|
|
||||||
const FLOW_LOOKBACK_MS = 15 * 60 * 1000;
|
const FLOW_LOOKBACK_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
export type RealtimePipeFlowSnapshot = {
|
export type RealtimePipeFlowSnapshot = {
|
||||||
@@ -24,22 +22,13 @@ export async function fetchRealtimePipeFlows(
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
now = new Date()
|
now = new Date()
|
||||||
): Promise<RealtimePipeFlowSnapshot> {
|
): Promise<RealtimePipeFlowSnapshot> {
|
||||||
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
|
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
start_time: new Date(now.getTime() - FLOW_LOOKBACK_MS).toISOString(),
|
start_time: new Date(now.getTime() - FLOW_LOOKBACK_MS).toISOString(),
|
||||||
end_time: now.toISOString()
|
end_time: now.toISOString()
|
||||||
});
|
});
|
||||||
const response = await fetch(
|
const payload = await requestWorkbenchJson(`/api/v1/timeseries/realtime/links?${query}`, "实时管道流速请求", {
|
||||||
`${SERVER_API_BASE_URL}/api/v1/timeseries/realtime/links?${query.toString()}`,
|
access: getAccessToken, init: { signal }
|
||||||
{
|
});
|
||||||
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 rows = realtimeLinksSchema.parse(payload);
|
const rows = realtimeLinksSchema.parse(payload);
|
||||||
const latestByPipe = new Map<string, z.infer<typeof realtimeLinkSchema>>();
|
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 type { Feature, FeatureCollection, Point } from "geojson";
|
||||||
import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context";
|
import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context";
|
||||||
import { env } from "@/shared/config/env";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
|
import { findOwnedWorkbenchProject } from "./project-client";
|
||||||
|
|
||||||
const scadaDeviceSchema = z.object({
|
const scadaDeviceSchema = z.object({
|
||||||
device_id: z.string().trim().min(1),
|
device_id: z.string().trim().min(1),
|
||||||
@@ -68,7 +68,6 @@ export type ScadaSnapshot = {
|
|||||||
telemetryStatus: "ready" | "empty" | "unavailable";
|
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_LOOKBACK_MS = 15 * 60 * 1000;
|
||||||
const SCADA_READING_BATCH_SIZE = 100;
|
const SCADA_READING_BATCH_SIZE = 100;
|
||||||
|
|
||||||
@@ -244,11 +243,5 @@ async function requestJson(
|
|||||||
projectId: string | undefined,
|
projectId: string | undefined,
|
||||||
signal: AbortSignal | undefined
|
signal: AbortSignal | undefined
|
||||||
) {
|
) {
|
||||||
const headers = createWorkbenchHeaders(accessToken, projectId);
|
return requestWorkbenchJson(path, "SCADA API请求", { credentials: { accessToken, projectId }, init: { signal } });
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { requestWorkbenchJson } from "./request-json";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { env } from "@/shared/config/env";
|
import { findOwnedWorkbenchProject } from "./project-client";
|
||||||
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
|
|
||||||
import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context";
|
import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context";
|
||||||
|
|
||||||
const ruleSchema = z.object({
|
const ruleSchema = z.object({
|
||||||
@@ -128,14 +128,10 @@ export class ScadaReplayClient {
|
|||||||
this.projectId ??= findOwnedWorkbenchProject(this.access).then(({ project }) => project.project_id)
|
this.projectId ??= findOwnedWorkbenchProject(this.access).then(({ project }) => project.project_id)
|
||||||
.catch((error) => { this.projectId = null; throw error; });
|
.catch((error) => { this.projectId = null; throw error; });
|
||||||
const [projectId, token] = await Promise.all([this.projectId, getWorkbenchAccessToken(this.access)]);
|
const [projectId, token] = await Promise.all([this.projectId, getWorkbenchAccessToken(this.access)]);
|
||||||
const headers = createWorkbenchHeaders(token, projectId);
|
return requestWorkbenchJson(`/api/v1/scada-replays${path}`, "SCADA 回放", {
|
||||||
if (body !== undefined) headers.set("Content-Type", "application/json");
|
credentials: { projectId, accessToken: token },
|
||||||
const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/scada-replays${path}`, {
|
init: { method: body === undefined ? "GET" : "POST", signal,
|
||||||
method: body === undefined ? "GET" : "POST", headers, signal,
|
...(body === undefined ? {} : { body: JSON.stringify(body) }) }
|
||||||
...(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 { z } from "zod";
|
||||||
import type { WorkbenchAccess } from "./request-context";
|
import type { WorkbenchAccess } from "./request-context";
|
||||||
import { env } from "@/shared/config/env";
|
|
||||||
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
|
|
||||||
|
|
||||||
const analysisHistoryItemSchema = z.object({
|
const analysisHistoryItemSchema = z.object({
|
||||||
run_id: z.string().trim().min(1),
|
run_id: z.string().trim().min(1),
|
||||||
@@ -41,7 +40,6 @@ export type WorkbenchAnalysisHistoryPage = {
|
|||||||
nextOffset: number | null;
|
nextOffset: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
|
|
||||||
|
|
||||||
export async function fetchWorkbenchAnalysisHistory(
|
export async function fetchWorkbenchAnalysisHistory(
|
||||||
options: {
|
options: {
|
||||||
@@ -59,25 +57,13 @@ export async function fetchWorkbenchAnalysisHistory(
|
|||||||
if (!Number.isInteger(offset) || offset < 0) {
|
if (!Number.isInteger(offset) || offset < 0) {
|
||||||
throw new Error("分析成果历史偏移量无效");
|
throw new Error("分析成果历史偏移量无效");
|
||||||
}
|
}
|
||||||
const { project, accessToken } = await findOwnedWorkbenchProject(
|
|
||||||
options.getAccessToken,
|
|
||||||
options.signal
|
|
||||||
);
|
|
||||||
const search = new URLSearchParams({
|
const search = new URLSearchParams({
|
||||||
limit: String(limit),
|
limit: String(limit),
|
||||||
offset: String(offset)
|
offset: String(offset)
|
||||||
});
|
});
|
||||||
const response = await fetch(
|
const payload = await requestWorkbenchJson(`/api/v1/workbench/analysis-runs?${search}`, "分析成果历史请求", {
|
||||||
`${SERVER_API_BASE_URL}/api/v1/workbench/analysis-runs?${search}`,
|
access: options.getAccessToken, init: { signal: options.signal }
|
||||||
{
|
});
|
||||||
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 page = analysisHistoryPageSchema.parse(payload);
|
const page = analysisHistoryPageSchema.parse(payload);
|
||||||
return {
|
return {
|
||||||
items: page.items.map((item) => ({
|
items: page.items.map((item) => ({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { requestWorkbenchJson } from "./request-json";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { FeatureCollection, Geometry, Position } from "geojson";
|
import type { FeatureCollection, Geometry, Position } from "geojson";
|
||||||
import type { WorkbenchAccess } from "./request-context";
|
import type { WorkbenchAccess } from "./request-context";
|
||||||
import { env } from "@/shared/config/env";
|
|
||||||
import { formatDateTime } from "@/shared/utils/date-time";
|
import { formatDateTime } from "@/shared/utils/date-time";
|
||||||
import type {
|
import type {
|
||||||
ScheduledConditionRecord,
|
ScheduledConditionRecord,
|
||||||
@@ -10,7 +10,6 @@ import type {
|
|||||||
WorkbenchScenario
|
WorkbenchScenario
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import type { AnalysisArtifact, ArtifactLifecycle, ArtifactType } from "../workspace/workspace-model";
|
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;
|
// Keep legacy analysis values parseable while old servers are rolling forward;
|
||||||
// they are filtered before the operational workbench model is created.
|
// they are filtered before the operational workbench model is created.
|
||||||
@@ -159,7 +158,6 @@ export type WorkbenchAnalysisProjection = {
|
|||||||
simulationOverlay: FeatureCollection<Geometry> | null;
|
simulationOverlay: FeatureCollection<Geometry> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
|
|
||||||
|
|
||||||
export function parseWorkbenchRuntimeSnapshot(payload: unknown): WorkbenchRuntimeSnapshot {
|
export function parseWorkbenchRuntimeSnapshot(payload: unknown): WorkbenchRuntimeSnapshot {
|
||||||
const snapshot = runtimeSnapshotSchema.parse(payload);
|
const snapshot = runtimeSnapshotSchema.parse(payload);
|
||||||
@@ -264,18 +262,9 @@ export async function fetchWorkbenchAnalysisRun(
|
|||||||
): Promise<WorkbenchAnalysisProjection> {
|
): Promise<WorkbenchAnalysisProjection> {
|
||||||
const normalizedRunId = runId.trim();
|
const normalizedRunId = runId.trim();
|
||||||
if (!normalizedRunId) throw new Error("分析运行 ID 不能为空");
|
if (!normalizedRunId) throw new Error("分析运行 ID 不能为空");
|
||||||
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
|
const payload = await requestWorkbenchJson(`/api/v1/workbench/analysis-runs/${encodeURIComponent(normalizedRunId)}`, "分析运行成果请求", {
|
||||||
const response = await fetch(
|
access: getAccessToken, init: { signal }
|
||||||
`${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 analysis = runtimeLatestAnalysisSchema.parse(payload);
|
const analysis = runtimeLatestAnalysisSchema.parse(payload);
|
||||||
if (analysis.run_id !== normalizedRunId) {
|
if (analysis.run_id !== normalizedRunId) {
|
||||||
throw new Error("分析运行成果与请求 ID 不一致");
|
throw new Error("分析运行成果与请求 ID 不一致");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { requestWorkbenchJson } from "./request-json";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { WorkbenchAccess } from "./request-context";
|
import type { WorkbenchAccess } from "./request-context";
|
||||||
import { env } from "@/shared/config/env";
|
import { env } from "@/shared/config/env";
|
||||||
@@ -63,13 +64,9 @@ export async function fetchWorkbenchSyncSnapshot(
|
|||||||
getAccessToken?: WorkbenchAccess,
|
getAccessToken?: WorkbenchAccess,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<WorkbenchSyncSnapshot> {
|
): Promise<WorkbenchSyncSnapshot> {
|
||||||
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
|
const payload = await requestWorkbenchJson("/api/v1/workbench/sync-snapshot", "工作台同步快照请求", {
|
||||||
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/workbench/sync-snapshot`, {
|
access: getAccessToken, init: { signal }
|
||||||
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 envelope = snapshotEnvelopeSchema.parse(payload);
|
const envelope = snapshotEnvelopeSchema.parse(payload);
|
||||||
return {
|
return {
|
||||||
eventCursor: envelope.event_cursor,
|
eventCursor: envelope.event_cursor,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getReportPresentationState, snapshotRound } from "../utils/report-presentation-state";
|
||||||
import { InvestigationReportView } from "./investigation-report-view";
|
import { InvestigationReportView } from "./investigation-report-view";
|
||||||
import { formatDateTime as formatDisplayDateTime } from "@/shared/utils/date-time";
|
import { formatDateTime as formatDisplayDateTime } from "@/shared/utils/date-time";
|
||||||
import { InvestigationTaskReport } from "./investigation-task-report";
|
import { InvestigationTaskReport } from "./investigation-task-report";
|
||||||
@@ -110,18 +111,8 @@ export function AgentExecutionPlanPanel({
|
|||||||
/>;
|
/>;
|
||||||
const plan = task.latest_plan?.plan;
|
const plan = task.latest_plan?.plan;
|
||||||
const report = task.latest_report?.report;
|
const report = task.latest_report?.report;
|
||||||
const planGeneration = task.latest_plan?.execution_generation;
|
const planState = snapshotRound(task.latest_plan?.execution_generation, task.execution_generation);
|
||||||
const planState = planGeneration == null
|
const { round: reportState } = getReportPresentationState(task, task.latest_report);
|
||||||
? "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 visiblePlanNodeIds = plan && plan.schema === "execution-plan@2"
|
const visiblePlanNodeIds = plan && plan.schema === "execution-plan@2"
|
||||||
? activePlanGraph(plan).visible : null;
|
? activePlanGraph(plan).visible : null;
|
||||||
const visiblePlanNodes = plan && visiblePlanNodeIds
|
const visiblePlanNodes = plan && visiblePlanNodeIds
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getReportPresentationState } from "../utils/report-presentation-state";
|
||||||
import { FileText } from "lucide-react";
|
import { FileText } from "lucide-react";
|
||||||
import { StatusBadge } from "@/shared/ui/status";
|
import { StatusBadge } from "@/shared/ui/status";
|
||||||
import { formatDateTime } from "@/shared/utils/date-time";
|
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 }) {
|
export function InvestigationReportView({ task, reportLabel }: { task: OperationalTask; reportLabel: string }) {
|
||||||
const snapshot = task.latest_report;
|
const snapshot = task.latest_report;
|
||||||
const report = snapshot?.report;
|
const report = snapshot?.report;
|
||||||
const insufficient = report?.outcome === "insufficient_data";
|
const { insufficient, current, ended } = getReportPresentationState(task, snapshot);
|
||||||
const current = snapshot?.execution_generation === task.execution_generation;
|
|
||||||
const ended = current && task.lifecycle_status === "completed";
|
|
||||||
const insufficientLabel = ended ? "本轮已结束 · 无法形成结论"
|
const insufficientLabel = ended ? "本轮已结束 · 无法形成结论"
|
||||||
: task.lifecycle_status === "failed" ? "证据不足 · 本轮执行失败"
|
: task.lifecycle_status === "failed" ? "证据不足 · 本轮执行失败"
|
||||||
: task.lifecycle_status === "cancelled" ? "证据不足 · 本轮已取消"
|
: task.lifecycle_status === "cancelled" ? "证据不足 · 本轮已取消"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getReportPresentationState } from "../utils/report-presentation-state";
|
||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { InvestigationQuestions } from "./investigation-questions";
|
import { InvestigationQuestions } from "./investigation-questions";
|
||||||
import { History, LoaderCircle, MessageSquare } from "lucide-react";
|
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
|
// A current final report owns its archived graph. During investigation, a
|
||||||
// progress snapshot owns its whole graph, including an explicit null. A prior
|
// progress snapshot owns its whole graph, including an explicit null. A prior
|
||||||
// round's final report must never hide the new round's progress.
|
// round's final report must never hide the new round's progress.
|
||||||
const currentFinalReport = snapshot?.execution_generation === task.execution_generation
|
const { currentFinalReport, previousRound, olderRevision, insufficient } = getReportPresentationState(task, snapshot);
|
||||||
&& report?.stage === "final";
|
|
||||||
const diagram = live
|
const diagram = live
|
||||||
? progress && !currentFinalReport ? progress.progress.diagram : investigationDiagram(task, snapshot)
|
? progress && !currentFinalReport ? progress.progress.diagram : investigationDiagram(task, snapshot)
|
||||||
: selection.diagram;
|
: selection.diagram;
|
||||||
const diagramRunning = Boolean(live && !currentFinalReport && progress && task.active_run?.run_id === progress.run_id && task.active_run.status === "running");
|
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 latestAvailable = !live;
|
||||||
const insufficient = report?.outcome === "insufficient_data";
|
const { currentInsufficient } = getReportPresentationState(task, task.latest_report);
|
||||||
const currentInsufficient = task.latest_report?.execution_generation === task.execution_generation
|
|
||||||
&& task.latest_report?.report.outcome === "insufficient_data";
|
|
||||||
const pendingActions = task.actions.filter(isPendingInvestigationAction);
|
const pendingActions = task.actions.filter(isPendingInvestigationAction);
|
||||||
const completedActions = task.actions.filter((action) => !isPendingInvestigationAction(action));
|
const completedActions = task.actions.filter((action) => !isPendingInvestigationAction(action));
|
||||||
const evidence = task.evidence.filter((item) => report?.evidence_refs.includes(item.evidence_id));
|
const evidence = task.evidence.filter((item) => report?.evidence_refs.includes(item.evidence_id));
|
||||||
|
|||||||
@@ -138,8 +138,6 @@ function createCommands() {
|
|||||||
clearValueLabel: vi.fn(),
|
clearValueLabel: vi.fn(),
|
||||||
applyLayerGroupStyle: vi.fn(),
|
applyLayerGroupStyle: vi.fn(),
|
||||||
resetLayerGroupStyle: vi.fn(),
|
resetLayerGroupStyle: vi.fn(),
|
||||||
renderScadaAnalysis: vi.fn(),
|
|
||||||
clearScadaAnalysis: vi.fn(),
|
|
||||||
resetAll: vi.fn()
|
resetAll: vi.fn()
|
||||||
} satisfies WorkbenchMapCommands;
|
} satisfies WorkbenchMapCommands;
|
||||||
}
|
}
|
||||||
@@ -163,7 +161,6 @@ function createControllerState(
|
|||||||
invalid: 0
|
invalid: 0
|
||||||
},
|
},
|
||||||
styledGroups: [],
|
styledGroups: [],
|
||||||
scadaAnalysis: null,
|
|
||||||
...patch
|
...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("这段结构化内容无法显示。");
|
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 type { Dispatch, SetStateAction } from "react";
|
||||||
import {
|
import {
|
||||||
parseAnswerUiBlock,
|
parseAnswerUiBlock,
|
||||||
|
completeRunningProgress,
|
||||||
|
cancelRunningTodos,
|
||||||
|
finalizeAssistantMessageAfterAbort,
|
||||||
|
finalizeAssistantMessageAfterError,
|
||||||
readInteractionReply,
|
readInteractionReply,
|
||||||
getFinalAnswerTranscript,
|
getFinalAnswerTranscript,
|
||||||
type AnswerUiPart,
|
type AnswerUiPart,
|
||||||
@@ -17,7 +21,8 @@ import {
|
|||||||
type AgentPermissionStatus,
|
type AgentPermissionStatus,
|
||||||
type AgentQuestionRequest,
|
type AgentQuestionRequest,
|
||||||
type AgentSessionStreamEvent,
|
type AgentSessionStreamEvent,
|
||||||
type AgentStreamRenderState
|
type AgentStreamRenderState,
|
||||||
|
type AgentRunStatus
|
||||||
} from "@/features/agent";
|
} from "@/features/agent";
|
||||||
|
|
||||||
type AgentUiDataParts = {
|
type AgentUiDataParts = {
|
||||||
@@ -26,6 +31,7 @@ type AgentUiDataParts = {
|
|||||||
task_context: Record<string, unknown>;
|
task_context: Record<string, unknown>;
|
||||||
execution_plan: Record<string, unknown>;
|
execution_plan: Record<string, unknown>;
|
||||||
progress: Record<string, unknown>;
|
progress: Record<string, unknown>;
|
||||||
|
run_status: Record<string, unknown>;
|
||||||
final_answer: Record<string, unknown>;
|
final_answer: Record<string, unknown>;
|
||||||
credential_refresh_required: Record<string, unknown>;
|
credential_refresh_required: Record<string, unknown>;
|
||||||
credential_refreshed: Record<string, unknown>;
|
credential_refreshed: Record<string, unknown>;
|
||||||
@@ -137,11 +143,14 @@ export function toAgentChatMessages(
|
|||||||
...(answerParts ? { answerParts } : {})
|
...(answerParts ? { answerParts } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let runState: Record<string, unknown> | undefined;
|
||||||
for (const part of message.parts) {
|
for (const part of message.parts) {
|
||||||
if (!isAgentDataPart(part)) {
|
if (!isAgentDataPart(part)) {
|
||||||
continue;
|
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);
|
const reply = readInteractionReply(part.data, message.id);
|
||||||
if (reply) next = { ...next, interactionReply: reply };
|
if (reply) next = { ...next, interactionReply: reply };
|
||||||
} else if (part.type === "data-task_context") {
|
} else if (part.type === "data-task_context") {
|
||||||
@@ -193,23 +202,44 @@ export function toAgentChatMessages(
|
|||||||
if (next.finalAnswer) {
|
if (next.finalAnswer) {
|
||||||
next.content = getFinalAnswerTranscript(next);
|
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)];
|
return [applyQuestionOverrides(applyPermissionOverrides(next, permissionOverrides), questionOverrides)];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toAgentUiMessages(messages: unknown[]): AgentUiMessage[] {
|
export function toAgentUiMessages(
|
||||||
return messages.flatMap((message) => {
|
messages: unknown[],
|
||||||
|
session?: { runStatus?: AgentRunStatus; isStreaming?: boolean }
|
||||||
|
): AgentUiMessage[] {
|
||||||
|
const normalized = messages.flatMap((message) => {
|
||||||
if (isAgentUiMessage(message)) {
|
if (isAgentUiMessage(message)) {
|
||||||
return [message];
|
return [message];
|
||||||
}
|
}
|
||||||
const legacyMessage = toAgentUiMessageFromLegacy(message);
|
const legacyMessage = toAgentUiMessageFromLegacy(message);
|
||||||
return legacyMessage ? [legacyMessage] : [];
|
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[] {
|
export function applySessionStreamEvent(messages: AgentUiMessage[], event: AgentSessionStreamEvent): AgentUiMessage[] {
|
||||||
if (event.type === "state") {
|
if (event.type === "state") {
|
||||||
return toAgentUiMessages(event.messages);
|
return toAgentUiMessages(event.messages, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "user_input") {
|
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;
|
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 (
|
if (
|
||||||
|
event.type === "run_status" ||
|
||||||
event.type === "final_answer" ||
|
event.type === "final_answer" ||
|
||||||
event.type === "progress" ||
|
event.type === "progress" ||
|
||||||
event.type === "execution_plan" ||
|
event.type === "execution_plan" ||
|
||||||
event.type === "todo_update" ||
|
event.type === "todo_update" ||
|
||||||
event.type === "permission_request" ||
|
event.type === "permission_request" ||
|
||||||
event.type === "permission_response" ||
|
|
||||||
event.type === "question_request" ||
|
event.type === "question_request" ||
|
||||||
event.type === "question_response" ||
|
|
||||||
event.type === "answer_ui" ||
|
event.type === "answer_ui" ||
|
||||||
event.type === "ui_envelope" ||
|
event.type === "ui_envelope" ||
|
||||||
event.type === "session_title"
|
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;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,7 +520,7 @@ function applyPermissionOverrides(
|
|||||||
...message,
|
...message,
|
||||||
permissions: message.permissions.map((permission) => {
|
permissions: message.permissions.map((permission) => {
|
||||||
const override = permissionOverrides[permission.requestId];
|
const override = permissionOverrides[permission.requestId];
|
||||||
return override
|
return override && ["pending", "submitting", "error"].includes(permission.status)
|
||||||
? {
|
? {
|
||||||
...permission,
|
...permission,
|
||||||
status: override.status,
|
status: override.status,
|
||||||
@@ -502,7 +544,7 @@ function applyQuestionOverrides(
|
|||||||
...message,
|
...message,
|
||||||
questions: message.questions.map((question) => {
|
questions: message.questions.map((question) => {
|
||||||
const override = questionOverrides[question.requestId];
|
const override = questionOverrides[question.requestId];
|
||||||
return override
|
return override && ["checking", "pending", "submitting", "error"].includes(question.status)
|
||||||
? {
|
? {
|
||||||
...question,
|
...question,
|
||||||
status: override.status,
|
status: override.status,
|
||||||
|
|||||||
@@ -3,11 +3,42 @@ import type { ChatTransport, UIMessageChunk } from "ai";
|
|||||||
import { AgentSessionStreamError, AgentStreamDisconnectedError, type AgentApiClient } from "@/features/agent";
|
import { AgentSessionStreamError, AgentStreamDisconnectedError, type AgentApiClient } from "@/features/agent";
|
||||||
import { WorkbenchSessionResources } from "./workbench-session-resources";
|
import { WorkbenchSessionResources } from "./workbench-session-resources";
|
||||||
import { createAgentRuntime, mergeHistoryMessages, resumeAgentRuntime, runtimeRunning } from "./agent-session-runtime";
|
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 }] });
|
const message = (id: string, text = id): AgentUiMessage => ({ id, role: "assistant", parts: [{ type: "text", text }] });
|
||||||
|
|
||||||
describe("independent conversation runtimes", () => {
|
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 () => {
|
it("retries a disconnected subscription and preserves the draft, pages and reading position", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const resources = new WorkbenchSessionResources();
|
const resources = new WorkbenchSessionResources();
|
||||||
@@ -141,7 +172,9 @@ describe("independent conversation runtimes", () => {
|
|||||||
});
|
});
|
||||||
resumeAgentRuntime(resources, runtime);
|
resumeAgentRuntime(resources, runtime);
|
||||||
await vi.waitFor(() => expect(runtime.controller).toBeUndefined());
|
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({
|
expect(submit).toHaveBeenCalledWith("a", "action-a", expect.objectContaining({
|
||||||
status: "failed", error: expect.objectContaining({ code: "SESSION_NOT_ACTIVE" }),
|
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 active = () => resources.activeAgent === runtime;
|
||||||
const notify = () => queueMicrotask(() => resources.notify());
|
const notify = () => queueMicrotask(() => resources.notify());
|
||||||
runtime.chat = new Chat<AgentUiMessage>({
|
runtime.chat = new Chat<AgentUiMessage>({
|
||||||
id: runtime.key, transport, messages: toAgentUiMessages(loaded?.messages ?? []),
|
id: runtime.key, transport, messages: toAgentUiMessages(loaded?.messages ?? [], loaded),
|
||||||
onData: (part) => {
|
onData: (part) => {
|
||||||
if (resources.disposed) return;
|
if (resources.disposed) return;
|
||||||
const data = part as AgentDataPart;
|
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 !== runtime.sessionId) return;
|
||||||
if (sessionId) runtime.sessionId = sessionId;
|
if (sessionId) runtime.sessionId = sessionId;
|
||||||
if (data.type === "data-interaction_request") runtime.runStatus = "waiting_input";
|
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-session_title") runtime.title = getDataString(data.data, "title") ?? runtime.title;
|
||||||
if (data.type === "data-credential_refresh_required") {
|
if (data.type === "data-credential_refresh_required") {
|
||||||
void refreshRuntimeCredential(runtime, data.data);
|
void refreshRuntimeCredential(runtime, data.data);
|
||||||
@@ -118,10 +122,16 @@ export function createAgentRuntime(resources: WorkbenchSessionResources, scope:
|
|||||||
if (resources.disposed) return;
|
if (resources.disposed) return;
|
||||||
runtime.loadedAt = Date.now();
|
runtime.loadedAt = Date.now();
|
||||||
runtime.updatedAt = 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"
|
? isPendingInteractionError(runtime.error) ? "waiting_input" : "error"
|
||||||
: runtime.runStatus === "waiting_input" ? "waiting_input" : "completed";
|
: runtime.runStatus === "waiting_input" ? "waiting_input" : "completed";
|
||||||
const recovering = isError && !isAbort && runtime.transportInterrupted && runtime.sessionId;
|
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); }
|
if (active()) { if (!recovering) runtime.handlers?.onFinish(runtime.runStatus); }
|
||||||
else if (!recovering) notifyBackgroundCompletion(runtime);
|
else if (!recovering) notifyBackgroundCompletion(runtime);
|
||||||
if (recovering) {
|
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");
|
const sessionId = event.type === "state" ? event.sessionId : event.sessionId ?? getDataString(event.data, "session_id");
|
||||||
if (sessionId && sessionId !== runtime.sessionId) return;
|
if (sessionId && sessionId !== runtime.sessionId) return;
|
||||||
if (event.type === "state") {
|
if (event.type === "state") {
|
||||||
const tail = toAgentUiMessages(event.messages);
|
const tail = toAgentUiMessages(event.messages, event);
|
||||||
const prior = runtime.chat.messages;
|
const prior = runtime.chat.messages;
|
||||||
// Start filling a disconnected interval before reconciling the live tail.
|
// 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))) {
|
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);
|
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);
|
else if (resources.activeAgent !== runtime && event.type === "frontend_action") void rejectBackgroundAction(resources, runtime, event.data);
|
||||||
runtime.chat.messages = applySessionStreamEvent(runtime.chat.messages, event);
|
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 === "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;
|
terminal = true;
|
||||||
runtime.resumed = false;
|
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();
|
runtime.updatedAt = Date.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function useOperationalTasks(
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshGeneration, setRefreshGeneration] = useState(0);
|
const [refreshGeneration, setRefreshGeneration] = useState(0);
|
||||||
const [eventRetryGeneration, setEventRetryGeneration] = useState(0);
|
const enqueueEvents = useRef<(events: WorkbenchEvent[]) => void>(() => {});
|
||||||
const eventRetryMsRef = useRef(1_000);
|
const eventRetryMsRef = useRef(1_000);
|
||||||
const forceRefreshRef = useRef(false);
|
const forceRefreshRef = useRef(false);
|
||||||
const detailResetGenerationRef = useRef(sync.detailResetGeneration);
|
const detailResetGenerationRef = useRef(sync.detailResetGeneration);
|
||||||
@@ -131,50 +131,76 @@ export function useOperationalTasks(
|
|||||||
}, [getAccessToken, refreshGeneration, sync.detailResetGeneration, sync.snapshot]);
|
}, [getAccessToken, refreshGeneration, sync.detailResetGeneration, sync.snapshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const events = syncEvents;
|
|
||||||
if (events.length === 0) return;
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let retryTimer: number | undefined;
|
let retryTimer: number | undefined;
|
||||||
const lastSequence = events.at(-1)!.event_seq;
|
let pendingEvents: WorkbenchEvent[] = [];
|
||||||
const { taskIds, deletedTaskIds, notificationsChanged } = collectOperationalTaskEventChanges(events);
|
let appliedSequence = 0;
|
||||||
if (deletedTaskIds.size > 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));
|
tasksRef.current = tasksRef.current.filter((task) => !deletedTaskIds.has(task.task_id));
|
||||||
setTasks(tasksRef.current);
|
setTasks(tasksRef.current);
|
||||||
setNotifications((current) => current.filter((item) => !deletedTaskIds.has(item.task_id)));
|
setNotifications((current) => current.filter((item) => !deletedTaskIds.has(item.task_id)));
|
||||||
}
|
};
|
||||||
void Promise.all([
|
const load = async () => {
|
||||||
notificationsChanged
|
if (processing || controller.signal.aborted) return;
|
||||||
? fetchOperationalTaskNotifications(getAccessToken, controller.signal)
|
processing = true;
|
||||||
: Promise.resolve(null),
|
window.clearTimeout(retryTimer);
|
||||||
Promise.all([...taskIds].map((taskId) =>
|
try {
|
||||||
fetchOperationalTask(taskId, getAccessToken, controller.signal)
|
while (pendingEvents.length > 0 && !controller.signal.aborted) {
|
||||||
))
|
const events = pendingEvents;
|
||||||
]).then(([nextNotifications, changedTasks]) => {
|
const lastSequence = events.at(-1)!.event_seq;
|
||||||
if (controller.signal.aborted) return;
|
const { taskIds, notificationsChanged } = collectOperationalTaskEventChanges(events);
|
||||||
if (nextNotifications) setNotifications(nextNotifications);
|
const [nextNotifications, changedTasks] = await Promise.all([
|
||||||
if (changedTasks.length > 0) {
|
notificationsChanged
|
||||||
const changed = new Map(changedTasks.map((task) => [task.task_id, task]));
|
? fetchOperationalTaskNotifications(getAccessToken, controller.signal)
|
||||||
tasksRef.current = [
|
: Promise.resolve(null),
|
||||||
...changedTasks,
|
Promise.all([...taskIds].map((taskId) =>
|
||||||
...tasksRef.current.filter((task) => !changed.has(task.task_id))
|
fetchOperationalTask(taskId, getAccessToken, controller.signal)
|
||||||
];
|
))
|
||||||
setTasks(tasksRef.current);
|
]);
|
||||||
}
|
if (controller.signal.aborted) return;
|
||||||
acknowledgeEventsThrough(lastSequence);
|
// A deletion received during this read must not be undone by its response.
|
||||||
eventRetryMsRef.current = 1_000;
|
const { deletedTaskIds } = collectOperationalTaskEventChanges(pendingEvents);
|
||||||
setError(null);
|
if (nextNotifications) setNotifications(nextNotifications.filter((item) => !deletedTaskIds.has(item.task_id)));
|
||||||
}).catch((nextError) => {
|
const visibleTasks = changedTasks.filter((task) => !deletedTaskIds.has(task.task_id));
|
||||||
if (!controller.signal.aborted) {
|
if (visibleTasks.length > 0) {
|
||||||
setError(nextError instanceof Error ? nextError.message : "工况任务增量刷新失败。");
|
const changed = new Map(visibleTasks.map((task) => [task.task_id, task]));
|
||||||
retryTimer = window.setTimeout(() => setEventRetryGeneration((current) => current + 1), eventRetryMsRef.current);
|
tasksRef.current = [
|
||||||
eventRetryMsRef.current = Math.min(eventRetryMsRef.current * 2, 30_000);
|
...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 () => {
|
return () => {
|
||||||
controller.abort();
|
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),
|
const conditions = useMemo(() => tasks.map((task) => ({ ...toOperationalTaskCondition(task),
|
||||||
hasNewReport: notifications.some((item) => item.task_id === task.task_id && item.kind === "report_updated"
|
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 { getWorkbenchAccessToken, getWorkbenchTokenProvider, getWorkbenchProject } from "../api/request-context";
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
@@ -33,9 +34,6 @@ import {
|
|||||||
type AgentEventTriggerContext,
|
type AgentEventTriggerContext,
|
||||||
type AgentModelOption,
|
type AgentModelOption,
|
||||||
type AgentInputDelivery,
|
type AgentInputDelivery,
|
||||||
type AgentPermissionReply,
|
|
||||||
type AgentPermissionRequest,
|
|
||||||
type AgentQuestionRequest,
|
|
||||||
type AgentSessionStreamEvent,
|
type AgentSessionStreamEvent,
|
||||||
type AgentSessionPurpose,
|
type AgentSessionPurpose,
|
||||||
type AgentStreamRenderState,
|
type AgentStreamRenderState,
|
||||||
@@ -52,7 +50,6 @@ import {
|
|||||||
readBodyString,
|
readBodyString,
|
||||||
toAgentChatMessages,
|
toAgentChatMessages,
|
||||||
toAgentUiMessages,
|
toAgentUiMessages,
|
||||||
toPermissionStatus,
|
|
||||||
type AgentDataPart,
|
type AgentDataPart,
|
||||||
type AgentUiMessage,
|
type AgentUiMessage,
|
||||||
type PermissionOverride,
|
type PermissionOverride,
|
||||||
@@ -200,7 +197,7 @@ export function useWorkbenchAgent({
|
|||||||
try {
|
try {
|
||||||
const loaded = await target.client.loadSession(owningSessionId, { messageLimit: 40 });
|
const loaded = await target.client.loadSession(owningSessionId, { messageLimit: 40 });
|
||||||
if (loaded && !resources.disposed && target.client === scopeClient) {
|
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();
|
resources.notify();
|
||||||
}
|
}
|
||||||
} catch { /* A failed reconciliation leaves the accepted answer intact. */ }
|
} catch { /* A failed reconciliation leaves the accepted answer intact. */ }
|
||||||
@@ -534,12 +531,15 @@ export function useWorkbenchAgent({
|
|||||||
() => clientRef.current.listResponseInputs(sessionId!),
|
() => clientRef.current.listResponseInputs(sessionId!),
|
||||||
{ refreshInterval: streaming ? 1000 : 0, shouldRetryOnError: false }
|
{ refreshInterval: streaming ? 1000 : 0, shouldRetryOnError: false }
|
||||||
);
|
);
|
||||||
|
const handledRecovery = useRef({ scopeKey, version: 0 });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (handledRecovery.current.scopeKey === scopeKey && handledRecovery.current.version === recoveryVersion) return;
|
||||||
|
handledRecovery.current = { scopeKey, version: recoveryVersion };
|
||||||
if (!recoveryVersion) return;
|
if (!recoveryVersion) return;
|
||||||
void refreshBootstrap();
|
void refreshBootstrap();
|
||||||
void refreshInteractionRequests();
|
void refreshInteractionRequests();
|
||||||
void refreshPendingInputs();
|
void refreshPendingInputs();
|
||||||
}, [recoveryVersion, refreshBootstrap, refreshInteractionRequests, refreshPendingInputs]);
|
}, [scopeKey, recoveryVersion, refreshBootstrap, refreshInteractionRequests, refreshPendingInputs]);
|
||||||
const updateInputDelivery = useCallback(async (id: string, delivery: AgentInputDelivery) => {
|
const updateInputDelivery = useCallback(async (id: string, delivery: AgentInputDelivery) => {
|
||||||
if (!sessionIdRef.current) return;
|
if (!sessionIdRef.current) return;
|
||||||
try {
|
try {
|
||||||
@@ -681,9 +681,9 @@ export function useWorkbenchAgent({
|
|||||||
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||||
setStatusLabel(event.data.reason === "waiting_input" ? "等待补充信息" : "Agent 分析完成");
|
setStatusLabel(event.data.reason === "waiting_input" ? "等待补充信息" : "Agent 分析完成");
|
||||||
runtimeRef.current!.loadedAt = Date.now();
|
runtimeRef.current!.loadedAt = Date.now();
|
||||||
} else if (event.type === "error") {
|
} else if (event.type === "aborted" || event.type === "error") {
|
||||||
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||||
setStatusLabel("Agent 后端请求失败");
|
setStatusLabel(event.type === "aborted" || event.data.status === "aborted" ? "Agent 已停止" : "Agent 后端请求失败");
|
||||||
runtimeRef.current!.loadedAt = Date.now();
|
runtimeRef.current!.loadedAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,7 +732,7 @@ export function useWorkbenchAgent({
|
|||||||
if (generation !== loadingGenerationRef.current) return false;
|
if (generation !== loadingGenerationRef.current) return false;
|
||||||
if (!target) target = createAgentRuntime(resources, scopeKey, scopeClient, transport, loaded);
|
if (!target) target = createAgentRuntime(resources, scopeKey, scopeClient, transport, loaded);
|
||||||
else {
|
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.title = loaded.title; target.topicId = loaded.topicId; target.loadedAt = Date.now(); target.runStatus = loaded.runStatus;
|
||||||
target.error = undefined;
|
target.error = undefined;
|
||||||
target.messagePage ??= loaded.messagePage;
|
target.messagePage ??= loaded.messagePage;
|
||||||
@@ -827,7 +827,7 @@ export function useWorkbenchAgent({
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
target.client = projectClient;
|
target.client = projectClient;
|
||||||
target.chat.messages = toAgentUiMessages(loaded.messages);
|
target.chat.messages = toAgentUiMessages(loaded.messages, loaded);
|
||||||
target.title = loaded.title;
|
target.title = loaded.title;
|
||||||
target.purpose = "task_execution";
|
target.purpose = "task_execution";
|
||||||
target.taskId = normalizedTaskId;
|
target.taskId = normalizedTaskId;
|
||||||
@@ -877,7 +877,7 @@ export function useWorkbenchAgent({
|
|||||||
refreshing = false;
|
refreshing = false;
|
||||||
return;
|
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.title = loaded.title;
|
||||||
target.updatedAt = loaded.updatedAt;
|
target.updatedAt = loaded.updatedAt;
|
||||||
target.loadedAt = Date.now();
|
target.loadedAt = Date.now();
|
||||||
@@ -1045,7 +1045,7 @@ export function useWorkbenchAgent({
|
|||||||
target.purpose = "task_collaboration";
|
target.purpose = "task_collaboration";
|
||||||
target.taskId = binding.taskId;
|
target.taskId = binding.taskId;
|
||||||
target.taskExecutionHistory = undefined;
|
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.messagePage = loaded.messagePage;
|
||||||
target.loadedAt = Date.now();
|
target.loadedAt = Date.now();
|
||||||
target.updatedAt = loaded.updatedAt;
|
target.updatedAt = loaded.updatedAt;
|
||||||
@@ -1139,62 +1139,9 @@ export function useWorkbenchAgent({
|
|||||||
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||||
}, [chat, interactionRequests, refreshPendingInputs, stopSessionStreamSubscription]);
|
}, [chat, interactionRequests, refreshPendingInputs, stopSessionStreamSubscription]);
|
||||||
|
|
||||||
const replyPermission = useCallback(async (permission: AgentPermissionRequest, reply: AgentPermissionReply) => {
|
const { replyPermission, replyQuestion, rejectQuestion } = useMemo(() => createAgentInteractionReplies(resources, {
|
||||||
const target = resources.activeAgent;
|
permission: setPermissionOverrides, question: setQuestionOverrides
|
||||||
if (!target || target.sessionId !== permission.sessionId) return;
|
}), [resources]);
|
||||||
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]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
apiClient: clientRef.current,
|
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;
|
onSelectFeature: (feature: DetailFeature) => void;
|
||||||
selectedFeature: DetailFeature | null;
|
selectedFeature: DetailFeature | null;
|
||||||
simulationOverlay: FeatureCollection<Geometry> | null;
|
simulationOverlay: FeatureCollection<Geometry> | null;
|
||||||
allowDemoSimulation: boolean;
|
|
||||||
networkView?: NetworkViewParams | null;
|
networkView?: NetworkViewParams | null;
|
||||||
getAccessToken?: WorkbenchAccess;
|
getAccessToken?: WorkbenchAccess;
|
||||||
geoserverWorkspace?: string;
|
geoserverWorkspace?: string;
|
||||||
@@ -121,8 +120,6 @@ const SOURCE_GROUP_BY_ID: Record<string, string> = {
|
|||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])
|
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"
|
[SIMULATION_SOURCE_IDS.backendAnalysis]: "annotation-source"
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,7 +139,6 @@ export function useWorkbenchMap({
|
|||||||
onSelectFeature,
|
onSelectFeature,
|
||||||
selectedFeature,
|
selectedFeature,
|
||||||
simulationOverlay,
|
simulationOverlay,
|
||||||
allowDemoSimulation,
|
|
||||||
networkView,
|
networkView,
|
||||||
geoserverWorkspace,
|
geoserverWorkspace,
|
||||||
getAccessToken
|
getAccessToken
|
||||||
@@ -150,11 +146,9 @@ export function useWorkbenchMap({
|
|||||||
const mapRef = useRef<MapLibreMap | null>(null);
|
const mapRef = useRef<MapLibreMap | null>(null);
|
||||||
const impactVisibleRef = useRef(impactVisible);
|
const impactVisibleRef = useRef(impactVisible);
|
||||||
const simulationOverlayRef = useRef(simulationOverlay);
|
const simulationOverlayRef = useRef(simulationOverlay);
|
||||||
const allowDemoSimulationRef = useRef(allowDemoSimulation);
|
|
||||||
const networkViewRef = useRef<NetworkViewParams>(networkView ?? WATER_NETWORK_GLOBAL_VIEW);
|
const networkViewRef = useRef<NetworkViewParams>(networkView ?? WATER_NETWORK_GLOBAL_VIEW);
|
||||||
const fittedNetworkViewRef = useRef<string | null>(null);
|
const fittedNetworkViewRef = useRef<string | null>(null);
|
||||||
simulationOverlayRef.current = simulationOverlay;
|
simulationOverlayRef.current = simulationOverlay;
|
||||||
allowDemoSimulationRef.current = allowDemoSimulation;
|
|
||||||
networkViewRef.current = networkView ?? WATER_NETWORK_GLOBAL_VIEW;
|
networkViewRef.current = networkView ?? WATER_NETWORK_GLOBAL_VIEW;
|
||||||
const [mapReady, setMapReady] = useState(false);
|
const [mapReady, setMapReady] = useState(false);
|
||||||
const [mapError, setMapError] = useState<string | null>(null);
|
const [mapError, setMapError] = useState<string | null>(null);
|
||||||
@@ -315,10 +309,8 @@ export function useWorkbenchMap({
|
|||||||
const source = sources[sourceId];
|
const source = sources[sourceId];
|
||||||
if (source) map.addSource(sourceId, source);
|
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);
|
map.addSource(SIMULATION_SOURCE_IDS.backendAnalysis, simulationSources.backendAnalysis);
|
||||||
syncSimulationSourceData(map, simulationOverlayRef.current, allowDemoSimulationRef.current);
|
syncSimulationSourceData(map, simulationOverlayRef.current);
|
||||||
await registerSupplyAssetImages(map, scadaAbortController.signal);
|
await registerSupplyAssetImages(map, scadaAbortController.signal);
|
||||||
if (scadaAbortController.signal.aborted) return;
|
if (scadaAbortController.signal.aborted) return;
|
||||||
const scadaRegistration = await registerScadaImages(map, scadaAbortController.signal);
|
const scadaRegistration = await registerScadaImages(map, scadaAbortController.signal);
|
||||||
@@ -327,16 +319,13 @@ export function useWorkbenchMap({
|
|||||||
scadaRegistration.status === "unavailable"
|
scadaRegistration.status === "unavailable"
|
||||||
? scadaFallbackBusinessLayers
|
? scadaFallbackBusinessLayers
|
||||||
: scadaBusinessLayers;
|
: scadaBusinessLayers;
|
||||||
simulationAnnotationLayers
|
|
||||||
.filter((layer) => layer.id === "simulation-impact-fill")
|
|
||||||
.forEach((layer) => map.addLayer(layer));
|
|
||||||
filterWaterNetworkLayersBySourceIds(
|
filterWaterNetworkLayersBySourceIds(
|
||||||
waterNetworkBusinessLayers,
|
waterNetworkBusinessLayers,
|
||||||
REQUIRED_GEOSERVER_SOURCE_IDS
|
REQUIRED_GEOSERVER_SOURCE_IDS
|
||||||
).forEach((layer) => map.addLayer(layer));
|
).forEach((layer) => map.addLayer(layer));
|
||||||
activeScadaBusinessLayers.forEach((layer) => map.addLayer(layer));
|
activeScadaBusinessLayers.forEach((layer) => map.addLayer(layer));
|
||||||
simulationAnnotationLayers
|
simulationAnnotationLayers
|
||||||
.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill")
|
.filter((layer) => layer.type !== "symbol")
|
||||||
.forEach((layer) => map.addLayer(layer));
|
.forEach((layer) => map.addLayer(layer));
|
||||||
filterWaterNetworkLayersBySourceIds(
|
filterWaterNetworkLayersBySourceIds(
|
||||||
waterNetworkInteractionLayers,
|
waterNetworkInteractionLayers,
|
||||||
@@ -484,8 +473,8 @@ export function useWorkbenchMap({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map || !mapReady) return;
|
if (!map || !mapReady) return;
|
||||||
syncSimulationSourceData(map, simulationOverlay, allowDemoSimulation);
|
syncSimulationSourceData(map, simulationOverlay);
|
||||||
}, [allowDemoSimulation, mapReady, simulationOverlay]);
|
}, [mapReady, simulationOverlay]);
|
||||||
|
|
||||||
function fitToNetworkBounds() {
|
function fitToNetworkBounds() {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
@@ -541,23 +530,12 @@ function getScadaStatusMessage(snapshot: Awaited<ReturnType<typeof fetchScadaSna
|
|||||||
|
|
||||||
function syncSimulationSourceData(
|
function syncSimulationSourceData(
|
||||||
map: MapLibreMap,
|
map: MapLibreMap,
|
||||||
backendOverlay: FeatureCollection<Geometry> | null,
|
backendOverlay: FeatureCollection<Geometry> | null
|
||||||
allowDemoSimulation: boolean
|
|
||||||
) {
|
) {
|
||||||
const useBackendOverlay = Boolean(backendOverlay?.features.length);
|
const useBackendOverlay = Boolean(backendOverlay?.features.length);
|
||||||
(map.getSource(SIMULATION_SOURCE_IDS.backendAnalysis) as GeoJSONSource | undefined)?.setData(
|
(map.getSource(SIMULATION_SOURCE_IDS.backendAnalysis) as GeoJSONSource | undefined)?.setData(
|
||||||
useBackendOverlay ? backendOverlay! : EMPTY_SIMULATION_COLLECTION
|
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(
|
function syncDataDependentGeoServerLayers(
|
||||||
|
|||||||
@@ -177,12 +177,15 @@ export function useWorkbenchRuntimeData(
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let timeoutId: number | undefined;
|
let timeoutId: number | undefined;
|
||||||
|
|
||||||
|
let refreshing = false;
|
||||||
const refreshRealtimePipeFlows = async () => {
|
const refreshRealtimePipeFlows = async () => {
|
||||||
|
if (controller.signal.aborted || refreshing) return;
|
||||||
if (document.visibilityState === "hidden") {
|
if (document.visibilityState === "hidden") {
|
||||||
timeoutId = window.setTimeout(refreshRealtimePipeFlows, BACKEND_RUNTIME_REFRESH_INTERVAL_MS);
|
timeoutId = window.setTimeout(refreshRealtimePipeFlows, BACKEND_RUNTIME_REFRESH_INTERVAL_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
refreshing = true;
|
||||||
const snapshot = await fetchRealtimePipeFlows(getAccessToken, controller.signal);
|
const snapshot = await fetchRealtimePipeFlows(getAccessToken, controller.signal);
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
setRealtimePipeFlows(snapshot.records.length > 0 ? snapshot : null);
|
setRealtimePipeFlows(snapshot.records.length > 0 ? snapshot : null);
|
||||||
@@ -190,6 +193,7 @@ export function useWorkbenchRuntimeData(
|
|||||||
} catch {
|
} catch {
|
||||||
if (!controller.signal.aborted) setRealtimePipeFlows(null);
|
if (!controller.signal.aborted) setRealtimePipeFlows(null);
|
||||||
} finally {
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
timeoutId = window.setTimeout(
|
timeoutId = window.setTimeout(
|
||||||
refreshRealtimePipeFlows,
|
refreshRealtimePipeFlows,
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ import {
|
|||||||
import { getResponsivePadding, getResponsiveWorkbenchPadding } from "./map/camera";
|
import { getResponsivePadding, getResponsiveWorkbenchPadding } from "./map/camera";
|
||||||
import { exportMapViewImage } from "./map/export-view";
|
import { exportMapViewImage } from "./map/export-view";
|
||||||
import { setMapLayersVisibility } from "./map/layer-visibility";
|
import { setMapLayersVisibility } from "./map/layer-visibility";
|
||||||
|
import { MAP_STYLE_TOKENS } from "./map/map-colors";
|
||||||
import {
|
import {
|
||||||
BASE_LAYER_OPTIONS,
|
BASE_LAYER_OPTIONS,
|
||||||
INITIAL_LAYER_VISIBILITY,
|
INITIAL_LAYER_VISIBILITY,
|
||||||
@@ -298,9 +299,6 @@ function MapWorkbenchContent({
|
|||||||
const artifactActive = artifactWorkspace.activeArtifact !== null;
|
const artifactActive = artifactWorkspace.activeArtifact !== null;
|
||||||
const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null;
|
const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null;
|
||||||
const activeArtifactHasSpatialView = Boolean(activeSpatialOverlay?.features.length);
|
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(
|
const latestAnalysisAvailable = Boolean(
|
||||||
latestAnalysisArtifact?.blocks.some(
|
latestAnalysisArtifact?.blocks.some(
|
||||||
(block) =>
|
(block) =>
|
||||||
@@ -355,11 +353,6 @@ function MapWorkbenchContent({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setImpactVisible(activeArtifactHasSpatialView);
|
setImpactVisible(activeArtifactHasSpatialView);
|
||||||
setLayerVisibility((current) =>
|
|
||||||
current.simulation === activeArtifactHasSpatialView
|
|
||||||
? current
|
|
||||||
: { ...current, simulation: activeArtifactHasSpatialView }
|
|
||||||
);
|
|
||||||
}, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]);
|
}, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]);
|
||||||
|
|
||||||
function openAgentWorkspaceForViewport() {
|
function openAgentWorkspaceForViewport() {
|
||||||
@@ -458,7 +451,6 @@ function MapWorkbenchContent({
|
|||||||
onSelectFeature: handleSelectFeature,
|
onSelectFeature: handleSelectFeature,
|
||||||
selectedFeature: detailFeature,
|
selectedFeature: detailFeature,
|
||||||
simulationOverlay: activeSpatialOverlay,
|
simulationOverlay: activeSpatialOverlay,
|
||||||
allowDemoSimulation,
|
|
||||||
networkView: projectPresentation.networkView,
|
networkView: projectPresentation.networkView,
|
||||||
geoserverWorkspace: getWorkbenchProject(getAccessToken)?.gs_workspace,
|
geoserverWorkspace: getWorkbenchProject(getAccessToken)?.gs_workspace,
|
||||||
getAccessToken
|
getAccessToken
|
||||||
@@ -690,23 +682,55 @@ function MapWorkbenchContent({
|
|||||||
[activeToolId, mapReady, toggleToolbarTool]
|
[activeToolId, mapReady, toggleToolbarTool]
|
||||||
);
|
);
|
||||||
|
|
||||||
const layerControlItems = useMemo(
|
const overlayLayers = [
|
||||||
() => createLayerControlItems(layerVisibility, availableSourceIds, sourceFeatureCounts),
|
{ scope: "investigation", description: "Agent 调查", surface: presentation },
|
||||||
[availableSourceIds, layerVisibility, sourceFeatureCounts]
|
{ 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(
|
const visibleLegendItems = useMemo(
|
||||||
() => createVisibleMapLegendItems(layerControlItems),
|
() => createVisibleMapLegendItems(layerControlItems),
|
||||||
[layerControlItems]
|
[layerControlItems]
|
||||||
);
|
);
|
||||||
function handleToggleLayer(layerControlId: string, visible: boolean) {
|
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);
|
presentationLayerChangesRef.current.delete(layerControlId);
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
|
|
||||||
setLayerVisibility((current) => ({ ...current, [layerControlId]: visible }));
|
setLayerVisibility((current) => ({ ...current, [layerControlId]: visible }));
|
||||||
if (layerControlId === "simulation") {
|
|
||||||
setImpactVisible(visible);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!map || !mapReady) {
|
if (!map || !mapReady) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -932,19 +956,15 @@ function MapWorkbenchContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleStartNewAgentSession() {
|
async function handleStartNewAgentSession() {
|
||||||
mapController.clearScadaAnalysis();
|
|
||||||
await agent.startNewSession();
|
await agent.startNewSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLoadAgentHistorySession(sessionId: string) {
|
async function handleLoadAgentHistorySession(sessionId: string) {
|
||||||
const changesSession = sessionId !== agent.sessionId;
|
|
||||||
const loaded = await agent.loadHistorySession(sessionId);
|
const loaded = await agent.loadHistorySession(sessionId);
|
||||||
if (!loaded) throw new Error("会话打开已取消");
|
if (!loaded) throw new Error("会话打开已取消");
|
||||||
if (changesSession) mapController.clearScadaAnalysis();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleOpenTaskCollaboration(taskId: string, discussion?: InvestigationDiscussion) {
|
async function handleOpenTaskCollaboration(taskId: string, discussion?: InvestigationDiscussion) {
|
||||||
mapController.clearScadaAnalysis();
|
|
||||||
const id = await agent.openTaskCollaboration(taskId, discussion);
|
const id = await agent.openTaskCollaboration(taskId, discussion);
|
||||||
if (discussion?.reference && id) agent.setContentReference(discussion.reference);
|
if (discussion?.reference && id) agent.setContentReference(discussion.reference);
|
||||||
readingWorkspace?.close();
|
readingWorkspace?.close();
|
||||||
@@ -956,7 +976,6 @@ function MapWorkbenchContent({
|
|||||||
target: TaskExecutionHistoryTarget,
|
target: TaskExecutionHistoryTarget,
|
||||||
knownRunId?: string
|
knownRunId?: string
|
||||||
) {
|
) {
|
||||||
mapController.clearScadaAnalysis();
|
|
||||||
let runId = knownRunId;
|
let runId = knownRunId;
|
||||||
if (!runId) {
|
if (!runId) {
|
||||||
const runs = await fetchOperationalTaskRuns(taskId, getAccessToken);
|
const runs = await fetchOperationalTaskRuns(taskId, getAccessToken);
|
||||||
@@ -975,12 +994,8 @@ function MapWorkbenchContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteAgentHistorySession(sessionId: string) {
|
async function handleDeleteAgentHistorySession(sessionId: string) {
|
||||||
const clearsActiveAnalysis = sessionId === agent.sessionId;
|
|
||||||
const deleted = await agent.deleteHistorySession(sessionId);
|
const deleted = await agent.deleteHistorySession(sessionId);
|
||||||
if (!deleted) throw new Error("会话删除失败,请重试");
|
if (!deleted) throw new Error("会话删除失败,请重试");
|
||||||
if (deleted && clearsActiveAnalysis) {
|
|
||||||
mapController.clearScadaAnalysis();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAgentMapAction(
|
async function handleAgentMapAction(
|
||||||
@@ -1079,7 +1094,7 @@ function MapWorkbenchContent({
|
|||||||
measurement.clear();
|
measurement.clear();
|
||||||
drawing.clear();
|
drawing.clear();
|
||||||
geocoding.clear();
|
geocoding.clear();
|
||||||
handleToggleLayer("simulation", false);
|
setImpactVisible(false);
|
||||||
showMapNotice({ tone: "success", message: "已清空所有高亮和覆盖层。" });
|
showMapNotice({ tone: "success", message: "已清空所有高亮和覆盖层。" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1471,10 +1486,10 @@ function MapWorkbenchContent({
|
|||||||
</div>
|
</div>
|
||||||
{!devPanelOpen ? (
|
{!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} />
|
<MapToolbar items={toolbarItems} />
|
||||||
</div>
|
</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} />
|
<ToolbarPanel {...toolbarPanelProps} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -1570,7 +1585,7 @@ function MapWorkbenchContent({
|
|||||||
|
|
||||||
{mobileSheet === null && !readingWorkspace?.active && artifactWorkspace.surfaceMode !== "focus" ? (
|
{mobileSheet === null && !readingWorkspace?.active && artifactWorkspace.surfaceMode !== "focus" ? (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
bottom: mobileOverlayLayout.mapControlBottom,
|
bottom: mobileOverlayLayout.mapControlBottom,
|
||||||
"--mobile-tool-available-height": `calc(100dvh - ${mobileOverlayLayout.mapControlBottom}px - 4.5rem)`
|
"--mobile-tool-available-height": `calc(100dvh - ${mobileOverlayLayout.mapControlBottom}px - 4.5rem)`
|
||||||
|
|||||||
@@ -1,54 +1,18 @@
|
|||||||
import type { GeoJSONSourceSpecification, StyleSpecification } from "maplibre-gl";
|
import type { GeoJSONSourceSpecification, StyleSpecification } from "maplibre-gl";
|
||||||
import { annotationPoints, impactAreaPolygon } from "../data/workbench-simulation";
|
|
||||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
|
|
||||||
export const SIMULATION_SOURCE_IDS = {
|
export const SIMULATION_SOURCE_IDS = {
|
||||||
impactArea: "simulation-impact-area",
|
|
||||||
annotations: "simulation-annotations",
|
|
||||||
backendAnalysis: "simulation-backend-analysis"
|
backendAnalysis: "simulation-backend-analysis"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const EMPTY_FEATURE_COLLECTION = {
|
|
||||||
type: "FeatureCollection" as const,
|
|
||||||
features: []
|
|
||||||
};
|
|
||||||
|
|
||||||
export const simulationSources = {
|
export const simulationSources = {
|
||||||
impactArea: {
|
|
||||||
type: "geojson",
|
|
||||||
data: impactAreaPolygon
|
|
||||||
} satisfies GeoJSONSourceSpecification,
|
|
||||||
annotations: {
|
|
||||||
type: "geojson",
|
|
||||||
data: annotationPoints
|
|
||||||
} satisfies GeoJSONSourceSpecification,
|
|
||||||
backendAnalysis: {
|
backendAnalysis: {
|
||||||
type: "geojson",
|
type: "geojson",
|
||||||
data: EMPTY_FEATURE_COLLECTION
|
data: { type: "FeatureCollection", features: [] }
|
||||||
} satisfies GeoJSONSourceSpecification
|
} satisfies GeoJSONSourceSpecification
|
||||||
};
|
};
|
||||||
|
|
||||||
export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
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",
|
id: "simulation-backend-analysis-fill",
|
||||||
type: "fill",
|
type: "fill",
|
||||||
@@ -82,83 +46,6 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
|||||||
"circle-stroke-width": 2
|
"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",
|
id: "simulation-backend-analysis-label",
|
||||||
type: "symbol",
|
type: "symbol",
|
||||||
@@ -176,25 +63,6 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
|||||||
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
|
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||||
"text-halo-width": 3
|
"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",
|
"scada-flow",
|
||||||
"water_quality_monitoring_points",
|
"water_quality_monitoring_points",
|
||||||
"fx_20200423_pipes",
|
"fx_20200423_pipes",
|
||||||
"fx_20200811_dn100_pipes",
|
"fx_20200811_dn100_pipes"
|
||||||
"simulation"
|
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
expect(items.find((item) => item.id === "fx_20200423_pipes")).toMatchObject({
|
expect(items.find((item) => item.id === "fx_20200423_pipes")).toMatchObject({
|
||||||
@@ -104,12 +103,30 @@ describe("workbench supply layer controls", () => {
|
|||||||
"valves",
|
"valves",
|
||||||
"reservoirs",
|
"reservoirs",
|
||||||
"scada-pressure",
|
"scada-pressure",
|
||||||
"scada-flow",
|
"scada-flow"
|
||||||
"simulation"
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
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([
|
expect(MAP_LEGEND_ITEMS.map((item) => item.id)).toEqual([
|
||||||
"major-pipe",
|
"major-pipe",
|
||||||
"medium-pipe",
|
"medium-pipe",
|
||||||
@@ -134,8 +151,7 @@ describe("workbench supply layer controls", () => {
|
|||||||
"source-hydrant",
|
"source-hydrant",
|
||||||
"source-connection",
|
"source-connection",
|
||||||
"source-customer-meter",
|
"source-customer-meter",
|
||||||
"source-non-residential-meter",
|
"source-non-residential-meter"
|
||||||
"impact-area"
|
|
||||||
]);
|
]);
|
||||||
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "valve")).toMatchObject({
|
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "valve")).toMatchObject({
|
||||||
imageSrc: "/map/valve.png"
|
imageSrc: "/map/valve.png"
|
||||||
@@ -186,11 +202,6 @@ describe("workbench supply layer controls", () => {
|
|||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
imageSrc: "/map/gis-non-residential-meter.svg"
|
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", () => {
|
it("keeps the legend synchronized with visible layer controls", () => {
|
||||||
@@ -222,8 +233,7 @@ describe("workbench supply layer controls", () => {
|
|||||||
|
|
||||||
const reducedItems = createLayerControlItems({
|
const reducedItems = createLayerControlItems({
|
||||||
...INITIAL_LAYER_VISIBILITY,
|
...INITIAL_LAYER_VISIBILITY,
|
||||||
valves: false,
|
valves: false
|
||||||
simulation: false
|
|
||||||
});
|
});
|
||||||
const reducedLegendIds = createVisibleMapLegendItems(reducedItems).map((item) => item.id);
|
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 { SCADA_ICON_PATHS, SCADA_LAYER_CONTROL_ITEMS, SCADA_LAYER_GROUPS } from "./scada";
|
||||||
import { SUPPLY_ASSET_ICON_PATHS } from "./supply-icons";
|
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> = {
|
export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
SUPPLY_LAYER_CATALOG.filter((layer) => layer.id !== "scada").map((layer) => [
|
SUPPLY_LAYER_CATALOG.filter((layer) => layer.id !== "scada").map((layer) => [
|
||||||
@@ -35,8 +22,17 @@ export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
|
|||||||
layer.initialVisible
|
layer.initialVisible
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
...Object.fromEntries(SCADA_LAYER_CONTROL_ITEMS.map((item) => [item.id, true])),
|
...Object.fromEntries(SCADA_LAYER_CONTROL_ITEMS.map((item) => [item.id, true]))
|
||||||
simulation: false
|
};
|
||||||
|
|
||||||
|
export type AnalysisOverlayLayer = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
visible: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
featureCount: number;
|
||||||
|
color?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SOURCE_CONTROL_LABELS = Object.fromEntries(
|
const SOURCE_CONTROL_LABELS = Object.fromEntries(
|
||||||
@@ -421,25 +417,27 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
|
|||||||
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceNonResidentialMeter,
|
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceNonResidentialMeter,
|
||||||
layerControlId: "fx_20200423_non_residential_meters",
|
layerControlId: "fx_20200423_non_residential_meters",
|
||||||
...SOURCE_FACILITY_LEGEND_GROUP
|
...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(
|
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));
|
const visibleLayerIds = new Set(layerItems.filter((item) => item.visible).map((item) => item.id));
|
||||||
return MAP_LEGEND_ITEMS.filter((item) =>
|
return [
|
||||||
item.layerControlId ? visibleLayerIds.has(item.layerControlId) : true
|
...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 = {
|
export const BASE_LAYER_GROUPS = {
|
||||||
@@ -502,7 +500,8 @@ export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
|
|||||||
export function createLayerControlItems(
|
export function createLayerControlItems(
|
||||||
layerVisibility: Record<string, boolean>,
|
layerVisibility: Record<string, boolean>,
|
||||||
availableSourceIds: readonly WaterNetworkSourceId[] = AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
availableSourceIds: readonly WaterNetworkSourceId[] = AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
||||||
featureCounts: Partial<Record<WaterNetworkSourceId, number>> = {}
|
featureCounts: Partial<Record<WaterNetworkSourceId, number>> = {},
|
||||||
|
analysisOverlays: readonly AnalysisOverlayLayer[] = []
|
||||||
): MapLayerControlItem[] {
|
): MapLayerControlItem[] {
|
||||||
const available = new Set<WaterNetworkSourceId>(availableSourceIds);
|
const available = new Set<WaterNetworkSourceId>(availableSourceIds);
|
||||||
const catalogItems = SUPPLY_LAYER_CATALOG.filter(
|
const catalogItems = SUPPLY_LAYER_CATALOG.filter(
|
||||||
@@ -560,16 +559,18 @@ export function createLayerControlItems(
|
|||||||
groupDescription: "源数据快照与专项台账",
|
groupDescription: "源数据快照与专项台账",
|
||||||
groupDefaultOpen: false
|
groupDefaultOpen: false
|
||||||
})),
|
})),
|
||||||
{
|
...analysisOverlays.map((overlay) => ({
|
||||||
id: "simulation",
|
id: overlay.id,
|
||||||
label: "模拟结果",
|
label: overlay.title,
|
||||||
description: "影响范围与事故标注",
|
description: overlay.description,
|
||||||
visual: { kind: "area", color: MAP_STYLE_TOKENS.state.incident },
|
featureCount: overlay.featureCount,
|
||||||
visible: layerVisibility.simulation,
|
visual: { kind: "area" as const, color: overlay.color ?? MAP_STYLE_TOKENS.state.risk },
|
||||||
|
visible: overlay.visible,
|
||||||
|
disabled: overlay.disabled,
|
||||||
group: "analysis",
|
group: "analysis",
|
||||||
groupLabel: "分析覆盖",
|
groupLabel: "分析覆盖",
|
||||||
groupDescription: "随已打开的空间成果显示"
|
groupDescription: "Agent 生成的范围与分析成果"
|
||||||
}
|
}))
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,8 +590,7 @@ export function getWorkbenchLayerIds(
|
|||||||
.map((layer) => layer.id);
|
.map((layer) => layer.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const layerIds = WORKBENCH_LAYER_GROUPS[groupId] ?? [];
|
return [];
|
||||||
return layerIds.filter((layerId) => map.getLayer(layerId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWaterNetworkSourceId(value: string): value is WaterNetworkSourceId {
|
function isWaterNetworkSourceId(value: string): value is WaterNetworkSourceId {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Feature, FeatureCollection, Point } from "geojson";
|
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";
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
|
|
||||||
export const SCADA_ANALYSIS_SOURCE_ID = "agent-scada-analysis";
|
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> {
|
export function createEmptyScadaAnalysisCollection(): FeatureCollection<Point> {
|
||||||
return { type: "FeatureCollection", features: [] };
|
return { type: "FeatureCollection", features: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { StyleSpecification, VectorSourceSpecification } from "maplibre-gl"
|
|||||||
|
|
||||||
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
|
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
|
||||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
import { SCADA_ANALYSIS_SOURCE_ID, createEmptyScadaAnalysisCollection } from "./scada-analysis";
|
|
||||||
import { SCADA_SOURCE_ID } from "./scada";
|
import { SCADA_SOURCE_ID } from "./scada";
|
||||||
|
|
||||||
export const WATER_NETWORK_GLOBAL_VIEW = {
|
export const WATER_NETWORK_GLOBAL_VIEW = {
|
||||||
@@ -325,10 +324,6 @@ export function createBaseStyle(mapboxToken?: string, tiandituToken?: string): S
|
|||||||
type: "geojson",
|
type: "geojson",
|
||||||
promoteId: "device_id",
|
promoteId: "device_id",
|
||||||
data: { type: "FeatureCollection", features: [] }
|
data: { type: "FeatureCollection", features: [] }
|
||||||
},
|
|
||||||
[SCADA_ANALYSIS_SOURCE_ID]: {
|
|
||||||
type: "geojson",
|
|
||||||
data: createEmptyScadaAnalysisCollection()
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const layers: StyleSpecification["layers"] = [
|
const layers: StyleSpecification["layers"] = [
|
||||||
|
|||||||
@@ -20,29 +20,23 @@ describe("WorkbenchMapController", () => {
|
|||||||
expect(map.easeTo).not.toHaveBeenCalled();
|
expect(map.easeTo).not.toHaveBeenCalled();
|
||||||
expect(controller.getSnapshot().targets).toEqual([]);
|
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();
|
const map = createMap();
|
||||||
map.getLayer.mockReturnValue({} as never);
|
map.getLayer.mockReturnValue({} as never);
|
||||||
const setData = vi.fn();
|
const setData = vi.fn();
|
||||||
map.getSource.mockReturnValue({ setData });
|
map.getSource.mockReturnValue({ setData });
|
||||||
let resolveFeatures = (_value: FeatureCollection) => {};
|
|
||||||
const pendingAnalysis = new Promise<FeatureCollection>((resolve) => { resolveFeatures = resolve; });
|
|
||||||
const controller = new WorkbenchMapController({
|
const controller = new WorkbenchMapController({
|
||||||
getMap: () => map as unknown as MapLibreMap, isReady: () => true,
|
getMap: () => map as unknown as MapLibreMap, isReady: () => true,
|
||||||
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
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" });
|
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
|
||||||
controller.applyLayerGroupStyle("pipes", { color: "#123456" });
|
controller.applyLayerGroupStyle("pipes", { color: "#123456" });
|
||||||
const showingFlow = controller.setFlowVisible(true);
|
const showingFlow = controller.setFlowVisible(true);
|
||||||
const analysis = controller.renderScadaAnalysis([{ device_id: "J-1", level: "high" }]);
|
|
||||||
controller.clearOverlays();
|
controller.clearOverlays();
|
||||||
await showingFlow;
|
await showingFlow;
|
||||||
resolveFeatures(pointCollection);
|
|
||||||
await expect(analysis).rejects.toThrow("ACTION_SUPERSEDED");
|
|
||||||
expect(setData).toHaveBeenCalledWith({ type: "FeatureCollection", features: [] });
|
|
||||||
expect(controller.getSnapshot()).toMatchObject({
|
expect(controller.getSnapshot()).toMatchObject({
|
||||||
target: null, targets: [], targetResult: null, scadaAnalysis: null,
|
target: null, targets: [], targetResult: null,
|
||||||
flowVisible: false, pending: false, valueLabels: {}, styledGroups: ["pipes"]
|
flowVisible: false, pending: false, valueLabels: {}, styledGroups: ["pipes"]
|
||||||
});
|
});
|
||||||
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({ id: "J-1" }), "highlighted");
|
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({ id: "J-1" }), "highlighted");
|
||||||
@@ -552,37 +546,7 @@ describe("WorkbenchMapController", () => {
|
|||||||
expect(map.removeFeatureState).not.toHaveBeenCalled();
|
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" } }] };
|
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 { 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 { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
|
||||||
import { PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
|
import { PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
|
||||||
import {
|
import {
|
||||||
@@ -36,14 +36,7 @@ import {
|
|||||||
type ValueLabelSourceId,
|
type ValueLabelSourceId,
|
||||||
type ValueLabelStyle
|
type ValueLabelStyle
|
||||||
} from "./value-label";
|
} 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 FeatureTarget = { sourceId: WaterNetworkSourceId; featureId: string };
|
||||||
export type FeatureBatchTarget = { sourceId: WaterNetworkSourceId; featureIds: string[] };
|
export type FeatureBatchTarget = { sourceId: WaterNetworkSourceId; featureIds: string[] };
|
||||||
@@ -56,7 +49,6 @@ export type WorkbenchMapErrorCode =
|
|||||||
| "INVALID_FEATURE_IDS"
|
| "INVALID_FEATURE_IDS"
|
||||||
| "WFS_UNAVAILABLE"
|
| "WFS_UNAVAILABLE"
|
||||||
| "SCADA_API_UNAVAILABLE"
|
| "SCADA_API_UNAVAILABLE"
|
||||||
| "SCADA_FEATURES_NOT_FOUND"
|
|
||||||
| "ACTION_SUPERSEDED"
|
| "ACTION_SUPERSEDED"
|
||||||
| "FLOW_UNAVAILABLE"
|
| "FLOW_UNAVAILABLE"
|
||||||
| "INVALID_FLOW_CALCULATION"
|
| "INVALID_FLOW_CALCULATION"
|
||||||
@@ -72,7 +64,6 @@ export type WorkbenchMapControllerState = {
|
|||||||
flowVisible: boolean;
|
flowVisible: boolean;
|
||||||
flowSummary: PipeFlowSummary;
|
flowSummary: PipeFlowSummary;
|
||||||
styledGroups: LayerGroupId[];
|
styledGroups: LayerGroupId[];
|
||||||
scadaAnalysis: ScadaAnalysisRenderResult | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ValueLabelRequest = Omit<ValueLabelStyle, "unit"> & { unit?: string };
|
export type ValueLabelRequest = Omit<ValueLabelStyle, "unit"> & { unit?: string };
|
||||||
@@ -89,8 +80,6 @@ export type WorkbenchMapCommands = {
|
|||||||
clearValueLabel: (sourceId?: ValueLabelSourceId) => void;
|
clearValueLabel: (sourceId?: ValueLabelSourceId) => void;
|
||||||
applyLayerGroupStyle: (groupId: LayerGroupId, patch: LayerGroupStylePatch) => void;
|
applyLayerGroupStyle: (groupId: LayerGroupId, patch: LayerGroupStylePatch) => void;
|
||||||
resetLayerGroupStyle: (groupId: LayerGroupId) => void;
|
resetLayerGroupStyle: (groupId: LayerGroupId) => void;
|
||||||
renderScadaAnalysis: (items: ScadaAnalysisItem[], signal?: AbortSignal) => Promise<ScadaAnalysisRenderResult>;
|
|
||||||
clearScadaAnalysis: () => { cleared: true };
|
|
||||||
resetAll: () => void;
|
resetAll: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -114,15 +103,13 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
errorCode: null,
|
errorCode: null,
|
||||||
flowVisible: false,
|
flowVisible: false,
|
||||||
flowSummary: createEmptyPipeFlowSummary(),
|
flowSummary: createEmptyPipeFlowSummary(),
|
||||||
styledGroups: [],
|
styledGroups: []
|
||||||
scadaAnalysis: null
|
|
||||||
};
|
};
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
private cachedFeatures = new Map<string, Feature>();
|
private cachedFeatures = new Map<string, Feature>();
|
||||||
private flowState: FlowOverlayState = createFlowOverlayState();
|
private flowState: FlowOverlayState = createFlowOverlayState();
|
||||||
private flowUpdateRevision = 0;
|
private flowUpdateRevision = 0;
|
||||||
private flowVisibilityRevision = 0;
|
private flowVisibilityRevision = 0;
|
||||||
private scadaAnalysisRevision = 0;
|
|
||||||
private targetRequestRevision = 0;
|
private targetRequestRevision = 0;
|
||||||
private pendingTargetSourceId: WaterNetworkSourceId | null = null;
|
private pendingTargetSourceId: WaterNetworkSourceId | null = null;
|
||||||
private availableSourceIds: ReadonlySet<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 });
|
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 = () => {
|
clearOverlays = () => {
|
||||||
this.clearHighlight();
|
this.clearHighlight();
|
||||||
this.clearValueLabel();
|
this.clearValueLabel();
|
||||||
this.clearScadaAnalysis();
|
|
||||||
this.flowVisibilityRevision += 1;
|
this.flowVisibilityRevision += 1;
|
||||||
const map = this.options.getMap();
|
const map = this.options.getMap();
|
||||||
if (map) hideFlowOverlay(map, this.flowState);
|
if (map) hideFlowOverlay(map, this.flowState);
|
||||||
this.patchState({ flowVisible: false });
|
this.patchState({ flowVisible: false, pending: false, errorCode: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
resetAll = () => {
|
resetAll = () => {
|
||||||
@@ -536,8 +411,7 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
errorCode: null,
|
errorCode: null,
|
||||||
flowVisible: false,
|
flowVisible: false,
|
||||||
flowSummary: createEmptyPipeFlowSummary(),
|
flowSummary: createEmptyPipeFlowSummary(),
|
||||||
styledGroups: [],
|
styledGroups: []
|
||||||
scadaAnalysis: null
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatLocalMonthDayTime } from "@/shared/utils/date-time";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { motion, useReducedMotion } from "motion/react";
|
import { motion, useReducedMotion } from "motion/react";
|
||||||
import { ArrowUpRight, CheckCircle2, CircleHelp, Clock3, LoaderCircle, TriangleAlert } from "lucide-react";
|
import { ArrowUpRight, CheckCircle2, CircleHelp, Clock3, LoaderCircle, TriangleAlert } from "lucide-react";
|
||||||
@@ -54,7 +55,7 @@ export function ScadaMonitoringNotice({ result, animationId, processing, onOpenI
|
|||||||
<span>调查监测</span>
|
<span>调查监测</span>
|
||||||
{result ? <time dateTime={result.period_end} className="whitespace-nowrap tabular-nums">
|
{result ? <time dateTime={result.period_end} className="whitespace-nowrap tabular-nums">
|
||||||
<span className="sr-only">最新监测 </span>
|
<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>}
|
</time> : <span>尚无监测结果</span>}
|
||||||
</div>
|
</div>
|
||||||
<div data-slot="scada-notice-content" className="flex min-w-0 items-center gap-3">
|
<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";
|
import type { ScadaMonitoringCalibration, ScadaMonitoringConfig } from "../api/scada-replay-client";
|
||||||
|
|
||||||
export function ScadaMonitoringRules({ config, calibration, frozen }: {
|
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}%(逐测点)`;
|
const range = lower === upper ? `${direction}阈值 ${lower}%` : `${direction}阈值 ${lower}%~${upper}%(逐测点)`;
|
||||||
return thresholds.length < ids.length ? `${range} · 已就绪 ${thresholds.length}/${ids.length} 个测点` : range;
|
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 { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Activity,
|
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) {
|
function localTime(time: number) {
|
||||||
const date = new Date(time);
|
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"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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) ?? [];
|
const layerIds = globalThis.__waterNetworkMap?.getStyle().layers.map((layer) => layer.id) ?? [];
|
||||||
return {
|
return {
|
||||||
valveLabel: layerIds.indexOf("workbench-value-label-valves"),
|
valveLabel: layerIds.indexOf("workbench-value-label-valves"),
|
||||||
simulationLabel: layerIds.indexOf("simulation-burst-label")
|
simulationLabel: layerIds.indexOf("simulation-backend-analysis-label")
|
||||||
};
|
};
|
||||||
})).toMatchObject({
|
})).toMatchObject({
|
||||||
valveLabel: expect.any(Number),
|
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) ?? [];
|
const layerIds = globalThis.__waterNetworkMap?.getStyle().layers.map((layer) => layer.id) ?? [];
|
||||||
return [
|
return [
|
||||||
layerIds.indexOf("workbench-value-label-valves"),
|
layerIds.indexOf("workbench-value-label-valves"),
|
||||||
layerIds.indexOf("simulation-burst-label")
|
layerIds.indexOf("simulation-backend-analysis-label")
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
expect(layerOrder[0]).toBeGreaterThanOrEqual(0);
|
expect(layerOrder[0]).toBeGreaterThanOrEqual(0);
|
||||||
|
|||||||
@@ -377,9 +377,8 @@ test("keeps a non-spatial backend result in the analysis workspace without demo
|
|||||||
await expect.poll(() => page.evaluate(() => ({
|
await expect.poll(() => page.evaluate(() => ({
|
||||||
backend: window.__waterNetworkMap
|
backend: window.__waterNetworkMap
|
||||||
?.querySourceFeatures("simulation-backend-analysis").length ?? 0,
|
?.querySourceFeatures("simulation-backend-analysis").length ?? 0,
|
||||||
demo: window.__waterNetworkMap
|
demoSource: Boolean(window.__waterNetworkMap?.getSource("simulation-impact-area"))
|
||||||
?.querySourceFeatures("simulation-impact-area").length ?? 0
|
}))).toEqual({ backend: 0, demoSource: false });
|
||||||
}))).toEqual({ backend: 0, demo: 0 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps mock conditions while using backend work orders and alerts", async ({ page }) => {
|
test("keeps mock conditions while using backend work orders and alerts", async ({ page }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user