refactor: consolidate requests and finalize agent tool states
Share project JSON transport, interaction replies and report selectors. Settle tool progress consistently for direct streams, reconnects and history, preserving failure reasons and stopped state.
This commit is contained in:
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -837,6 +839,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 +850,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<
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */ }
|
||||||
@@ -681,9 +678,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 +729,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 +824,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 +874,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 +1042,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 +1136,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,
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user