feat(workbench): redesign investigation reports and diagrams

This commit is contained in:
2026-10-10 16:02:47 +08:00
parent 85a9a3ab45
commit ba78a24384
27 changed files with 2380 additions and 187 deletions
@@ -1283,6 +1283,10 @@ function BackendMessageList({
)}
>
<div className="space-y-3">
{message.role === "assistant" && message.taskContext ? <p className="text-xs leading-5 text-[var(--ink-muted)]">
依据:{message.taskContext.execution_generation == null ? "历史资料 · 轮次未记录" : `第 ${message.taskContext.execution_generation} 轮`}
{message.taskContext.report_revision == null ? " · 尚无报告" : ` · 报告第 ${message.taskContext.report_revision} 版`}
</p> : null}
{message.role === "assistant" && !qaOnly ? (
<AgentMessageProgress
progress={message.progress}
+1
View File
@@ -1,4 +1,5 @@
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
export { agentTaskDiscussionContextSchema, agentTaskContextReferenceSchema, type AgentTaskDiscussionContext, type AgentTaskContextReference } from "./task-context";
export { readExecutionReport, updateExecutionReportProgress, appendExecutionReportText, type AgentExecutionReport } from "./execution-report";
export { parseExecutionPlan, readExecutionPlanHistory, executionPlanStages, executionStatusLabels, type AgentExecutionPlan } from "./execution-plan";
export { AgentCommandPanel } from "./components/agent-command-panel";
+21
View File
@@ -0,0 +1,21 @@
import { z } from "zod";
export const agentTaskDiscussionContextSchema = z
.object({
report_revision: z.number().int().positive().optional(),
evidence_id: z.string().min(1).max(128).optional(),
gap_id: z.string().min(1).max(128).optional()
})
.strict();
export type AgentTaskDiscussionContext = z.infer<typeof agentTaskDiscussionContextSchema>;
export const agentTaskContextReferenceSchema = z
.object({
task_id: z.string().uuid(),
execution_generation: z.number().int().positive().nullable(),
report_revision: z.number().int().positive().nullable()
})
.strict();
export type AgentTaskContextReference = z.infer<typeof agentTaskContextReferenceSchema>;
+1
View File
@@ -6,6 +6,7 @@ export type AgentChatMessage = {
permissions?: AgentPermissionRequest[];
questions?: AgentQuestionRequest[];
todos?: AgentTodoUpdate;
taskContext?: import("./task-context").AgentTaskContextReference;
};
export type AgentStreamRenderMessageState = {
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { investigationReportSchema } from "./investigation-report";
const base = {
title: "调查报告",
current_conclusion: "无法确定原因",
conclusion_boundary: "仅有上游监测",
evidence_refs: ["query-1"],
recommendations: [],
pending_items: []
};
const result = {
...base,
stage: "final",
outcome: "insufficient_data",
stop_reason: "缺少采样",
data_gaps: [
{
gap_id: "g1",
node_id: "read",
missing_data: "末梢采样",
scope: "投诉小区,投诉时段",
impact: "无法确定异常来源",
required_data: "该时段采样结果",
evidence_refs: ["query-1"],
blocking: true
}
]
};
describe("investigation report read contract", () => {
it("preserves unknown historical facts instead of inventing a result or empty gap list", () => {
const legacy = investigationReportSchema.parse(base);
expect(legacy.outcome).toBeUndefined();
expect(legacy.data_gaps).toBeUndefined();
expect(
investigationReportSchema.parse({
...base,
stage: null,
outcome: null,
data_gaps: null,
stop_reason: null
}).data_gaps
).toBeNull();
});
it("accepts an evidenced final insufficient-data result", () => {
expect(investigationReportSchema.parse(result).outcome).toBe("insufficient_data");
});
it("reads a narrative diagram without requiring a legacy execution plan", () => {
const value = investigationReportSchema.parse({
...result,
data_gaps: [{ ...result.data_gaps[0], node_id: null }],
diagram: {
title: "调查脉络",
mermaid: 'flowchart TD\nA["查询"] --> B["判断"]',
node_refs: [{ node_id: "A", label: "查询", evidence_refs: ["query-1"] }]
}
});
expect(value.diagram?.node_refs[0]?.action_refs).toEqual([]);
expect(value.data_gaps?.[0]?.node_id).toBeNull();
});
it.each([
{ outcome: "other" },
{ stage: "interim" },
{ stop_reason: null },
{ data_gaps: [] },
{ recommendations: ["自动派单"] },
{ pending_items: ["等人工"] },
{ outcome: "concluded" },
{ data_gaps: [{ ...result.data_gaps[0], evidence_refs: [] }] },
{ data_gaps: [result.data_gaps[0], result.data_gaps[0]] },
{ schema: "invented" }
])("rejects inconsistent business facts %j", (changes) => {
expect(() => investigationReportSchema.parse({ ...result, ...changes })).toThrow();
});
});
@@ -0,0 +1,93 @@
import { z } from "zod";
import type { AgentTaskDiscussionContext } from "@/features/agent";
export type InvestigationDiscussion = { prompt?: string; context?: AgentTaskDiscussionContext };
const text = z.string().trim().min(1);
export const investigationDiagramSchema = z
.object({
title: text,
mermaid: text
.max(20000)
.refine(
(source) =>
/^\s*(?:flowchart|graph)\s+(?:TD|TB|BT|LR|RL)\b/.test(source) &&
!source.includes("```") &&
!source.includes("%%{") &&
!/(?:^|[;\n])\s*click\b/.test(source),
"调查图只接受不含指令或回调的 Mermaid 流程描述"
),
node_refs: z
.array(
z
.object({
node_id: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
label: text,
evidence_refs: z.array(text).default([]),
action_refs: z.array(z.string().uuid()).default([])
})
.strict()
)
.max(100)
.default([])
})
.strict()
.superRefine((diagram, ctx) => {
if (new Set(diagram.node_refs.map((node) => node.node_id)).size !== diagram.node_refs.length) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "调查图节点引用重复" });
}
});
const dataGapSchema = z
.object({
gap_id: text,
node_id: text.nullish(),
missing_data: text,
scope: text,
impact: text,
required_data: text,
evidence_refs: z.array(text).min(1),
blocking: z.boolean()
})
.strict();
/** Historical omissions remain unknown. Never infer outcomes from report prose. */
export const investigationReportSchema = z
.object({
title: text,
current_conclusion: text,
conclusion_boundary: text,
evidence_refs: z.array(text),
recommendations: z.array(text),
pending_items: z.array(text),
stage: z.enum(["interim", "final"]).nullish(),
outcome: z.enum(["concluded", "insufficient_data"]).nullish(),
stop_reason: text.nullish(),
data_gaps: z.array(dataGapSchema).nullish(),
diagram: investigationDiagramSchema.nullish()
})
.strict()
.superRefine((report, ctx) => {
const invalid = (message: string) => ctx.addIssue({ code: z.ZodIssueCode.custom, message });
if (report.outcome == null) {
if (report.stage != null || report.stop_reason != null || report.data_gaps != null)
invalid("报告结构化字段必须一起提供");
return;
}
if (report.stage == null || report.data_gaps == null) {
invalid("报告缺少阶段或数据缺口清单");
return;
}
if (new Set(report.data_gaps.map((gap) => gap.gap_id)).size !== report.data_gaps.length)
invalid("数据缺口编号重复");
const blocking = report.data_gaps.some((gap) => gap.blocking);
if (report.outcome === "insufficient_data") {
if (report.stage !== "final" || !report.stop_reason || !blocking)
invalid("证据不足必须提供最终报告、结束原因和关键缺口");
if (report.recommendations.length || report.pending_items.length)
invalid("证据不足退出后不再生成后续处置建议或待办");
} else if (report.stop_reason != null || blocking)
invalid("已形成结论不能包含结束原因或阻塞缺口");
});
export type InvestigationReport = z.infer<typeof investigationReportSchema>;
export type InvestigationDiagram = z.infer<typeof investigationDiagramSchema>;
@@ -1,4 +1,5 @@
import { z } from "zod";
import { investigationReportSchema } from "./investigation-report";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
@@ -105,14 +106,7 @@ const planSnapshotSchema = z.object({
published_by: z.string().min(1),
plan: planInputSchema
}).strict();
const reportInputSchema = z.object({
title: z.string().min(1),
current_conclusion: z.string().min(1),
conclusion_boundary: z.string().min(1),
evidence_refs: z.array(z.string().min(1)),
recommendations: z.array(z.string().min(1)),
pending_items: z.array(z.string().min(1))
}).strict();
const reportInputSchema = investigationReportSchema;
const reportRevisionSchema = z.object({
revision: z.number().int().positive(),
execution_generation: z.number().int().positive().nullish(),
@@ -121,6 +115,10 @@ const reportRevisionSchema = z.object({
published_by: z.string().min(1),
report: reportInputSchema
}).strict();
const reportPageSchema = z.object({
items: z.array(reportRevisionSchema),
next_offset: z.number().int().nonnegative().nullable(),
}).strict();
const actionReceiptSchema = z.object({
adapter: z.string().min(1),
receipt_id: z.string().min(1),
@@ -240,6 +238,7 @@ const notificationSchema = z.object({
lifecycle_status: z.enum(["active", "resolved"]).default("active"),
subject_type: z.enum(["task", "run", "access_request", "action"]).default("task"),
subject_id: z.string().uuid().nullable().default(null),
report_reference: z.object({ execution_generation: z.number().int().positive(), revision: z.number().int().positive() }).strict().nullish(),
resolved_at: workbenchDateTimeSchema.nullable().default(null),
resolution_reason: z.string().min(1).nullable().default(null),
status: z.enum(["unread", "read"]),
@@ -285,6 +284,8 @@ export type OperationalTaskAction = z.infer<typeof actionSchema>;
export type OperationalTaskAccessRequest = z.infer<typeof accessRequestSchema>;
export type OperationalTaskEvidence = z.infer<typeof evidenceSchema>;
export type OperationalTaskNotification = z.infer<typeof notificationSchema>;
export type OperationalTaskReport = z.infer<typeof reportRevisionSchema>;
export type OperationalTaskReportPage = z.infer<typeof reportPageSchema>;
export type OperationalTaskSchedule = z.infer<typeof scheduleSchema>;
export type OperationalTaskDataSnapshot = z.infer<typeof taskDataSnapshotSchema>;
export type ExternalWorkOrderSnapshot = z.infer<typeof externalWorkOrderSnapshotSchema>;
@@ -335,6 +336,12 @@ export async function fetchOperationalTask(taskId: string, getAccessToken?: Acce
));
}
export async function fetchOperationalTaskReports(taskId: string, offset: number, getAccessToken?: AccessTokenProvider) {
return reportPageSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/reports?limit=20&offset=${offset}`, getAccessToken,
));
}
export async function createOperationalTask(input: CreateOperationalTaskInput, getAccessToken?: AccessTokenProvider) {
return operationalTaskSchema.parse(await operationalTaskRequest(
"/operational-tasks",
@@ -96,6 +96,23 @@ const taskBase = {
work_order_snapshot: null
} as OperationalTask;
it("leads with the insufficient-data report and keeps previous rounds historical", () => {
const task: OperationalTask = { ...taskBase, status: "completed", lifecycle_status: "completed", actions: [],
latest_report: { ...taskBase.latest_report!, report: { ...taskBase.latest_report!.report,
stage: "final", outcome: "insufficient_data", stop_reason: "缺少末梢采样", recommendations: [], pending_items: [],
data_gaps: [{ gap_id: "gap-1", node_id: "sampling", missing_data: "末梢采样", scope: "投诉时段",
impact: "无法判断异常来源", required_data: "末梢采样结果", evidence_refs: ["e-1"], blocking: true }] } }
};
const { rerender, ...props } = taskPanel(task);
expect(screen.getByText("本轮已结束 · 无法形成结论")).toBeVisible();
expect(screen.getByLabelText("数据缺口")).toHaveTextContent("无法判断异常来源");
expect(screen.queryByText("待确认事项")).not.toBeInTheDocument();
expect(screen.queryByText("确认并创建预约工单")).not.toBeInTheDocument();
rerender(<AgentExecutionPlanPanel {...props} task={{ ...task, execution_generation: 2, status: "running", lifecycle_status: "open" }} />);
expect(screen.queryByText("本轮已结束 · 无法形成结论")).not.toBeInTheDocument();
expect(screen.getByText("上一轮报告,本轮分析中", { exact: true })).toBeVisible();
});
function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<typeof AgentExecutionPlanPanel>> = {}) {
const props = {
task,
@@ -108,6 +125,8 @@ function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<type
...overrides
};
const { rerender } = render(<AgentExecutionPlanPanel {...props} />);
const summary = screen.queryByLabelText("当前需处理")?.querySelector("summary");
if (summary) fireEvent.click(summary);
return { ...props, rerender };
}
@@ -158,26 +177,87 @@ describe("operational task approval and evidence", () => {
expect(container.querySelectorAll('[data-execution-node="evidence"]')).toHaveLength(1);
expect(container.querySelectorAll('[data-execution-node="result"]')).toHaveLength(1);
expect(container.querySelectorAll("[data-execution-node]")).toHaveLength(plan.nodes.length);
expect(screen.getByText("汇合前置:upstream(已完成)、network(已完成)")).toBeVisible();
});
it("keeps route alternatives beside the decision and expands node evidence and approvals", () => {
const actionId = taskBase.actions[0].action_id;
const plan = {
nodes: [
{ id: "risk", title: "判断风险", detail: "按投诉核对", owner: "agent" as const, status: "completed" as const, kind: "decision" as const, selected_edge_id: "urgent" },
{ id: "urgent", title: "现场采样", detail: "采样完成", owner: "human" as const, status: "completed" as const, kind: "activity" as const, evidence_refs: ["e-1"] },
{ id: "routine", title: "常规核查", detail: "未采用", owner: "human" as const, status: "skipped" as const, kind: "activity" as const },
{ id: "recheck", title: "再次采样", detail: "需复核", owner: "human" as const, status: "waiting" as const, kind: "activity" as const, revisits_node_id: "urgent" },
{ id: "approve", title: "确认工单", detail: "等待确认", owner: "human" as const, status: "waiting" as const, kind: "gate" as const, gate_ref: actionId, action_refs: [actionId] }
],
edges: [
{ id: "urgent", from: "risk", to: "urgent", condition: "有紧急风险" },
{ id: "routine", from: "risk", to: "routine", condition: "无紧急风险" },
{ id: "next", from: "urgent", to: "recheck" },
{ id: "approval", from: "recheck", to: "approve" }
]
};
const { rerender } = render(<ExecutionPlanSteps plan={plan} context={taskBase} onLoadEvidenceSnapshot={vi.fn()} />);
expect(screen.getByText("其他规划路线 1 项")).toBeVisible();
fireEvent.click(screen.getByText("其他规划路线 1 项"));
expect(screen.getByRole("region", { name: "规划路线 无紧急风险" })).toContainElement(screen.getByText("常规核查"));
fireEvent.click(screen.getByText("查看关联详情 · 1 条证据"));
expect(screen.getByText("上游余氯处于正常范围")).toBeVisible();
fireEvent.click(screen.getAllByText("查看关联详情")[0]);
expect(screen.getByText("再次核验:现场采样")).toBeVisible();
fireEvent.click(screen.getAllByText("查看关联详情")[1]);
expect(screen.getByText(/审核关联:动作待确认,需要现场采样/)).toBeVisible();
expect(screen.getByText("需要现场采样 · 待确认 · 未提交")).toBeVisible();
rerender(<ExecutionPlanSteps plan={plan} context={{ evidence: [], access_requests: [], actions: [] }} onLoadEvidenceSnapshot={vi.fn()} />);
expect(screen.getByText("证据 e-1 不可用")).toBeVisible();
expect(screen.getByText("审核关联:关联记录不可用")).toBeVisible();
expect(screen.getByText(`动作 ${actionId} 不可用`)).toBeVisible();
});
it("shows both planned routes while a decision is pending", () => {
const plan = {
nodes: [
{ id: "risk", title: "判断风险", detail: "等待资料", owner: "agent" as const, status: "waiting" as const, kind: "decision" as const, selected_edge_id: null },
{ id: "urgent", title: "紧急处置", detail: "待判断", owner: "human" as const, status: "pending" as const, kind: "activity" as const },
{ id: "routine", title: "常规核查", detail: "待判断", owner: "human" as const, status: "pending" as const, kind: "activity" as const }
],
edges: [
{ id: "urgent", from: "risk", to: "urgent", condition: "有紧急风险" },
{ id: "routine", from: "risk", to: "routine", condition: "无紧急风险" }
]
};
render(<ExecutionPlanSteps plan={plan} />);
expect(screen.getByText("待选路线 2 项")).toBeVisible();
fireEvent.click(screen.getByText("待选路线 2 项"));
expect(screen.getByText("紧急处置")).toBeVisible();
expect(screen.getByText("常规核查")).toBeVisible();
expect(screen.queryByText("未选择")).not.toBeInTheDocument();
});
it("lets a reviewer edit a work-order draft before one-click dispatch", async () => {
const onDraftUpdate = vi.fn(async () => undefined);
const onConfirmDispatch = vi.fn(async () => undefined);
const initialTask = { ...taskBase, permissions: { ...taskBase.permissions, can_decide_work_orders: true, can_execute_work_orders: true },
actions: [{ ...taskBase.actions[0], payload: { title: "旧草稿", description: "现场采样", work_order_type: "general", priority: "normal" } }] };
actions: [{ ...taskBase.actions[0], payload: { title: "旧草稿", description: "现场采样", work_order_type: "water_quality_complaint", priority: "normal" } }] };
const props = taskPanel(initialTask, { onDraftUpdate, onConfirmDispatch });
expect(screen.getByRole("heading", { name: "关联预约工单" })).toBeVisible();
expect(screen.getByText(/调查任务只保留创建关联/)).toBeVisible();
expect(screen.getByText("预约工单")).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "编辑草稿" }));
expect(screen.queryByRole("combobox", { name: "工单类型" })).not.toBeInTheDocument();
expect(screen.getByText("预约任务")).toBeVisible();
fireEvent.change(screen.getByRole("textbox", { name: "工单标题" }), { target: { value: "人工修订草稿" } });
fireEvent.click(screen.getByRole("button", { name: "保存草稿" }));
await waitFor(() => expect(onDraftUpdate).toHaveBeenCalledWith(taskBase.actions[0].action_id, 3, expect.objectContaining({ title: "人工修订草稿" })));
await waitFor(() => expect(screen.getByRole("button", { name: "确认并下发工单" })).toBeDisabled());
await waitFor(() => expect(onDraftUpdate).toHaveBeenCalledWith(taskBase.actions[0].action_id, 3, expect.objectContaining({ title: "人工修订草稿", work_order_type: "general" })));
await waitFor(() => expect(screen.getByRole("button", { name: "确认并创建预约工单" })).toBeDisabled());
props.rerender(<AgentExecutionPlanPanel {...props} task={{ ...initialTask, actions: [{ ...initialTask.actions[0], version: 4, payload: { ...initialTask.actions[0].payload, title: "人工修订草稿" } }] }} />);
fireEvent.click(screen.getByRole("button", { name: "确认并下发工单" }));
fireEvent.click(screen.getByRole("button", { name: "确认并创建预约工单" }));
await waitFor(() => expect(onConfirmDispatch).toHaveBeenCalledWith(taskBase.actions[0].action_id, 4));
});
it("distinguishes the current report from a previous execution without exposing generation numbers", () => {
it("distinguishes the selected report from the current investigation round", () => {
taskPanel(taskBase);
expect(screen.getByText("当前结论", { exact: true })).toBeVisible();
expect(screen.getByText("历史报告内容", { exact: true })).toBeVisible();
expect(screen.getByText("结果未记录", { exact: true })).toBeVisible();
const previousReport = {
...taskBase,
@@ -194,29 +274,31 @@ describe("operational task approval and evidence", () => {
expect(screen.queryByText(/第\s*[12]\s*代|generation/i)).not.toBeInTheDocument();
rerender(<AgentExecutionPlanPanel
task={{ ...previousReport, status: "failed" }}
task={{ ...previousReport, status: "failed", lifecycle_status: "failed" }}
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
/>);
expect(screen.getByText("上一轮报告,当前任务失败", { exact: true })).toBeVisible();
expect(screen.getByText("本轮执行失败", { exact: true })).toBeVisible();
expect(screen.getByText("上一轮报告", { exact: true })).toBeVisible();
expect(screen.queryByText("上一轮报告,本轮分析中", { exact: true })).not.toBeInTheDocument();
rerender(<AgentExecutionPlanPanel
task={{ ...previousReport, status: "cancelled" }}
task={{ ...previousReport, status: "cancelled", lifecycle_status: "cancelled" }}
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
/>);
expect(screen.getByText("上一轮报告,当前任务已取消", { exact: true })).toBeVisible();
expect(screen.getByText("本轮已取消", { exact: true })).toBeVisible();
rerender(<AgentExecutionPlanPanel
task={{ ...previousReport, latest_report: { ...previousReport.latest_report!, execution_generation: null } }}
task={{ ...previousReport, latest_report: { ...previousReport.latest_report!, revision: 2, execution_generation: null } }}
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
/>);
expect(screen.getByText("历史报告", { exact: true })).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "查看新报告" }));
expect(screen.getByText(/正在阅读:历史报告/)).toBeVisible();
});
it("labels current, previous and legacy execution plans without presenting old progress as current", () => {
it("keeps plan nodes as explanatory relationships without presenting their statuses as task progress", () => {
const latestPlan: NonNullable<OperationalTask["latest_plan"]> = {
revision: 1,
execution_generation: 1,
@@ -245,26 +327,27 @@ describe("operational task approval and evidence", () => {
onRefresh: vi.fn(), onLoadEvidenceSnapshot: vi.fn()
};
const { rerender } = render(<AgentExecutionPlanPanel task={currentPlanTask} {...props} />);
expect(screen.getByText("已完成 1 项 · 执行中 0 项 · 待人工或资料 0 项", { exact: true })).toBeVisible();
expect(screen.getByRole("heading", { name: "调查脉络" })).toBeVisible();
expect(screen.queryByText(/已完成 1 项/)).not.toBeInTheDocument();
rerender(<AgentExecutionPlanPanel
task={{ ...currentPlanTask, status: "running", execution_generation: 2 }}
{...props}
/>);
expect(screen.getByText("上一轮流程,本轮规划中", { exact: true })).toBeVisible();
expect(screen.getByText("上一轮报告,本轮分析中", { exact: true })).toBeVisible();
expect(screen.queryByText(/已完成 1 项/)).not.toBeInTheDocument();
rerender(<AgentExecutionPlanPanel
task={{ ...currentPlanTask, status: "failed", execution_generation: 2 }}
{...props}
/>);
expect(screen.getByText("上一轮流程,当前任务失败", { exact: true })).toBeVisible();
expect(screen.getByText("上一轮报告", { exact: true })).toBeVisible();
rerender(<AgentExecutionPlanPanel
task={{ ...currentPlanTask, latest_plan: { ...latestPlan, execution_generation: null } }}
{...props}
/>);
expect(screen.getByText("历史流程", { exact: true })).toBeVisible();
expect(screen.getByRole("heading", { name: "调查脉络" })).toBeVisible();
expect(screen.queryByText(/generation|第\s*\d+\s*代/i)).not.toBeInTheDocument();
});
@@ -279,8 +362,8 @@ describe("operational task approval and evidence", () => {
expect(screen.getByText(/上游余氯处于正常范围 · SCADA 水质监测/)).toBeVisible();
expect(screen.getByText(/当前账号仅可查看/)).toBeVisible();
expect(screen.queryByText(/operational_action\.work_order\.approve/)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "批准创建工单" })).not.toBeInTheDocument();
fireEvent.click(screen.getByText("上游余氯处于正常范围"));
expect(screen.queryByRole("button", { name: "批准创建预约工单" })).not.toBeInTheDocument();
fireEvent.click(screen.getByLabelText("主要依据").querySelector("summary")!);
expect(screen.getByText("SCADA 水质监测")).toBeVisible();
expect(screen.getByText("系统数据")).toBeVisible();
});
@@ -288,7 +371,7 @@ describe("operational task approval and evidence", () => {
it("keeps pending state after a decision network error", async () => {
const onDecision = vi.fn(async () => { throw new Error("network unavailable"); });
taskPanel({ ...taskBase, permissions: { ...taskBase.permissions, can_decide_work_orders: true } }, { onDecision });
fireEvent.click(screen.getByRole("button", { name: "批准创建工单" }));
fireEvent.click(screen.getByRole("button", { name: "批准创建预约工单" }));
expect(await screen.findByRole("alert")).toHaveTextContent("当前状态未按成功处理");
expect(screen.getByText("待确认")).toBeVisible();
});
@@ -297,7 +380,7 @@ describe("operational task approval and evidence", () => {
const onRefresh = vi.fn();
const onDecision = vi.fn(async () => { throw new Error("409 version conflict"); });
taskPanel({ ...taskBase, permissions: { ...taskBase.permissions, can_decide_work_orders: true } }, { onDecision, onRefresh });
fireEvent.click(screen.getByRole("button", { name: "批准创建工单" }));
fireEvent.click(screen.getByRole("button", { name: "批准创建预约工单" }));
const refresh = await screen.findByRole("button", { name: "刷新后重新确认" });
fireEvent.click(refresh);
await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce());
@@ -328,6 +411,7 @@ describe("operational task approval and evidence", () => {
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={onReconcile}
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
/>);
fireEvent.click(screen.getByText("现场采样 · 待核对回执"));
fireEvent.click(screen.getByRole("button", { name: "核对执行回执" }));
await waitFor(() => expect(onReconcile).toHaveBeenCalledWith(unknownAction.action_id, unknownAction.version));
@@ -336,7 +420,43 @@ describe("operational task approval and evidence", () => {
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={onReconcile}
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
/>);
expect(screen.getByRole("region", { name: "执行回执" })).toHaveTextContent("receipt-20260923-01");
expect(screen.getByRole("region", { name: "执行回执" })).toHaveTextContent("外围工单创建完成");
expect(screen.getByLabelText("处置进展")).toHaveTextContent("工单已创建");
expect(screen.getByLabelText("处置进展")).toHaveTextContent("动作回执只说明对应系统操作结果");
fireEvent.click(screen.getByText("现场采样 · 工单已创建"));
expect(screen.getByRole("region", { name: "预约工单创建回执" })).toHaveTextContent("receipt-20260923-01");
expect(screen.getByRole("region", { name: "预约工单创建回执" })).toHaveTextContent("外围工单创建完成");
});
it("orders evidence, report, approval, and receipt in one real timeline", () => {
const completedAction = {
...taskBase.actions[0],
approval_status: "approved" as const,
execution_status: "succeeded" as const,
decided_at: "2026-09-23T01:11:00Z",
executed_at: "2026-09-23T01:12:00Z",
receipt: {
adapter: "peripheral",
receipt_id: "receipt-timeline",
submitted_at: "2026-09-23T01:11:30Z",
completed_at: "2026-09-23T01:12:00Z",
external_status: "open",
detail: "预约工单已创建,等待现场处理"
}
};
taskPanel({
...taskBase,
latest_report: { ...taskBase.latest_report!, published_at: "2026-09-23T01:10:00Z" },
actions: [completedAction]
});
fireEvent.click(screen.getByText("任务时间线"));
const items = [...screen.getByLabelText("任务时间线记录").querySelectorAll("li")];
expect(items.map((item) => item.textContent)).toEqual([
expect.stringContaining("预约工单已创建,等待现场处理"),
expect.stringContaining("现场采样已批准"),
expect.stringContaining("报告第 1 版已发布"),
expect.stringContaining("现场采样已提出"),
expect.stringContaining("上游余氯处于正常范围")
]);
});
});
@@ -1,9 +1,12 @@
import { InvestigationReportView } from "./investigation-report-view";
import { InvestigationTaskReport } from "./investigation-task-report";
import type { InvestigationDiscussion } from "../api/investigation-report";
import type { OperationalTaskReportPage } from "../api/operational-task-client";
import { useMemo, useState, type ReactNode } from "react";
import {
AlertTriangle,
CheckCircle2,
Clock3,
FileText,
GitFork,
Loader2,
MessageSquare,
@@ -38,8 +41,12 @@ type ExecutionNode = {
selected_edge_id?: string | null;
gate_ref?: string | null;
revisits_node_id?: string | null;
evidence_refs?: string[];
action_refs?: string[];
};
type ExecutionFlowContext = Pick<OperationalTask, "evidence" | "access_requests" | "actions">;
type ExecutionEdge = {
id?: string | null;
from: string;
@@ -55,7 +62,7 @@ export type ExecutionPlanView = {
type AgentExecutionPlanPanelProps = {
task: OperationalTask;
onContinue: () => void;
onContinue: (discussion?: InvestigationDiscussion) => void;
onDecision: (actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onExecute: (actionId: string, expectedVersion: number) => Promise<unknown>;
onDraftUpdate?: (actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
@@ -64,6 +71,9 @@ type AgentExecutionPlanPanelProps = {
onReconcile: (actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadEvidenceSnapshot: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onRefresh: () => void;
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
onReportViewed?: (generation: number, revision: number) => void;
focusActionId?: string | null;
};
export function AgentExecutionPlanPanel({
@@ -76,8 +86,17 @@ export function AgentExecutionPlanPanel({
onAccessDecision,
onReconcile,
onLoadEvidenceSnapshot,
onRefresh
onRefresh,
onLoadReports,
onReportViewed,
focusActionId
}: AgentExecutionPlanPanelProps) {
if (task.task_type === "event_investigation") return <InvestigationTaskReport key={task.task_id} task={task}
onContinue={onContinue} onLoadReports={onLoadReports} onReportViewed={onReportViewed} focusActionId={focusActionId}
renderAction={(action) => <HumanActionCard key={action.action_id} task={task} action={action} onDecision={onDecision}
onExecute={onExecute} onDraftUpdate={onDraftUpdate} onConfirmDispatch={onConfirmDispatch} onReconcile={onReconcile} onRefresh={onRefresh} />}
renderEvidence={(item) => <EvidenceDisclosure key={item.evidence_id} item={item} onLoadSnapshot={onLoadEvidenceSnapshot} />}
/>;
const plan = task.latest_plan?.plan;
const report = task.latest_report?.report;
const planGeneration = task.latest_plan?.execution_generation;
@@ -92,9 +111,19 @@ export function AgentExecutionPlanPanel({
: reportGeneration === task.execution_generation
? "current"
: "previous";
const completed = plan?.nodes.filter((node) => node.status === "completed").length ?? 0;
const active = plan?.nodes.filter((node) => node.status === "running").length ?? 0;
const waiting = plan?.nodes.filter((node) => node.status === "waiting" || node.owner === "human" && node.status === "pending").length ?? 0;
const visiblePlanNodeIds = plan && plan.schema === "execution-plan@2"
? activePlanGraph(plan).visible : null;
const visiblePlanNodes = plan && visiblePlanNodeIds
? plan.nodes.filter((node) => visiblePlanNodeIds.has(node.id))
: plan?.nodes ?? [];
const completed = visiblePlanNodes.filter((node) => node.status === "completed").length;
const active = visiblePlanNodes.filter((node) => node.status === "running").length;
const waiting = visiblePlanNodes.filter((node) => node.status === "waiting" || node.owner === "human" && node.status === "pending").length;
const selectedRoutes = visiblePlanNodes.filter((node) => node.kind === "decision" && node.selected_edge_id).map((node) => {
const edge = plan?.edges.find((item) => item.id === node.selected_edge_id && item.from === node.id);
return edge ? `${node.title}:${edge.condition}` : null;
}).filter((item): item is string => Boolean(item));
const waitingNodes = visiblePlanNodes.filter((node) => node.status === "waiting" || node.owner === "human" && node.status === "pending").map((node) => node.title);
const externalEvidence = task.evidence.filter((item) => item.kind === "external_feedback");
const taskActive = task.status === "queued" || task.status === "running";
const planSummary = !plan
@@ -123,6 +152,7 @@ export function AgentExecutionPlanPanel({
return (
<section aria-label="工况任务处理" className="min-w-0 space-y-2.5">
<InvestigationReportView task={task} reportLabel={reportLabel} />
<section className="surface-reading overflow-hidden rounded-xl border border-[var(--surface-divider)]/70">
<div className="space-y-2 border-b border-[var(--surface-divider)]/70 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
@@ -130,8 +160,8 @@ export function AgentExecutionPlanPanel({
<Route size={13} aria-hidden="true" />
{task.task_type === "external_work_order" ? "外部工单进度" : "调查处理流程"}
</h3>
<StatusBadge tone={task.status === "failed" ? "danger" : task.status === "waiting_human" ? "warning" : task.status === "completed" ? "success" : "info"}>
{taskStatusLabel(task.status)}
<StatusBadge tone={task.status === "failed" ? "danger" : task.status === "waiting_human" ? "warning" : task.status === "completed" ? report?.outcome === "insufficient_data" && reportState === "current" ? "warning" : "success" : "info"}>
{task.lifecycle_status === "completed" && reportState === "current" && report?.outcome === "insufficient_data" ? "已结束 · 证据不足" : taskStatusLabel(task.status)}
</StatusBadge>
</div>
<p className="text-xs leading-5 text-slate-600" role="status">
@@ -144,7 +174,15 @@ export function AgentExecutionPlanPanel({
{task.task_type === "external_work_order" && externalEvidence.length > 0 ? (
<ExternalProgress evidence={externalEvidence} />
) : plan ? (
<ExecutionPlanSteps plan={plan} nodeActions={nodeActions} />
<>
<div className="border-b border-[var(--surface-divider)]/70 px-3 py-2.5 text-xs leading-5 text-slate-700">
<p className="font-semibold text-slate-900">{plan.title} <span className="font-normal text-[var(--ink-muted)]">· 第 {task.latest_plan?.revision} 版</span></p>
<p className="mt-0.5 text-slate-600">规划依据:{plan.reason}</p>
{plan.schema === "execution-plan@2" ? <p className="mt-0.5 text-slate-600">{selectedRoutes.length ? `实际路线:${selectedRoutes.join(";")}` : plan.nodes.some((node) => node.kind === "decision") ? "实际路线:待判断" : "实际路线:按计划执行"}</p> : null}
{waitingNodes.length > 0 ? <p className="mt-0.5 text-[var(--status-warning-foreground)]">等待:{waitingNodes.join("、")}</p> : null}
</div>
<ExecutionPlanSteps plan={plan} nodeActions={nodeActions} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} />
</>
) : (
<p className="p-3 text-xs leading-5 text-[var(--ink-muted)]">尚无执行节点,页面不会按时间推算或虚构完成进度。</p>
)}
@@ -156,38 +194,6 @@ export function AgentExecutionPlanPanel({
canDecide={task.permissions.can_decide_access} onDecision={onAccessDecision} onRefresh={onRefresh} />)}
</section> : null}
<section aria-label="任务报告" className="surface-reading space-y-3 rounded-xl border border-slate-200/70 p-3">
<div>
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-800"><FileText size={13} aria-hidden="true" />任务报告</h3>
<p className="mt-1 text-[11px] leading-5 text-slate-500">结论与证据独立呈现,不展示模型推理或节点执行日志。</p>
</div>
{report ? (
<>
<div className={cn(
"rounded-lg px-3 py-2.5",
reportState === "current" ? "bg-blue-50/70" : "bg-amber-50/80"
)}>
<p className={cn(
"text-[11px] font-semibold",
reportState === "current" ? "text-blue-700" : "text-amber-800"
)}>
{reportLabel}
</p>
<p className="mt-1 text-xs leading-6 text-slate-800">{report.current_conclusion}</p>
</div>
{report.recommendations.length > 0 ? (
<ReportList title="处置建议" items={report.recommendations} tone="blue" />
) : null}
{report.pending_items.length > 0 ? (
<ReportList title="待确认事项" items={report.pending_items} tone="amber" />
) : null}
<p className="border-t border-slate-200/70 pt-2 text-[11px] leading-5 text-slate-500">结论边界:{report.conclusion_boundary}</p>
</>
) : (
<p className="text-xs leading-6 text-slate-600">尚未形成任务报告。</p>
)}
</section>
<EvidenceSection evidence={task.evidence} referencedIds={report?.evidence_refs ?? []} onLoadSnapshot={onLoadEvidenceSnapshot} />
{unlinkedActions.length > 0 ? (
@@ -209,7 +215,7 @@ export function AgentExecutionPlanPanel({
) : null}
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={onContinue}>
<Button variant="outline" size="sm" onClick={() => onContinue()}>
<MessageSquare size={14} aria-hidden="true" />Agent 协作
</Button>
</div>
@@ -217,60 +223,58 @@ export function AgentExecutionPlanPanel({
);
}
function ReportList({ title, items, tone }: { title: string; items: string[]; tone: "blue" | "amber" }) {
return (
<div>
<p className="text-[11px] font-semibold text-slate-600">{title}</p>
<ul className="mt-1.5 grid gap-1.5">
{items.map((item) => (
<li key={item} className="grid grid-cols-[6px_1fr] gap-2 text-xs leading-5 text-slate-700">
<span className={cn("mt-2 h-1.5 w-1.5 rounded-full", tone === "blue" ? "bg-blue-500" : "bg-amber-400")} aria-hidden="true" />
<span>{item}</span>
</li>
))}
</ul>
</div>
);
}
export function ExecutionPlanSteps({ plan, nodeActions = {} }: { plan: ExecutionPlanView | AgentExecutionPlan | OperationalTaskPlan; nodeActions?: Record<string, ReactNode> }) {
export function ExecutionPlanSteps({ plan, nodeActions = {}, context, onLoadEvidenceSnapshot }: {
plan: ExecutionPlanView | AgentExecutionPlan | OperationalTaskPlan;
nodeActions?: Record<string, ReactNode>;
context?: ExecutionFlowContext;
onLoadEvidenceSnapshot?: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
}) {
return <div aria-label="调查执行步骤" className="px-3 py-3">{
plan.nodes.some((node) => node.kind)
? <SemanticExecutionFlow plan={plan} nodeActions={nodeActions} />
? <SemanticExecutionFlow plan={plan} nodeActions={nodeActions} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} />
: <ExactDependencyList plan={plan} nodeActions={nodeActions} />
}</div>;
}
function SemanticExecutionFlow({ plan, nodeActions }: { plan: ExecutionPlanView; nodeActions: Record<string, ReactNode> }) {
type SemanticFlowProps = {
plan: ExecutionPlanView;
nodeActions: Record<string, ReactNode>;
context?: ExecutionFlowContext;
onLoadEvidenceSnapshot?: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
};
function SemanticExecutionFlow({ plan, nodeActions, context, onLoadEvidenceSnapshot }: SemanticFlowProps) {
const nodeById = new Map(plan.nodes.map((node) => [node.id, node]));
const { activeEdges, visible } = activePlanGraph(plan);
const roots = plan.nodes.filter((node) => !plan.edges.some((edge) => edge.to === node.id));
return <div className="space-y-2">
<div className="space-y-2 border-l-2 border-[var(--surface-divider)] pl-3">
{roots.filter((node) => visible.has(node.id)).map((node) => <SemanticPath key={node.id} nodeId={node.id} stopId={null} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={new Set()} visible={visible} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} />)}
</div>
</div>;
}
function activePlanGraph(plan: ExecutionPlanView) {
const nodeById = new Map(plan.nodes.map((node) => [node.id, node]));
const activeEdges = plan.edges.filter((edge) => {
const source = nodeById.get(edge.from);
return source?.kind !== "decision" || source.selected_edge_id === edge.id;
});
const roots = plan.nodes.filter((node) => !plan.edges.some((edge) => edge.to === node.id));
const visible = new Set<string>();
const queue = roots.map((node) => node.id);
const queue = plan.nodes.filter((node) => !plan.edges.some((edge) => edge.to === node.id)).map((node) => node.id);
while (queue.length > 0) {
const current = queue.shift()!;
if (visible.has(current)) continue;
visible.add(current);
for (const edge of activeEdges.filter((item) => item.from === current)) queue.push(edge.to);
}
const ordered = topologicalNodes(plan);
const alternatives = ordered.filter((node) => !visible.has(node.id));
return <div className="space-y-3">
<div className="space-y-2 border-l border-slate-200 pl-3">
{roots.filter((node) => visible.has(node.id)).map((node) => <SemanticPath key={node.id} nodeId={node.id} stopId={null} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={new Set()} />)}
</div>
{alternatives.length > 0 ? <Disclosure label={`其他规划路线 ${alternatives.length} 项`} className="surface-control rounded-lg border border-slate-200/70 px-2.5">
<ol className="grid gap-2 border-t border-slate-200/70 py-2">{alternatives.map((node) => <li key={node.id}><ExecutionNodeCard node={node} action={nodeActions[node.id]} compact /></li>)}</ol>
</Disclosure> : null}
</div>;
return { activeEdges, visible };
}
function SemanticPath({ nodeId, stopId, plan, activeEdges, nodeById, nodeActions, seen }: {
function SemanticPath({ nodeId, stopId, plan, activeEdges, nodeById, nodeActions, seen, visible, context, onLoadEvidenceSnapshot }: {
nodeId: string; stopId: string | null; plan: ExecutionPlanView; activeEdges: ExecutionEdge[];
nodeById: Map<string, ExecutionNode>; nodeActions: Record<string, ReactNode>; seen: Set<string>;
nodeById: Map<string, ExecutionNode>; nodeActions: Record<string, ReactNode>; seen: Set<string>; visible: Set<string>;
context?: ExecutionFlowContext; onLoadEvidenceSnapshot?: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
}) {
if (nodeId === stopId || seen.has(nodeId)) return null;
const node = nodeById.get(nodeId);
@@ -280,20 +284,36 @@ function SemanticPath({ nodeId, stopId, plan, activeEdges, nodeById, nodeActions
const allRoutes = plan.edges.filter((edge) => edge.from === nodeId);
const join = node.kind === "parallel_split" ? findSemanticJoin(outgoing, activeEdges, nodeById, stopId) : null;
const branchEnd = join?.id ?? stopId;
const selectedRoute = node.kind === "decision" ? allRoutes.find((edge) => edge.id === node.selected_edge_id) : null;
const unchosenRoutes = node.kind === "decision" ? allRoutes.filter((edge) => edge.id !== node.selected_edge_id) : [];
const claimed = new Set<string>();
const alternativeGroups = unchosenRoutes.map((edge) => {
const reachable = reachableFrom(edge.to, new Map(plan.nodes.map((item) => [item.id, plan.edges.filter((route) => route.from === item.id)])));
const nodes = topologicalNodes(plan).filter((item) => reachable.has(item.id) && !visible.has(item.id) && !claimed.has(item.id));
nodes.forEach((item) => claimed.add(item.id));
return { edge, nodes };
});
const predecessors = node.kind === "parallel_join" ? plan.edges.filter((edge) => edge.to === node.id).map((edge) => nodeById.get(edge.from)).filter((item): item is ExecutionNode => Boolean(item)) : [];
return <div className="space-y-2">
<ExecutionNodeCard node={node} action={nodeActions[node.id]} />
<ExecutionNodeCard node={node} action={nodeActions[node.id]} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} nodeById={nodeById} />
{node.kind === "decision" ? <div className="rounded-lg bg-[var(--action-soft)]/70 px-3 py-2 text-xs leading-5 text-slate-700">
<span className="font-semibold">路线选择:</span>{allRoutes.find((edge) => edge.id === node.selected_edge_id)?.condition ?? allRoutes.find((edge) => edge.id === node.selected_edge_id)?.label ?? "等待判断"}
<div className="mt-1 text-[var(--ink-muted)]">{allRoutes.map((edge) => `${edge.condition ?? edge.label ?? "未命名路线"}${edge.id === node.selected_edge_id ? "(已选择)" : ""}`).join(" · ")}</div>
<span className="font-semibold">路线选择:</span>{selectedRoute?.condition ?? selectedRoute?.label ?? "等待判断"}
{alternativeGroups.length > 0 ? <Disclosure label={`${selectedRoute ? "其他规划路线" : "待选路线"} ${alternativeGroups.length} 项`} className="mt-1 border-t border-[var(--surface-border)]">
<div className="grid gap-2 border-t border-[var(--surface-border)] py-2">{alternativeGroups.map(({ edge, nodes }) => <section key={edge.id ?? edge.to} className="rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] p-2" aria-label={`规划路线 ${edge.condition ?? edge.label ?? edge.to}`}>
<p className="font-semibold text-slate-700">{edge.condition ?? edge.label ?? "未命名路线"} <span className="font-normal text-[var(--ink-muted)]">· {selectedRoute ? "未选择" : "待判断"}</span></p>
{nodes.length > 0 ? <ol className="mt-1.5 grid gap-1.5">{nodes.map((item) => <li key={item.id}><ExecutionNodeCard node={item} action={nodeActions[item.id]} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} nodeById={nodeById} compact /></li>)}</ol> : <p className="mt-1 text-[var(--ink-muted)]">此路线在后续节点汇合。</p>}
</section>)}</div>
</Disclosure> : null}
</div> : null}
{predecessors.length > 0 ? <p className="rounded-lg bg-[var(--surface-reading)] px-3 py-2 text-[11px] leading-5 text-slate-600">汇合前置:{predecessors.map((item) => `${item.title}(${executionNodePresentation(item).label})`).join("、")}</p> : null}
{node.kind === "parallel_split" ? <section aria-label={`并行处理 ${outgoing.length} 路`} className="min-w-0 rounded-xl border border-[var(--surface-border)] bg-[var(--action-soft)]/40 p-2">
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-[var(--action-blue-pressed)]"><GitFork size={13} aria-hidden="true" />并行处理 · {outgoing.length} 路</p>
<div className="grid gap-2 sm:grid-cols-2">{outgoing.map((edge, index) => <div key={edge.id ?? edge.to} className="min-w-0 rounded-lg border border-[var(--surface-divider)]/70 bg-[var(--surface-reading)] p-2">
<div className="grid gap-2">{outgoing.map((edge, index) => <div key={edge.id ?? edge.to} className="min-w-0 border-l-2 border-[var(--surface-border)] pl-2">
<p className="mb-1.5 text-[11px] font-semibold text-slate-600">分支 {index + 1} · {edge.condition ?? edge.label ?? nodeById.get(edge.to)?.title}</p>
<SemanticPath nodeId={edge.to} stopId={branchEnd} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} />
<SemanticPath nodeId={edge.to} stopId={branchEnd} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} visible={visible} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} />
</div>)}</div>
</section> : outgoing[0] ? <SemanticPath nodeId={outgoing[0].to} stopId={stopId} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} /> : null}
{join ? <SemanticPath nodeId={join.id} stopId={stopId} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} /> : null}
</section> : outgoing[0] ? <SemanticPath nodeId={outgoing[0].to} stopId={stopId} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} visible={visible} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} /> : null}
{join ? <SemanticPath nodeId={join.id} stopId={stopId} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} visible={visible} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} /> : null}
</div>;
}
@@ -315,19 +335,36 @@ function ExactDependencyList({ plan, nodeActions }: { plan: ExecutionPlanView; n
})}</ol></div>;
}
function ExecutionNodeCard({ node, action, compact = false }: { node: ExecutionNode; action?: ReactNode; compact?: boolean }) {
function ExecutionNodeCard({ node, action, compact = false, context, onLoadEvidenceSnapshot, nodeById }: {
node: ExecutionNode; action?: ReactNode; compact?: boolean; context?: ExecutionFlowContext;
onLoadEvidenceSnapshot?: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
nodeById?: Map<string, ExecutionNode>;
}) {
const { tone, Icon, label } = executionNodePresentation(node);
const evidence = node.evidence_refs?.map((id) => context?.evidence.find((item) => item.evidence_id === id) ?? id) ?? [];
const gate = node.gate_ref && context ? context.access_requests.find((item) => item.access_request_id === node.gate_ref) ?? context.actions.find((item) => item.action_id === node.gate_ref) : null;
const relatedActions = node.action_refs?.map((id) => context?.actions.find((item) => item.action_id === id) ?? id) ?? [];
const hasDetails = Boolean(node.kind && (evidence.length || node.gate_ref || node.revisits_node_id || relatedActions.length));
return (
<article data-execution-node={node.id} className={cn("surface-reading min-w-0 rounded-xl border border-[var(--surface-divider)]/70", compact ? "px-2.5 py-2" : "px-3 py-2.5")}>
<div className="flex min-w-0 items-start gap-2">
<StatusIcon tone={tone} activity={node.status === "running" ? "loading" : "static"} shape="circle" size="md" className="mt-0.5 shrink-0"><Icon size={14} /></StatusIcon>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2"><span className="text-xs font-semibold leading-5 text-slate-900">{node.title}</span><StatusBadge tone={tone}>{label}</StatusBadge></div>
{node.kind && node.kind !== "activity" ? <p className="mt-0.5 text-[11px] font-medium text-[var(--action-blue-hover)]">{{ decision: "条件判断", parallel_split: "并行分叉", parallel_join: "并行汇合", gate: "等待审核" }[node.kind]}</p> : null}
{node.kind && node.kind !== "activity" ? <p className="mt-0.5 text-[11px] font-medium text-[var(--action-blue-hover)]">{{ decision: "条件判断", parallel_split: "并行分叉", parallel_join: "并行汇合", gate: "审核关口" }[node.kind]}</p> : null}
{node.kind ? <p className="mt-0.5 text-[11px] text-[var(--ink-muted)]">负责:{node.owner === "human" ? "人工" : "Agent"}</p> : null}
<p className="mt-1 break-words text-xs leading-5 text-slate-700">{node.detail || (node.owner === "human" ? "等待人工补充执行事实。" : "等待 Agent 更新执行事实。")}</p>
{hasDetails ? <Disclosure label={`查看关联详情${evidence.length ? ` · ${evidence.length} 条证据` : ""}`} className="mt-1 border-t border-[var(--surface-divider)]/70">
<div className="grid gap-2 border-t border-[var(--surface-divider)]/70 py-2 text-[11px] leading-5 text-slate-700">
{node.revisits_node_id ? <p>再次核验:{nodeById?.get(node.revisits_node_id)?.title ?? `节点 ${node.revisits_node_id}`}</p> : null}
{evidence.length > 0 ? <div><p className="font-semibold">节点依据</p><div className="mt-1 grid gap-1">{evidence.map((item) => typeof item === "string" ? <p key={item} className="text-[var(--status-warning-foreground)]">证据 {item} 不可用</p> : onLoadEvidenceSnapshot ? <EvidenceDisclosure key={item.evidence_id} item={item} onLoadSnapshot={onLoadEvidenceSnapshot} /> : <p key={item.evidence_id}>{item.summary} · {item.source_label}</p>)}</div></div> : null}
{node.gate_ref ? <p>审核关联:{gate ? "scopes" in gate ? `数据访问${gate.status === "approved" ? "已批准" : gate.status === "rejected" ? "已拒绝" : "待审核"},${gate.reason}` : `动作${approvalLabel(gate.approval_status)},${gate.reason}` : "关联记录不可用"}</p> : null}
{relatedActions.length > 0 ? <div><p className="font-semibold">关联动作</p>{relatedActions.map((item) => <p key={typeof item === "string" ? item : item.action_id}>{typeof item === "string" ? `动作 ${item} 不可用` : `${item.reason} · ${approvalLabel(item.approval_status)} · ${executionLabel(item.execution_status)}`}</p>)}</div> : null}
</div>
</Disclosure> : null}
</div>
</div>
{action ? <div className="mt-2">{action}</div> : null}
</div>
</div>
</article>
);
}
@@ -448,8 +485,10 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
const [rejectComment, setRejectComment] = useState("");
const [editing, setEditing] = useState(false);
const [savedVersion, setSavedVersion] = useState<number | null>(null);
const [draft, setDraft] = useState<Record<string, unknown>>(action.payload);
const command = action.kind === "issue_command";
const linkedAppointment = !command && task.task_type === "event_investigation";
const [draft, setDraft] = useState<Record<string, unknown>>(() => workOrderDraft(action.payload));
const displayedPayload = command ? action.payload : workOrderDraft(action.payload);
const canDecide = command ? task.permissions.can_decide_commands : task.permissions.can_decide_work_orders;
const canExecute = command ? task.permissions.can_execute_commands : task.permissions.can_execute_work_orders;
const run = async (kind: "approve" | "reject" | "execute" | "reconcile" | "save" | "confirm") => {
@@ -457,7 +496,7 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
try {
if (kind === "save") {
if (!onDraftUpdate) throw new Error("工单草稿编辑不可用");
await onDraftUpdate(action.action_id, action.version, draft);
await onDraftUpdate(action.action_id, action.version, workOrderDraft(draft));
setSavedVersion(action.version);
setEditing(false);
} else if (kind === "confirm") {
@@ -483,17 +522,19 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
<div className="flex items-start gap-2.5">
<StatusIcon tone="warning" shape="circle" size="md" className="mt-0.5"><UserCheck size={14} /></StatusIcon>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2"><h3 className="text-xs font-semibold text-slate-900">{command ? "命令下发确认" : "工单创建确认"}</h3><div className="flex flex-wrap gap-1.5"><StatusBadge tone={approvalTone(action.approval_status)}>{approvalLabel(action.approval_status)}</StatusBadge><StatusBadge tone={executionTone(action.execution_status)}>{executionLabel(action.execution_status)}</StatusBadge></div></div>
<div className="flex flex-wrap items-center justify-between gap-2"><h3 className="text-xs font-semibold text-slate-900">{command ? "命令下发确认" : linkedAppointment ? "关联预约工单" : "预约工单创建确认"}</h3><div className="flex flex-wrap gap-1.5"><StatusBadge tone={approvalTone(action.approval_status)}>{approvalLabel(action.approval_status)}</StatusBadge><StatusBadge tone={executionTone(action.execution_status)}>{executionLabel(action.execution_status)}</StatusBadge></div></div>
<p className="mt-2 text-xs leading-5 text-slate-700"><span className="font-semibold text-[var(--ink-muted)]">原因:</span>{action.reason}</p>
<ActionPayloadSummary payload={action.payload} />
{linkedAppointment ? <p className="mt-2 rounded-lg bg-sky-50/80 px-2.5 py-2 text-xs leading-5 text-slate-700">调查任务只保留创建关联。创建后请到“预约任务”查看派发进度、现场反馈和复令。</p> : null}
<ActionPayloadSummary payload={displayedPayload} />
{editing && !command ? <div className="mt-2 grid gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] p-2.5">
<Input aria-label="工单标题" value={String(draft.title ?? "")} onChange={(event) => setDraft({ ...draft, title: event.target.value })} />
<Input aria-label="工单说明" value={String(draft.description ?? "")} onChange={(event) => setDraft({ ...draft, description: event.target.value })} />
<div className="grid grid-cols-2 gap-2"><select aria-label="工单类型" className="form-field min-h-10 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2 text-xs" value={String(draft.work_order_type ?? "general")} onChange={(event) => setDraft({ ...draft, work_order_type: event.target.value })}><option value="general">一般工单</option><option value="water_quality_complaint">水质投诉</option><option value="burst_report">爆管上报</option></select><select aria-label="工单优先级" className="form-field min-h-10 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2 text-xs" value={String(draft.priority ?? "normal")} onChange={(event) => setDraft({ ...draft, priority: event.target.value })}><option value="normal">普通</option><option value="urgent">紧急</option></select></div>
<label className="grid gap-1 text-[11px] font-semibold text-[var(--ink-muted)]">工单归属<span className="form-field flex min-h-10 items-center rounded-lg border border-[var(--surface-divider)] bg-slate-50 px-2 text-xs font-normal text-slate-700">预约任务</span></label>
<select aria-label="工单优先级" className="form-field min-h-10 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2 text-xs" value={String(draft.priority ?? "normal")} onChange={(event) => setDraft({ ...draft, priority: event.target.value })}><option value="normal">普通</option><option value="urgent">紧急</option></select>
<Input aria-label="工单位置" value={String(draft.location ?? "")} onChange={(event) => setDraft({ ...draft, location: event.target.value || null })} />
<Input aria-label="处理班组" value={String(draft.assignee ?? "")} onChange={(event) => setDraft({ ...draft, assignee: event.target.value || null })} />
<Input aria-label="预约时间" type="datetime-local" value={toLocalDateTime(draft.scheduled_at)} onChange={(event) => setDraft({ ...draft, scheduled_at: event.target.value ? new Date(event.target.value).toISOString() : null })} />
<div className="flex justify-end gap-2"><Button variant="outline" size="sm" onClick={() => { setDraft(action.payload); setEditing(false); }}>取消编辑</Button><Button size="sm" disabled={submitting !== null || !String(draft.title ?? "").trim() || !String(draft.description ?? "").trim()} onClick={() => void run("save")}>保存草稿</Button></div>
<div className="flex justify-end gap-2"><Button variant="outline" size="sm" onClick={() => { setDraft(workOrderDraft(action.payload)); setEditing(false); }}>取消编辑</Button><Button size="sm" disabled={submitting !== null || !String(draft.title ?? "").trim() || !String(draft.description ?? "").trim()} onClick={() => void run("save")}>保存草稿</Button></div>
</div> : null}
{action.evidence_refs.length > 0 ? (
<div className="mt-2 rounded-lg bg-[var(--action-soft)]/70 px-2.5 py-2 text-xs leading-5 text-slate-700">
@@ -506,7 +547,7 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
</ul>
</div>
) : null}
{action.receipt ? <ActionReceipt receipt={action.receipt} /> : null}
{action.receipt ? <ActionReceipt receipt={action.receipt} title={linkedAppointment ? "预约工单创建回执" : undefined} /> : null}
{action.execution_detail && !action.receipt ? <p className="mt-2 rounded-lg bg-[var(--surface-reading)] px-2.5 py-2 text-xs leading-5 text-slate-700">{action.execution_detail}</p> : null}
<Disclosure label="查看原始参数" className="surface-control mt-2 rounded-lg border border-[var(--surface-divider)]/70 px-2.5"><pre className="max-h-52 overflow-auto whitespace-pre-wrap break-all border-t border-[var(--surface-divider)]/70 py-2 font-mono text-[11px] leading-5 text-slate-700">{JSON.stringify(action.payload, null, 2)}</pre></Disclosure>
<p className="mt-2 text-[11px] leading-5 text-[var(--ink-muted)]">确认版本 v{action.version}。内容或版本变化后必须重新确认。</p>
@@ -515,8 +556,8 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
{!canDecide && action.approval_status === "pending" ? <p className="mt-2 rounded-lg bg-[var(--surface-reading)] px-2.5 py-2 text-xs leading-5 text-slate-600">当前账号仅可查看,需要具备此动作审批权限的人员确认。</p> : null}
{action.approval_status === "pending" && canDecide ? <Input className="mt-2" aria-label="退回理由" placeholder="退回理由,拒绝动作时必填" value={rejectComment} onChange={(event) => setRejectComment(event.target.value)} /> : null}
<div className="mt-3 flex flex-wrap justify-end gap-2">
{action.approval_status === "pending" && canDecide ? <><Button variant="outline" size="sm" disabled={submitting !== null} onClick={() => void run("reject")}>拒绝动作</Button>{!command && onDraftUpdate ? <Button variant="outline" size="sm" disabled={submitting !== null || editing} onClick={() => { setDraft(action.payload); setEditing(true); }}>编辑草稿</Button> : null}<Button size="sm" disabled={submitting !== null || editing || savedVersion === action.version || (!command && !!onConfirmDispatch && !canExecute)} onClick={() => void run(!command && onConfirmDispatch ? "confirm" : "approve")}>{submitting === "approve" || submitting === "confirm" ? <Loader2 className="animate-spin" /> : null}{command ? "批准下发命令" : onConfirmDispatch ? "确认并下发工单" : "批准创建工单"}</Button></> : null}
{action.approval_status === "approved" && action.execution_status === "not_submitted" ? <Button size="sm" disabled={!canExecute || submitting !== null} title={!canExecute ? "当前账号无此动作执行权限" : undefined} onClick={() => void run("execute")}>{submitting === "execute" ? <Loader2 className="animate-spin" /> : null}{command ? "下发命令" : "创建工单"}</Button> : null}
{action.approval_status === "pending" && canDecide ? <><Button variant="outline" size="sm" disabled={submitting !== null} onClick={() => void run("reject")}>拒绝动作</Button>{!command && onDraftUpdate ? <Button variant="outline" size="sm" disabled={submitting !== null || editing} onClick={() => { setDraft(workOrderDraft(action.payload)); setEditing(true); }}>编辑草稿</Button> : null}<Button size="sm" disabled={submitting !== null || editing || savedVersion === action.version || (!command && !!onConfirmDispatch && !canExecute)} onClick={() => void run(!command && onConfirmDispatch ? "confirm" : "approve")}>{submitting === "approve" || submitting === "confirm" ? <Loader2 className="animate-spin" /> : null}{command ? "批准下发命令" : onConfirmDispatch ? "确认并创建预约工单" : "批准创建预约工单"}</Button></> : null}
{action.approval_status === "approved" && action.execution_status === "not_submitted" ? <Button size="sm" disabled={!canExecute || submitting !== null} title={!canExecute ? "当前账号无此动作执行权限" : undefined} onClick={() => void run("execute")}>{submitting === "execute" ? <Loader2 className="animate-spin" /> : null}{command ? "下发命令" : "创建预约工单"}</Button> : null}
{action.approval_status === "approved" && ["submitted", "unknown"].includes(action.execution_status) ? <Button variant="outline" size="sm" disabled={!canExecute || submitting !== null} onClick={() => void run("reconcile")}>{submitting === "reconcile" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}核对执行回执</Button> : null}
</div>
</div>
@@ -525,9 +566,9 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
);
}
function ActionReceipt({ receipt }: { receipt: NonNullable<OperationalTaskAction["receipt"]> }) {
return <section aria-label="执行回执" className="mt-2 rounded-lg bg-emerald-50/70 px-2.5 py-2">
<p className="text-xs font-semibold text-emerald-800">执行回执</p>
function ActionReceipt({ receipt, title = "执行回执" }: { receipt: NonNullable<OperationalTaskAction["receipt"]>; title?: string }) {
return <section aria-label={title} className="mt-2 rounded-lg bg-emerald-50/70 px-2.5 py-2">
<p className="text-xs font-semibold text-emerald-800">{title}</p>
<dl className="mt-1 grid gap-1 text-[11px] leading-5">
<EvidenceMeta label="回执编号" value={receipt.receipt_id} mono />
<EvidenceMeta label="外部状态" value={externalStatusLabel(receipt.external_status)} />
@@ -548,6 +589,10 @@ function ActionPayloadSummary({ payload }: { payload: Record<string, unknown> })
return <dl className="surface-control mt-2 grid gap-1 rounded-lg border border-[var(--surface-divider)]/70 px-2.5 py-2 text-xs leading-5">{rows.map((row) => <div key={row.key} className="grid grid-cols-[76px_1fr] gap-2"><dt className="font-semibold text-[var(--ink-muted)]">{payloadLabel(row.key)}</dt><dd className="break-words text-slate-800">{payloadValue(row.key, row.value)}</dd></div>)}</dl>;
}
function workOrderDraft(payload: Record<string, unknown>) {
return { ...payload, work_order_type: "general" };
}
function payloadLabel(key: string) {
const labels: Record<string, string> = {
title: "工单标题", description: "工作内容", work_order_type: "工单类型", priority: "优先级",
@@ -562,7 +607,7 @@ function payloadValue(key: string, value: unknown) {
if (value === null || value === undefined || value === "") return "未指定";
if (key === "command_type") return knownLabel(value, { set_valve: "调节阀门" });
if (key === "target.kind") return knownLabel(value, { valve: "阀门", pump: "泵站", pipe: "管段" });
if (key === "work_order_type") return knownLabel(value, { general: "一般工单", water_quality_complaint: "水质投诉", burst_report: "爆管上报" });
if (key === "work_order_type") return knownLabel(value, { general: "预约工单", water_quality_complaint: "水质投诉", burst_report: "爆管上报" });
if (key === "priority") return knownLabel(value, { normal: "普通", urgent: "紧急", high: "高", low: "低" });
if (key === "scheduled_at" || key === "expires_at") return typeof value === "string" ? formatDateTime(value) : String(value);
if (Array.isArray(value)) return value.map(String).join("、") || "无";
@@ -605,7 +650,7 @@ function topologicalNodes(plan: ExecutionPlanView) {
function executionNodePresentation(node: ExecutionNode) {
const tone: StatusTone = node.status === "completed" ? "success" : node.status === "running" ? "info" : node.status === "failed" ? "danger" : node.status === "waiting" ? "warning" : "neutral";
const Icon = node.status === "completed" ? CheckCircle2 : node.status === "running" ? Loader2 : node.status === "waiting" || node.status === "failed" ? AlertTriangle : Clock3;
const labels = { pending: "待执行", running: "执行中", waiting: "待人工/资料", completed: "已完成", failed: "失败", skipped: "不适用", cancelled: "已取消" } as const;
const labels = { pending: "待执行", running: "执行中", waiting: "待人工/资料", completed: "已完成", failed: "失败", skipped: "未进入", cancelled: "已取消" } as const;
return { tone, Icon, label: labels[node.status] };
}
@@ -0,0 +1,188 @@
import { useEffect, useRef, useState, type ReactNode, type SVGProps } from "react";
import { GitBranch, Maximize2, RotateCcw, X, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react";
import { MessageResponse } from "@/shared/ai-elements/message";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import type { InvestigationDiagram } from "../api/investigation-report";
import {
INVESTIGATION_DIAGRAM_FONT,
investigationDiagramConfig,
investigationDiagramPlugins,
styleInvestigationDiagram
} from "../utils/investigation-diagram-style";
function controlIcon(Icon: LucideIcon, control: string) {
return function DiagramControlIcon({ className, ...props }: SVGProps<SVGSVGElement> & { size?: number }) {
return (
<Icon
{...props}
aria-hidden
className={cn("investigation-diagram-control-icon", className)}
data-investigation-diagram-control={control}
size={16}
strokeWidth={2.2}
/>
);
};
}
// The icon markers also identify controls in Streamdown's fullscreen portal.
const diagramControlIcons = {
ZoomInIcon: controlIcon(ZoomIn, "zoom-in"),
ZoomOutIcon: controlIcon(ZoomOut, "zoom-out"),
RotateCcwIcon: controlIcon(RotateCcw, "reset"),
Maximize2Icon: controlIcon(Maximize2, "fullscreen"),
XIcon: controlIcon(X, "close")
};
function DiagramUnavailable() {
return (
<p role="status" className="p-4 text-sm leading-6 text-[var(--ink-muted)]">
图示暂不可用,仍可查看结论、证据和待审核动作。
</p>
);
}
const getNodeId = (node: Element | null | undefined) =>
node?.getAttribute("data-id") ?? node?.id.match(/(?:^|-)flowchart-(.+)-\d+$/)?.[1];
export function InvestigationDiagramView({
diagram,
renderDetails,
onAsk
}: {
diagram: InvestigationDiagram;
renderDetails: (node: InvestigationDiagram["node_refs"][number]) => ReactNode;
onAsk?: (node: InvestigationDiagram["node_refs"][number]) => void;
}) {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const pointerStart = useRef<{ nodeId: string; x: number; y: number } | null>(null);
const selected = diagram.node_refs.find((node) => node.node_id === selectedNodeId);
const [fontsReady, setFontsReady] = useState(
() => typeof document === "undefined" || !document.fonts
);
useEffect(() => {
if (!document.fonts) return;
let active = true;
void document.fonts
.load(`500 15px ${INVESTIGATION_DIAGRAM_FONT}`, diagram.mermaid)
.catch(() => undefined)
.then(() => {
if (active) setFontsReady(true);
});
return () => {
active = false;
};
}, [diagram.mermaid]);
return (
<section aria-label="调查脉络" className="investigation-report-section">
<h3 className="flex items-center gap-2 text-sm font-semibold text-[var(--ink)]">
<GitBranch size={15} aria-hidden />
{diagram.title}
</h3>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
核查方向与判断分支。可放大查看,点击节点阅读关联依据。
</p>
<div
className="investigation-diagram mt-3"
onPointerDownCapture={(event) => {
const node = (event.target as Element).closest?.(".node");
const ref = diagram.node_refs.find((item) => getNodeId(node) === item.node_id);
pointerStart.current = ref
? { nodeId: ref.node_id, x: event.clientX, y: event.clientY }
: null;
}}
onClick={(event) => {
const node = (event.target as Element).closest?.(".node");
// The pan control captures pointers; retain the hit target for a click, while ignoring drags.
const start = pointerStart.current;
pointerStart.current = null;
if (start && Math.hypot(event.clientX - start.x, event.clientY - start.y) > 6) return;
const ref = diagram.node_refs.find(
(item) => getNodeId(node) === item.node_id || start?.nodeId === item.node_id
);
if (ref) setSelectedNodeId(ref.node_id);
}}
>
{fontsReady ? (
<MessageResponse
icons={diagramControlIcons}
plugins={investigationDiagramPlugins}
translations={{
viewFullscreen: "全屏查看",
exitFullscreen: "退出全屏"
}}
mermaid={{
errorComponent: DiagramUnavailable,
config: investigationDiagramConfig
}}
controls={{
mermaid: { fullscreen: true, download: false, copy: false, panZoom: true }
}}
>{`\`\`\`mermaid\n${styleInvestigationDiagram(diagram)}\n\`\`\``}</MessageResponse>
) : (
<p className="p-4 text-xs text-[var(--ink-muted)]">正在排版调查图示。</p>
)}
</div>
{diagram.node_refs.some((node) => node.evidence_refs.length || node.action_refs.length) ? (
<div
className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--ink-muted)]"
aria-label="图示颜色说明"
>
{diagram.node_refs.some(
(node) => node.evidence_refs.length && !node.action_refs.length
) ? (
<span className="inline-flex items-center gap-1.5">
<span
aria-hidden
className="h-2 w-2 rounded-sm border border-[#6B9E8B] bg-[#EDF7F4]"
/>
引用证据
</span>
) : null}
{diagram.node_refs.some((node) => node.action_refs.length) ? (
<span className="inline-flex items-center gap-1.5">
<span
aria-hidden
className="h-2 w-2 rounded-sm border border-[#C4A168] bg-[#FFF6E8]"
/>
关联业务动作
</span>
) : null}
</div>
) : null}
{diagram.node_refs.length > 0 ? (
<div aria-label="图中关联信息" className="mt-3 flex flex-wrap gap-1.5">
{diagram.node_refs.map((node) => (
<button
key={node.node_id}
type="button"
aria-pressed={selectedNodeId === node.node_id}
className="investigation-node-link"
onClick={() =>
setSelectedNodeId(selectedNodeId === node.node_id ? null : node.node_id)
}
>
{node.label}
{node.evidence_refs.length ? ` · ${node.evidence_refs.length} 条依据` : ""}
</button>
))}
</div>
) : null}
{selected ? (
<div
className="mt-3 space-y-2 rounded-lg border border-[var(--surface-divider)] p-3"
aria-label={`节点详情 ${selected.label}`}
>
<p className="text-sm font-medium text-[var(--ink)]">{selected.label}</p>
{renderDetails(selected)}
{onAsk ? (
<Button size="sm" variant="outline" onClick={() => onAsk(selected)}>
围绕此节点提问
</Button>
) : null}
</div>
) : null}
</section>
);
}
@@ -0,0 +1,55 @@
import { FileText } from "lucide-react";
import { StatusBadge } from "@/shared/ui/status";
import type { OperationalTask } from "../api/operational-task-client";
export function InvestigationReportView({ task, reportLabel }: { task: OperationalTask; reportLabel: string }) {
const snapshot = task.latest_report;
const report = snapshot?.report;
const insufficient = report?.outcome === "insufficient_data";
const current = snapshot?.execution_generation === task.execution_generation;
const ended = current && task.lifecycle_status === "completed";
const insufficientLabel = ended ? "本轮已结束 · 无法形成结论"
: task.lifecycle_status === "failed" ? "证据不足 · 本轮执行失败"
: task.lifecycle_status === "cancelled" ? "证据不足 · 本轮已取消"
: "无法形成结论 · 正在结束本轮";
return <section aria-label="任务报告" className="surface-reading min-w-0 space-y-3 rounded-xl border border-[var(--surface-divider)]/70 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-800"><FileText size={13} aria-hidden="true" />任务报告</h3>
{report ? <StatusBadge tone={insufficient ? "warning" : "neutral"}>
{insufficient ? "证据不足" : report.outcome === "concluded" ? "已形成结论" : "结果未记录"}
</StatusBadge> : null}
</div>
{report ? <>
<div className={`border-l-2 pl-3 ${insufficient ? "border-[var(--status-warning-mark)]" : "border-[var(--palette-brand)]"}`}>
<p className="text-xs font-semibold leading-5 text-slate-900">
{!current ? reportLabel : insufficient ? insufficientLabel : report.stage === "interim" ? "阶段性判断" : report.outcome === "concluded" ? "调查结论" : "历史报告内容"}
</p>
<p className="mt-1 break-words text-xs leading-6 text-slate-700">{report.current_conclusion}</p>
{insufficient ? <p className="mt-1 break-words text-xs leading-5 text-[var(--status-warning-foreground)]">结束原因:{report.stop_reason}</p> : null}
</div>
{report.data_gaps && report.data_gaps.length > 0 ? <div aria-label="数据缺口" className="space-y-2">
<h4 className="text-xs font-semibold text-slate-800">{insufficient ? "关键数据缺口" : "数据范围限制"}</h4>
{report.data_gaps.map((gap) => <div key={gap.gap_id} className="space-y-1 border-l border-[var(--surface-divider)] pl-3 text-xs leading-5">
<p className="break-words font-medium text-slate-900">{gap.missing_data}{gap.blocking ? " · 阻止形成结论" : ""}</p>
<p className="break-words text-slate-600">查询范围:{gap.scope}</p>
<p className="break-words text-slate-600">判断影响:{gap.impact}</p>
<p className="break-words text-slate-600">需补齐:{gap.required_data}</p>
<p className="break-words text-[var(--ink-muted)]">依据:{gap.evidence_refs.map((id) => task.evidence.find((item) => item.evidence_id === id)?.summary ?? id).join(";")}</p>
</div>)}
</div> : report.data_gaps == null ? <p className="text-[11px] leading-5 text-[var(--ink-muted)]">历史记录未提供结构化数据缺口。</p> : null}
{!insufficient && report.recommendations.length > 0 ? <ReportItems title="处置建议" items={report.recommendations} /> : null}
{!insufficient && report.pending_items.length > 0 ? <ReportItems title="待确认事项" items={report.pending_items} /> : null}
{insufficient ? <p className="text-xs leading-5 text-slate-600">补齐上述数据后可发起新一轮调查。原投诉工单与现场处置进度独立记录。</p> : null}
<div className="space-y-1 border-t border-[var(--surface-divider)]/70 pt-2 text-[11px] leading-5 text-[var(--ink-muted)]">
<p className="break-words">结论边界:{report.conclusion_boundary}</p>
<p>报告第 {snapshot.revision} 版 · {new Date(snapshot.published_at).toLocaleString("zh-CN", { hour12: false })}</p>
</div>
</> : <p className="text-xs leading-6 text-slate-600">尚未形成任务报告。</p>}
</section>;
}
function ReportItems({ title, items }: { title: string; items: string[] }) {
return <div><h4 className="text-xs font-semibold text-slate-800">{title}</h4>
<ul className="mt-1 list-disc space-y-1 pl-4 text-xs leading-5 text-slate-700">{items.map((item, index) => <li key={index} className="break-words">{item}</li>)}</ul>
</div>;
}
@@ -0,0 +1,634 @@
import { useEffect, useState, type ReactNode } from "react";
import { FileText, History, MessageSquare } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Disclosure } from "@/shared/ui/disclosure";
import { StatusBadge } from "@/shared/ui/status";
import type { InvestigationDiagram, InvestigationDiscussion } from "../api/investigation-report";
import type {
OperationalTask,
OperationalTaskAction,
OperationalTaskEvidence,
OperationalTaskReport,
OperationalTaskReportPage
} from "../api/operational-task-client";
import {
investigationDiagram,
investigationStatus,
isPendingInvestigationAction
} from "../utils/investigation-presentation";
import { InvestigationDiagramView } from "./investigation-diagram-view";
type ReportSelection = { snapshot: OperationalTaskReport; diagram: InvestigationDiagram | null };
type Props = {
task: OperationalTask;
onContinue: (discussion?: InvestigationDiscussion) => void;
renderAction: (action: OperationalTaskAction) => ReactNode;
renderEvidence: (evidence: OperationalTaskEvidence) => ReactNode;
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
onReportViewed?: (generation: number, revision: number) => void;
focusActionId?: string | null;
};
const dateLabel = (value: string) => new Date(value).toLocaleString("zh-CN", { hour12: false });
const roundLabel = (snapshot: OperationalTaskReport) =>
snapshot.execution_generation == null ? "历史报告" : `第 ${snapshot.execution_generation} 轮`;
const actionTitle = (action: OperationalTaskAction) =>
String(action.payload.title ?? (action.kind === "issue_command" ? "设备命令" : "工单草稿"));
const actionProgressLabel = (action: OperationalTaskAction) => {
if (action.approval_status === "rejected") return "已拒绝";
if (action.approval_status === "pending") return "待审核";
return {
not_submitted: "待执行",
submitted: "已提交",
succeeded: action.kind === "create_work_order" ? "工单已创建" : "执行成功",
failed: "执行失败",
unsupported: "无法执行",
unknown: "待核对回执"
}[action.execution_status];
};
type InvestigationTimelineItem = {
id: string;
occurredAt: string;
title: string;
detail: string;
};
function investigationTimeline(
task: OperationalTask,
snapshot: OperationalTaskReport | null
): InvestigationTimelineItem[] {
const items: InvestigationTimelineItem[] = task.evidence.map((evidence) => ({
id: `evidence:${evidence.evidence_id}`,
occurredAt: evidence.recorded_at,
title: evidence.kind === "human_input" ? "已记录人工事实" : "已保存调查资料",
detail: evidence.summary
}));
if (snapshot) {
items.push({
id: `report:${snapshot.revision}`,
occurredAt: snapshot.published_at,
title: `${roundLabel(snapshot)}报告第 ${snapshot.revision} 版已发布`,
detail: snapshot.report.current_conclusion
});
}
task.actions.forEach((action) => {
const title = actionTitle(action);
items.push({
id: `action:${action.action_id}:proposed`,
occurredAt: action.proposed_at,
title: `${title}已提出`,
detail: action.reason
});
if (action.edited_at) {
items.push({
id: `action:${action.action_id}:edited`,
occurredAt: action.edited_at,
title: `${title}草稿已更新`,
detail: "后续审核与执行以更新后的版本为准。"
});
}
if (action.decided_at && action.approval_status !== "pending") {
items.push({
id: `action:${action.action_id}:decided`,
occurredAt: action.decided_at,
title: `${title}${action.approval_status === "approved" ? "已批准" : "已拒绝"}`,
detail: action.decision_comment ?? "已记录人工审核结果。"
});
}
if (action.receipt) {
items.push({
id: `action:${action.action_id}:receipt`,
occurredAt:
action.receipt.completed_at ?? action.executed_at ?? action.receipt.submitted_at,
title: `${title}已有执行回执`,
detail: action.receipt.detail
});
} else if (action.executed_at) {
items.push({
id: `action:${action.action_id}:executed`,
occurredAt: action.executed_at,
title: `${title}${actionProgressLabel(action)}`,
detail: action.execution_detail ?? "已记录动作执行状态。"
});
}
});
return items.sort((left, right) => Date.parse(right.occurredAt) - Date.parse(left.occurredAt));
}
export function InvestigationTaskReport({
task,
onContinue,
renderAction,
renderEvidence,
onLoadReports,
onReportViewed,
focusActionId
}: Props) {
const [selection, setSelection] = useState<ReportSelection | null>(() =>
task.latest_report
? { snapshot: task.latest_report, diagram: investigationDiagram(task, task.latest_report) }
: null
);
const [history, setHistory] = useState<OperationalTaskReport[]>([]);
const [historyOffset, setHistoryOffset] = useState<number | null>(0);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const snapshot = selection?.snapshot ?? null;
const report = snapshot?.report;
const diagram = selection?.diagram ?? (snapshot ? null : investigationDiagram(task, null));
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 = Boolean(task.latest_report && (!snapshot || olderRevision));
const insufficient = report?.outcome === "insufficient_data";
const currentInsufficient = task.latest_report?.execution_generation === task.execution_generation
&& task.latest_report?.report.outcome === "insufficient_data";
const pendingActions = task.actions.filter(isPendingInvestigationAction);
const completedActions = task.actions.filter((action) => !isPendingInvestigationAction(action));
const evidence = task.evidence.filter((item) => report?.evidence_refs.includes(item.evidence_id));
const majorEvidence = (snapshot ? evidence : task.evidence).slice(0, 3);
const remainingEvidence = (snapshot ? evidence : task.evidence).slice(3);
const timeline = investigationTimeline(task, snapshot);
useEffect(() => {
if (!selection && task.latest_report)
setSelection({
snapshot: task.latest_report,
diagram: investigationDiagram(task, task.latest_report)
});
}, [selection, task]);
useEffect(() => {
if (snapshot?.execution_generation == null || !onReportViewed) return;
onReportViewed(snapshot.execution_generation, snapshot.revision);
}, [snapshot, onReportViewed]);
useEffect(() => {
if (!focusActionId) return;
const details = [
...document.querySelectorAll<HTMLDetailsElement>("details[data-review-action]")
].find((item) => item.dataset.reviewAction === focusActionId);
if (details) {
details.open = true;
details.scrollIntoView?.({ block: "nearest" });
details.querySelector<HTMLElement>("summary")?.focus();
}
}, [focusActionId]);
const discuss = (prompt: string, extra?: { evidence_id?: string; gap_id?: string }) =>
onContinue({
prompt,
context: { ...(snapshot ? { report_revision: snapshot.revision } : {}), ...extra }
});
const selectReport = (item: OperationalTaskReport) =>
setSelection({ snapshot: item, diagram: investigationDiagram(task, item) });
const loadHistory = async (offset: number) => {
if (!onLoadReports || historyLoading) return;
setHistoryLoading(true);
setHistoryError(null);
try {
const page = await onLoadReports(offset);
setHistory((current) => (offset === 0 ? page.items : [...current, ...page.items]));
setHistoryOffset(page.next_offset);
} catch (error) {
setHistoryError(error instanceof Error ? error.message : "报告版本加载失败");
} finally {
setHistoryLoading(false);
}
};
return (
<article aria-label="调查任务报告" className="investigation-report surface-reading">
<header className="investigation-report-header">
<div className="min-w-0">
<p className="text-xs text-[var(--ink-muted)]">
调查任务 · 第 {task.execution_generation} 轮
</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<h2 className="text-base font-semibold leading-6 text-[var(--ink)]">{task.title}</h2>
<StatusBadge
tone={
task.lifecycle_status === "failed"
? "danger"
: currentInsufficient || pendingActions.length
? "warning"
: "neutral"
}
>
{investigationStatus(task)}
</StatusBadge>
</div>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{task.summary}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
onContinue({ context: snapshot ? { report_revision: snapshot.revision } : {} })
}
>
<MessageSquare size={14} aria-hidden />
Agent 协作
</Button>
</header>
{latestAvailable ? (
<div className="investigation-report-update" role="status">
<span>第 {task.latest_report!.execution_generation ?? "未知"} 轮有新报告</span>
<Button variant="outline" size="sm" onClick={() => selectReport(task.latest_report!)}>
查看新报告
</Button>
</div>
) : null}
<section aria-label="任务报告" className="investigation-report-section">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 text-sm font-semibold text-[var(--ink)]">
<FileText size={15} aria-hidden />
当前结果
</h3>
{report ? (
<StatusBadge tone={insufficient ? "warning" : "neutral"}>
{insufficient
? "证据不足"
: report.outcome === "concluded"
? "已形成判断"
: "结果未记录"}
</StatusBadge>
) : null}
</div>
{report && snapshot ? (
<>
<p className="mt-3 text-sm font-semibold leading-6 text-[var(--ink)]">
{previousRound
? task.status === "queued" || task.status === "running"
? "上一轮报告,本轮分析中"
: "上一轮报告"
: olderRevision
? "本轮历史报告"
: insufficient
? task.lifecycle_status === "completed"
? "本轮已结束 · 无法形成结论"
: task.lifecycle_status === "failed" || task.lifecycle_status === "cancelled"
? "无法形成结论 · 本轮运行已停止"
: "无法形成结论 · 正在结束本轮"
: report.stage === "interim"
? "阶段性判断"
: report.outcome === "concluded"
? "调查结论"
: "历史报告内容"}
</p>
<p className="mt-1 text-sm leading-6 text-[var(--ink)]">{report.current_conclusion}</p>
<p className="mt-2 text-xs leading-5 text-[var(--ink-muted)]">
结论边界:{report.conclusion_boundary}
</p>
{insufficient ? (
<p className="mt-2 text-sm leading-6 text-[var(--status-warning-foreground)]">
结束原因:{report.stop_reason}
</p>
) : null}
<p className="mt-2 text-xs text-[var(--ink-muted)]">
正在阅读:{roundLabel(snapshot)} · 报告第 {snapshot.revision} 版 ·{" "}
{dateLabel(snapshot.published_at)}
</p>
{!insufficient &&
(report.recommendations.length > 0 || report.pending_items.length > 0) ? (
<Disclosure label="判断说明与建议" className="mt-2">
{report.recommendations.length > 0 ? (
<ReportItems title="处置建议" items={report.recommendations} />
) : null}
{report.pending_items.length > 0 ? (
<ReportItems title="待核实问题" items={report.pending_items} />
) : null}
</Disclosure>
) : null}
</>
) : (
<p className="mt-3 text-sm leading-6 text-[var(--ink-muted)]">
尚未发布本轮调查报告。已保存的调查资料可在下方查看。
</p>
)}
</section>
{pendingActions.length > 0 ? (
<section aria-label="当前需处理" className="investigation-report-section">
<h3 className="text-sm font-semibold text-[var(--ink)]">
当前需处理{" "}
<span className="font-normal text-[var(--ink-muted)]">
· {pendingActions.length} 项
</span>
</h3>
{previousRound || olderRevision ? (
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
下列操作使用当前草稿与审批状态。
</p>
) : null}
{pendingActions.map((action) => (
<Disclosure
key={action.action_id}
data-review-action={action.action_id}
initiallyOpen={action.action_id === focusActionId}
label={
<span>
{actionTitle(action)} ·{" "}
{action.approval_status === "pending"
? "待审核"
: action.execution_status === "not_submitted"
? "待执行"
: "待核对回执"}
</span>
}
>
{renderAction(action)}
</Disclosure>
))}
</section>
) : null}
{completedActions.length > 0 ? (
<section aria-label="处置进展" className="investigation-report-section">
<h3 className="text-sm font-semibold text-[var(--ink)]">
处置进展{" "}
<span className="font-normal text-[var(--ink-muted)]">
· {completedActions.length} 项
</span>
</h3>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
{previousRound || olderRevision
? "以下为任务当前动作状态,与所选历史报告分开记录。"
: "动作回执只说明对应系统操作结果,不替代后续现场事实。"}
</p>
{completedActions.map((action) => (
<Disclosure
key={action.action_id}
label={
<span className="investigation-action-summary">
<span>{actionTitle(action)} · {actionProgressLabel(action)}</span>
<span className="investigation-action-summary-detail">
{action.receipt?.detail ?? action.execution_detail ?? action.reason}
</span>
</span>
}
>
<div className="pb-2">{renderAction(action)}</div>
</Disclosure>
))}
</section>
) : null}
{report?.data_gaps?.length ? (
<section aria-label="数据缺口" className="investigation-report-section">
<h3 className="text-sm font-semibold text-[var(--ink)]">
{insufficient ? "关键数据缺口" : "数据范围限制"}
</h3>
{report.data_gaps.map((gap) => (
<div key={gap.gap_id} className="mt-3">
<p className="text-sm font-medium leading-6 text-[var(--ink)]">{gap.missing_data}</p>
<p className="text-sm leading-6 text-[var(--ink-muted)]">判断影响:{gap.impact}</p>
<div className="mt-1 flex flex-wrap items-center gap-x-4">
<Disclosure label="查看查询依据">
<div className="space-y-1 py-2 text-xs leading-5 text-[var(--ink-muted)]">
<p>查询范围:{gap.scope}</p>
<p>需补齐:{gap.required_data}</p>
{gap.evidence_refs.map((id) => (
<EvidenceReference
key={id}
id={id}
task={task}
renderEvidence={renderEvidence}
/>
))}
</div>
</Disclosure>
<Button
variant="outline"
size="sm"
onClick={() =>
discuss(
`我要补充“${gap.missing_data}”相关的现场事实。请说明需要的记录范围和时间信息。`,
{ gap_id: gap.gap_id }
)
}
>
补充事实
</Button>
</div>
</div>
))}
{insufficient ? (
<p className="mt-3 text-xs leading-5 text-[var(--ink-muted)]">
{previousRound || olderRevision
? "此处展示所选历史报告的数据缺口,当前调查状态见顶部。"
: "补齐事实后发起新一轮调查。原投诉工单与现场处置进度独立记录。"}
</p>
) : null}
</section>
) : report?.data_gaps == null && report ? (
<p className="px-4 py-2 text-xs text-[var(--ink-muted)]">历史记录未提供结构化数据缺口。</p>
) : null}
{diagram ? (
<InvestigationDiagramView
key={`${snapshot?.revision ?? "plan"}:${diagram.mermaid}`}
diagram={diagram}
renderDetails={(node) => (
<>
{node.evidence_refs.map((id) => (
<EvidenceReference key={id} id={id} task={task} renderEvidence={renderEvidence} />
))}
{node.action_refs.map((id) => {
const action = task.actions.find((item) => item.action_id === id);
return action ? (
<p key={id} className="text-xs leading-5 text-[var(--ink-muted)]">
关联动作:{actionTitle(action)} ·{" "}
{action.approval_status === "approved"
? "已批准"
: action.approval_status === "rejected"
? "已拒绝"
: "待审核"}{" "}
·{" "}
{action.execution_status === "succeeded"
? "已有执行回执"
: "当前执行结果未确认成功"}
</p>
) : null;
})}
{!node.evidence_refs.length && !node.action_refs.length ? (
<p className="text-xs leading-5 text-[var(--ink-muted)]">
此处说明核查方向,尚无关联证据或业务动作记录。
</p>
) : null}
</>
)}
onAsk={(node) =>
discuss(
`请解释调查脉络中的“${node.label}”及其关联依据。`,
node.evidence_refs[0] ? { evidence_id: node.evidence_refs[0] } : undefined
)
}
/>
) : null}
<section aria-label="主要依据" className="investigation-report-section">
<h3 className="text-sm font-semibold text-[var(--ink)]">主要依据</h3>
{majorEvidence.map((item) => (
<div key={item.evidence_id} className="mt-2">
{renderEvidence(item)}
<button
type="button"
className="investigation-text-action"
onClick={() =>
discuss(`请解释“${item.summary}”这条证据的范围、来源和判断影响。`, {
evidence_id: item.evidence_id
})
}
>
围绕此证据提问
</button>
</div>
))}
{(snapshot ? evidence : task.evidence).length === 0 ? (
<p className="mt-2 text-sm text-[var(--ink-muted)]">尚无可展示的引用证据。</p>
) : null}
{remainingEvidence.length > 0 ? (
<Disclosure label={`更多引用依据 · ${remainingEvidence.length} 条`} className="mt-2">
{remainingEvidence.map((item) => (
<div key={item.evidence_id}>
{renderEvidence(item)}
<button
type="button"
className="investigation-text-action"
onClick={() =>
discuss(`请解释“${item.summary}”的来源和判断影响。`, {
evidence_id: item.evidence_id
})
}
>
围绕此证据提问
</button>
</div>
))}
</Disclosure>
) : null}
{snapshot &&
task.evidence.some((item) => !report?.evidence_refs.includes(item.evidence_id)) ? (
<Disclosure label="其他已保存资料" className="mt-2">
{task.evidence
.filter((item) => !report?.evidence_refs.includes(item.evidence_id))
.map(renderEvidence)}
</Disclosure>
) : null}
</section>
<section className="investigation-report-section">
<Disclosure label="任务时间线">
<p className="pb-2 text-xs leading-5 text-[var(--ink-muted)]">
按时间倒序汇总当前任务记录;报告事件对应当前正在阅读的版本。
</p>
<ol aria-label="任务时间线记录" className="investigation-timeline">
{timeline.map((item) => (
<li key={item.id} className="investigation-timeline-item">
<time dateTime={item.occurredAt} className="block text-[11px] text-[var(--ink-muted)]">
{dateLabel(item.occurredAt)}
</time>
<p className="text-xs font-medium leading-5 text-[var(--ink)]">{item.title}</p>
<p className="text-xs leading-5 text-[var(--ink-muted)]">{item.detail}</p>
</li>
))}
</ol>
</Disclosure>
{onLoadReports ? (
<Disclosure
label={
<span className="flex items-center gap-2">
<History size={14} aria-hidden />
报告版本
</span>
}
onToggle={(event) => {
if (event.currentTarget.open && history.length === 0 && !historyLoading)
void loadHistory(0);
}}
>
{history.map((item) => (
<button
key={item.revision}
type="button"
className="investigation-history-row"
aria-pressed={snapshot?.revision === item.revision}
onClick={() => selectReport(item)}
>
<span>
{roundLabel(item)} · 报告第 {item.revision} 版
{item.revision === task.latest_report?.revision ? " · 最新版" : ""}
</span>
<span className="text-xs text-[var(--ink-muted)]">
{item.report.outcome === "insufficient_data"
? "证据不足"
: item.report.stage === "interim"
? "阶段性判断"
: item.report.outcome === "concluded"
? "已形成判断"
: "历史记录"}
</span>
</button>
))}
{historyError ? (
<p role="alert" className="text-xs leading-5 text-[var(--status-warning-foreground)]">
{historyError}
</p>
) : null}
{historyLoading ? (
<p className="py-2 text-xs text-[var(--ink-muted)]">正在读取报告版本。</p>
) : historyOffset != null || historyError ? (
<Button
variant="outline"
size="sm"
onClick={() => void loadHistory(historyError ? 0 : historyOffset!)}
>
{historyError ? "重新加载" : "更多报告"}
</Button>
) : null}
</Disclosure>
) : null}
<p className="mt-3 text-xs text-[var(--ink-muted)]">
任务更新于 {dateLabel(task.updated_at)}
</p>
</section>
</article>
);
}
function EvidenceReference({
id,
task,
renderEvidence
}: {
id: string;
task: OperationalTask;
renderEvidence: Props["renderEvidence"];
}) {
const item = task.evidence.find((evidence) => evidence.evidence_id === id);
return item ? (
renderEvidence(item)
) : (
<p className="text-xs text-[var(--ink-muted)]">关联证据当前不可用。</p>
);
}
function ReportItems({ title, items }: { title: string; items: string[] }) {
return (
<div className="py-2 text-sm leading-6 text-[var(--ink)]">
<p className="font-medium">{title}</p>
<ul className="mt-1 list-disc space-y-1 pl-4">
{items.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
}
@@ -111,7 +111,7 @@ type ConditionDetailPanelProps = {
condition: ScheduledConditionItem;
dataSource?: WorkbenchRuntimeDataSource;
workOrderDataSource?: WorkbenchWorkOrderDataSource;
onContinueConversation: () => void;
onContinueConversation: (discussion?: import("../api/investigation-report").InvestigationDiscussion) => void;
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
@@ -120,6 +120,9 @@ type ConditionDetailPanelProps = {
onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onTaskRefresh?: () => void;
onLoadTaskReports?: (taskId: string, offset: number) => Promise<import("../api/operational-task-client").OperationalTaskReportPage>;
onReportViewed?: (taskId: string, generation: number, revision: number) => void;
focusActionId?: string | null;
};
export function StatusPill({
@@ -130,6 +133,10 @@ export function StatusPill({
className?: string;
}) {
const isAgentEvent = condition.kind === "condition" && condition.taskId === "water-quality-complaint";
const insufficient = condition.kind === "condition"
&& condition.operationalTask?.lifecycle_status === "completed"
&& condition.operationalTask.latest_report?.execution_generation === condition.operationalTask.execution_generation
&& condition.operationalTask.latest_report?.report.outcome === "insufficient_data";
const externalStatus = condition.kind === "work_order"
? condition.externalStatus
: condition.operationalTask?.task_type === "external_work_order"
@@ -141,14 +148,14 @@ export function StatusPill({
return (
<StatusBadge
tone={
isAgentEvent ? "neutral" : getStatusTone(badgeStatus)
insufficient ? "warning" : isAgentEvent ? "neutral" : getStatusTone(badgeStatus)
}
activity={
isAgentEvent ? "static" : getStatusActivity(badgeStatus)
}
className={className}
>
{isAgentEvent ? "事件调查" : externalStatus ? externalWorkOrderStatusLabels[externalStatus] : statusLabels[condition.status]}
{insufficient ? "已结束 · 证据不足" : isAgentEvent ? "事件调查" : externalStatus ? externalWorkOrderStatusLabels[externalStatus] : statusLabels[condition.status]}
</StatusBadge>
);
}
@@ -165,7 +172,10 @@ export function ConditionDetailPanel({
onTaskAccessDecision,
onTaskReconcile,
onLoadTaskEvidenceSnapshot,
onTaskRefresh
onTaskRefresh,
onLoadTaskReports,
onReportViewed,
focusActionId
}: ConditionDetailPanelProps) {
const detailKey =
condition.kind === "work_order"
@@ -190,6 +200,9 @@ export function ConditionDetailPanel({
onTaskReconcile={onTaskReconcile}
onLoadTaskEvidenceSnapshot={onLoadTaskEvidenceSnapshot}
onTaskRefresh={onTaskRefresh}
onLoadTaskReports={onLoadTaskReports}
onReportViewed={onReportViewed}
focusActionId={focusActionId}
/>
);
@@ -221,12 +234,15 @@ function ConditionRecordDetailPanel({
onTaskAccessDecision,
onTaskReconcile,
onLoadTaskEvidenceSnapshot,
onTaskRefresh
onTaskRefresh,
onLoadTaskReports,
onReportViewed,
focusActionId
}: {
condition: ScheduledConditionRecord;
dataSource: WorkbenchRuntimeDataSource;
workOrderDataSource: WorkbenchWorkOrderDataSource;
onContinueConversation: () => void;
onContinueConversation: ConditionDetailPanelProps["onContinueConversation"];
onTaskDecision?: ConditionDetailPanelProps["onTaskDecision"];
onTaskExecute?: ConditionDetailPanelProps["onTaskExecute"];
onTaskDraftUpdate?: ConditionDetailPanelProps["onTaskDraftUpdate"];
@@ -235,13 +251,17 @@ function ConditionRecordDetailPanel({
onTaskReconcile?: ConditionDetailPanelProps["onTaskReconcile"];
onLoadTaskEvidenceSnapshot?: ConditionDetailPanelProps["onLoadTaskEvidenceSnapshot"];
onTaskRefresh?: () => void;
onLoadTaskReports?: ConditionDetailPanelProps["onLoadTaskReports"];
onReportViewed?: ConditionDetailPanelProps["onReportViewed"];
focusActionId?: string | null;
}) {
const backendCondition = (condition.dataSource ?? dataSource) !== "mock";
const insight = backendCondition ? null : getConditionAnalysisInsight(condition);
const [submittedWorkOrder, setSubmittedWorkOrder] = useState<SubmittedIncidentWorkOrder | null>(null);
const header = getConditionDetailHeader(condition);
const riskLevel = getConditionReportRiskLevel(condition.status, condition.riskLevel);
const bodyKey = `${condition.id}-${condition.status}-${condition.riskLevel}`;
const investigation = condition.operationalTask?.task_type === "event_investigation";
const bodyKey = investigation ? condition.id : `${condition.id}-${condition.status}-${condition.riskLevel}`;
const externalWorkOrder = condition.operationalTask?.work_order_snapshot
? toScheduledWorkOrderSnapshot(condition.operationalTask.work_order_snapshot)
: null;
@@ -260,13 +280,13 @@ function ConditionRecordDetailPanel({
return (
<DetailScroll>
<DetailShell>
<DetailHeader
{!investigation ? <DetailHeader
condition={condition}
icon={header.icon}
iconClassName={header.iconClassName}
iconSpin={header.iconSpin}
hideDetail={condition.operationalTask?.task_type === "external_work_order"}
/>
/> : null}
<DetailDecisionBody>
<AnimatePresence initial={false} mode="wait">
<motion.div
@@ -290,6 +310,9 @@ function ConditionRecordDetailPanel({
<>
<AgentExecutionPlanPanel
task={condition.operationalTask}
onLoadReports={onLoadTaskReports ? (offset) => onLoadTaskReports(condition.operationalTask!.task_id, offset) : undefined}
onReportViewed={onReportViewed ? (generation, revision) => onReportViewed(condition.operationalTask!.task_id, generation, revision) : undefined}
focusActionId={focusActionId}
onContinue={onContinueConversation}
onDecision={(actionId, version, decision, comment) => {
if (!onTaskDecision) return Promise.reject(new Error("当前未配置人工确认接口。"));
@@ -19,6 +19,8 @@ import type {
} from "../types";
import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts";
import type { OperationalTaskDecision } from "../api/operational-task-client";
import type { InvestigationDiscussion } from "../api/investigation-report";
import type { OperationalTaskReportPage } from "../api/operational-task-client";
import type { OperationalTaskDataSnapshot } from "../api/operational-task-client";
import { cn } from "@/shared/ui/cn";
import {
@@ -82,7 +84,7 @@ type ScheduledConditionFeedProps = {
onClose?: () => void;
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
onSubmitPrompt: (prompt: string) => void | Promise<void>;
onOpenTaskCollaboration?: (taskId: string) => Promise<void>;
onOpenTaskCollaboration?: (taskId: string, discussion?: InvestigationDiscussion) => Promise<void>;
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
@@ -91,12 +93,15 @@ type ScheduledConditionFeedProps = {
onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onTaskRefresh?: () => void;
onLoadTaskReports?: (taskId: string, offset: number) => Promise<OperationalTaskReportPage>;
onReportViewed?: (taskId: string, generation: number, revision: number) => void;
onRefresh?: () => void | Promise<void>;
};
type ScheduledConditionFeedFocusRequest = {
conditionId: string;
requestId: number;
actionId?: string | null;
};
export function ScheduledConditionFeed({
@@ -127,6 +132,8 @@ export function ScheduledConditionFeed({
onTaskReconcile,
onLoadTaskEvidenceSnapshot,
onTaskRefresh,
onLoadTaskReports,
onReportViewed,
onRefresh
}: ScheduledConditionFeedProps) {
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
@@ -200,6 +207,7 @@ export function ScheduledConditionFeed({
null;
const focusConditionId = focusRequest?.conditionId;
const focusRequestId = focusRequest?.requestId;
const [focusedAction, setFocusedAction] = useState<{ conditionId: string; actionId: string } | null>(null);
const updateExpanded = useCallback(
(nextExpanded: boolean) => {
if (controlledExpanded === undefined) {
@@ -261,12 +269,14 @@ export function ScheduledConditionFeed({
setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL);
updateSelectedConditionId(targetCondition.id);
setFocusedAction(focusRequest?.actionId ? { conditionId: targetCondition.id, actionId: focusRequest.actionId } : null);
updateExpanded(true);
onFocusRequestHandled?.(focusRequestId);
}, [
conditions,
focusConditionId,
focusRequestId,
focusRequest?.actionId,
onFocusRequestHandled,
updateExpanded,
updateSelectedConditionId
@@ -325,14 +335,14 @@ export function ScheduledConditionFeed({
}
}
function handleContinueConversation(condition: ScheduledConditionItem) {
function handleContinueConversation(condition: ScheduledConditionItem, discussion?: InvestigationDiscussion) {
if (condition.kind === "work_order") {
return;
}
if (condition.operationalTask) {
onExpandAgent();
void Promise.resolve(onOpenTaskCollaboration?.(condition.operationalTask.task_id))
void Promise.resolve(onOpenTaskCollaboration?.(condition.operationalTask.task_id, discussion))
.catch((error) => showMapNotice({
id: "operational-task-collaboration",
tone: "error",
@@ -385,6 +395,7 @@ export function ScheduledConditionFeed({
return (
<section
aria-label="工况任务"
data-task-category={taskCategory}
aria-hidden={!visible}
className={cn(
"scheduled-feed-panel-shell pointer-events-auto overflow-hidden p-3 motion-reduce:transition-none",
@@ -506,14 +517,14 @@ export function ScheduledConditionFeed({
)}
aria-hidden={!expanded}
>
{activeLoading && expanded ? (
{activeLoading && expanded && !selectedCondition ? (
<ConditionDetailSkeleton />
) : expanded && selectedCondition ? (
<ConditionDetailPanel
condition={selectedCondition}
dataSource={dataSource}
workOrderDataSource={workOrderDataSource}
onContinueConversation={() => handleContinueConversation(selectedCondition)}
onContinueConversation={(discussion) => handleContinueConversation(selectedCondition, discussion)}
onTaskDecision={onTaskDecision}
onTaskExecute={onTaskExecute}
onTaskDraftUpdate={onTaskDraftUpdate}
@@ -522,6 +533,9 @@ export function ScheduledConditionFeed({
onTaskReconcile={onTaskReconcile}
onLoadTaskEvidenceSnapshot={onLoadTaskEvidenceSnapshot}
onTaskRefresh={onTaskRefresh}
onLoadTaskReports={onLoadTaskReports}
onReportViewed={onReportViewed}
focusActionId={focusedAction?.conditionId === selectedConditionId ? focusedAction.actionId : undefined}
/>
) : null}
</div>
@@ -1098,14 +1112,15 @@ function ConditionTimelineRow({
>
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold leading-4">
<span className="scheduled-condition-title block truncate text-xs font-semibold leading-4">
{condition.title}
</span>
<span className="block truncate text-xs leading-4 text-[var(--ink-muted)]">
{condition.summary}
</span>
</span>
<StatusPill condition={condition} className="shrink-0 text-xs" />
<StatusPill condition={condition} className="scheduled-condition-status shrink-0 text-xs" />
{condition.kind === "condition" && condition.hasNewReport ? <span className="text-xs font-medium text-[var(--action-blue)]">新报告</span> : null}
</span>
</span>
</button>
@@ -1,6 +1,7 @@
import { ShieldCheck } from "lucide-react";
import type { OperationalTask, OperationalTaskDecision } from "../api/operational-task-client";
import { AccessRequestCard, HumanActionCard } from "./agent-execution-plan-panel";
import { investigationStatus } from "../utils/investigation-presentation";
type TaskConversationReviewProps = {
task: OperationalTask;
@@ -15,12 +16,21 @@ type TaskConversationReviewProps = {
export function TaskConversationReview({ task, onAccessDecision, onActionDecision, onActionExecute,
onDraftUpdate, onConfirmDispatch, onActionReconcile, onRefresh }: TaskConversationReviewProps) {
const pendingAccess = task.access_requests.filter((request) => request.status === "pending");
const pendingAccess = (task.task_type === "event_investigation" ? [] : task.access_requests).filter((request) => request.status === "pending");
const pendingActions = task.actions.filter((action) => action.approval_status === "pending"
|| action.approval_status === "approved" && ["not_submitted", "submitted", "unknown"].includes(action.execution_status));
if (pendingAccess.length === 0 && pendingActions.length === 0) return null;
return <section aria-label="当前任务审核" className="surface-reading space-y-2 rounded-xl border border-[var(--surface-border)]/80 p-3">
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-800"><ShieldCheck size={14} aria-hidden="true" />当前任务审核 · {task.title}</div>
const recentFacts = task.evidence.filter((item) => item.kind === "human_input")
.sort((a, b) => b.recorded_at.localeCompare(a.recorded_at)).slice(0, 3);
return <section aria-label="任务协作状态" className="surface-reading space-y-2 rounded-lg border border-[var(--surface-divider)] p-3">
<div className="flex items-center gap-1.5 text-xs font-semibold text-[var(--ink)]"><ShieldCheck size={14} aria-hidden="true" />{task.title}</div>
<p className="text-xs leading-5 text-[var(--ink-muted)]">当前第 {task.execution_generation} 轮 · {investigationStatus(task)}</p>
{recentFacts.length > 0 ? <details>
<summary className="cursor-pointer py-1 text-xs text-[var(--ink-muted)]">最近已记录的人工事实 · {recentFacts.length} 条</summary>
<ul className="mt-1 space-y-2 text-xs leading-5 text-[var(--ink)]">{recentFacts.map((item) => <li key={item.evidence_id}>
<p>{item.summary}</p><p className="text-[var(--ink-muted)]">{item.source_label ?? "人工输入"} · 记录于 {new Date(item.recorded_at).toLocaleString("zh-CN", { hour12: false })}</p>
{item.observed_at ? <p className="text-[var(--ink-muted)]">现场时间 {new Date(item.observed_at).toLocaleString("zh-CN", { hour12: false })}</p> : null}
</li>)}</ul>
</details> : null}
{pendingAccess.map((request) => <AccessRequestCard key={request.access_request_id} request={request}
nodeTitle={task.latest_plan?.plan.nodes.find((node) => node.id === request.node_id)?.title}
canDecide={task.permissions.can_decide_access}
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { toAgentChatMessages, toAgentUiMessages } from "./agent-session-message-state";
describe("task context in persisted answers", () => {
it("keeps the report reference of each answer when history is restored", () => {
const reference = {
task_id: "22222222-2222-4222-8222-222222222222",
execution_generation: 1,
report_revision: 3
};
const stored = [
{
id: "answer-1",
role: "assistant",
parts: [
{ type: "data-task_context", data: reference },
{ type: "text", text: "该判断仅覆盖投诉时段的上游记录。" }
]
}
];
const messages = toAgentChatMessages(toAgentUiMessages(stored), {}, {});
expect(messages[0]?.taskContext).toEqual(reference);
expect(messages[0]?.content).toBe("该判断仅覆盖投诉时段的上游记录。");
});
it("keeps the answer readable when a report reference is invalid", () => {
const messages = toAgentChatMessages(
toAgentUiMessages([
{
id: "answer-1",
role: "assistant",
parts: [
{
type: "data-task_context",
data: { task_id: "unknown", execution_generation: -1, report_revision: 0 }
},
{ type: "text", text: "报告引用未记录。" }
]
}
]),
{},
{}
);
expect(messages[0]?.taskContext).toBeUndefined();
expect(messages[0]?.content).toBe("报告引用未记录。");
});
});
@@ -2,6 +2,7 @@ import type { UIMessage } from "ai";
import type { Dispatch, SetStateAction } from "react";
import {
applyPermissionResponse,
agentTaskContextReferenceSchema,
applyQuestionResponse,
toTodoUpdate,
upsertPermission,
@@ -16,6 +17,7 @@ import {
} from "@/features/agent";
type AgentUiDataParts = {
task_context: Record<string, unknown>;
execution_plan: Record<string, unknown>;
progress: Record<string, unknown>;
todo_update: Record<string, unknown>;
@@ -123,7 +125,10 @@ export function toAgentChatMessages(
if (!isAgentDataPart(part)) {
continue;
}
if (part.type === "data-progress") {
if (part.type === "data-task_context") {
const parsed = agentTaskContextReferenceSchema.safeParse(part.data);
if (parsed.success) next = { ...next, taskContext: parsed.data };
} else if (part.type === "data-progress") {
next = {
...next,
progress: upsertProgress(next.progress, part.data)
@@ -11,6 +11,7 @@ import {
executeOperationalTaskAction,
fetchOperationalTask,
fetchOperationalTaskEvidenceSnapshot,
fetchOperationalTaskReports,
fetchOperationalTaskNotifications,
reconcileOperationalTaskAction,
type OperationalTask,
@@ -172,7 +173,10 @@ export function useOperationalTasks(
};
}, [acknowledgeEventsThrough, eventRetryGeneration, getAccessToken, syncEvents]);
const conditions = useMemo(() => tasks.map(toOperationalTaskCondition), [tasks]);
const conditions = useMemo(() => tasks.map((task) => ({ ...toOperationalTaskCondition(task),
hasNewReport: notifications.some((item) => item.task_id === task.task_id && item.kind === "report_updated"
&& item.report_reference != null && item.lifecycle_status === "active" && item.status === "unread"),
})), [tasks, notifications]);
const createInvestigation = useCallback(async (input: CreateInvestigationInput) => {
const task = await createOperationalTask({
@@ -297,6 +301,20 @@ export function useOperationalTasks(
const loadEvidenceSnapshot = useCallback((taskId: string, evidenceId: string) =>
fetchOperationalTaskEvidenceSnapshot(taskId, evidenceId, getAccessToken), [getAccessToken]);
const loadReports = useCallback((taskId: string, offset: number) => fetchOperationalTaskReports(taskId, offset, getAccessToken), [getAccessToken]);
const markingReportsRef = useRef(new Set<string>());
const markReportRead = useCallback((taskId: string, generation: number, revision: number) => {
const unread = notifications.filter((item) => item.task_id === taskId && item.kind === "report_updated"
&& item.lifecycle_status === "active" && item.status === "unread"
&& item.report_reference?.execution_generation === generation && item.report_reference.revision <= revision
&& !markingReportsRef.current.has(item.notification_id));
for (const item of unread) {
markingReportsRef.current.add(item.notification_id);
void acknowledgeNotification(item.notification_id, item.version)
.catch((error) => setError(error instanceof Error ? error.message : "新报告标记更新失败"))
.finally(() => markingReportsRef.current.delete(item.notification_id));
}
}, [notifications, acknowledgeNotification]);
return {
error: error ?? sync.error,
@@ -313,7 +331,9 @@ export function useOperationalTasks(
confirmWorkOrderDispatch,
decideAccess,
acknowledgeNotification,
loadEvidenceSnapshot
loadEvidenceSnapshot,
loadReports,
markReportRead
};
}
@@ -361,6 +381,8 @@ function toConditionStatus(task: OperationalTask): ScheduledConditionStatus {
function toRiskLevel(task: OperationalTask): ScheduledConditionRiskLevel {
const { priority, lifecycle_status: status } = task;
if (task.latest_report?.execution_generation === task.execution_generation
&& task.latest_report?.report.outcome === "insufficient_data") return "attention";
if (status === "failed" || priority === "urgent") return "critical";
if (status === "waiting_human") return "attention";
return "normal";
@@ -10,6 +10,8 @@ import { showMapNotice } from "@/features/map/core";
import { findOwnedWorkbenchProject } from "../api/project-client";
import {
FrontendActionExecutor,
agentTaskDiscussionContextSchema,
type AgentTaskDiscussionContext,
agentBootstrapKey,
agentSessionsKey,
createAgentApiClient,
@@ -90,6 +92,7 @@ export function useWorkbenchAgent({
const [sessionId, setSessionId] = useState<string | null>(null);
const [sessionPurpose, setSessionPurpose] = useState<AgentSessionPurpose>("ordinary_chat");
const [sessionTaskId, setSessionTaskId] = useState<string | null>(null);
const taskDiscussionRef = useRef<{ sessionId: string; context: AgentTaskDiscussionContext } | null>(null);
const [registry, setRegistry] = useState<UIRegistry | null>(null);
const [sessionTitle, setSessionTitle] = useState("新对话");
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
@@ -231,7 +234,11 @@ export function useWorkbenchAgent({
api: `${env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")}/api/v1/agent/chat/stream`,
headers: async (): Promise<Record<string, string>> => {
const accessToken = await getAccessToken?.();
return accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
const projectId = await getProjectId();
return {
...(projectId ? { "X-Project-Id": projectId } : {}),
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {})
};
},
prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) {
return {
@@ -248,7 +255,7 @@ export function useWorkbenchAgent({
};
}
}),
[getAccessToken]
[getAccessToken, getProjectId]
);
const chat = useChat<AgentUiMessage>({
@@ -575,7 +582,7 @@ export function useWorkbenchAgent({
[applySessionId, chat, refreshSessionHistory, startSessionStreamSubscription, stopSessionStreamSubscription, streaming]
);
const openTaskCollaboration = useCallback(async (taskId: string) => {
const openTaskCollaboration = useCallback(async (taskId: string, context?: AgentTaskDiscussionContext) => {
const normalizedTaskId = taskId.trim();
if (!normalizedTaskId) {
throw new Error("工况任务 ID 不能为空。");
@@ -586,6 +593,7 @@ export function useWorkbenchAgent({
throw new Error("Agent 未返回任务协作会话。");
}
await loadHistorySession(collaborationSessionId);
taskDiscussionRef.current = { sessionId: collaborationSessionId, context: agentTaskDiscussionContextSchema.parse(context ?? {}) };
return collaborationSessionId;
}, [loadHistorySession]);
@@ -709,7 +717,8 @@ export function useWorkbenchAgent({
body: {
session_id: sessionIdRef.current ?? undefined,
model: selectedModel || undefined,
approval_mode: approvalMode
approval_mode: approvalMode,
task_context: taskDiscussionRef.current?.sessionId === sessionIdRef.current ? taskDiscussionRef.current.context : undefined
}
}
);
+16 -9
View File
@@ -42,6 +42,7 @@ import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
import { FeaturePopover } from "./components/feature-popover";
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
import { TaskConversationReview } from "./components/task-conversation-review";
import type { InvestigationDiscussion } from "./api/investigation-report";
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
import { OperationalTimeline } from "./operational-timeline/operational-timeline";
import {
@@ -144,6 +145,7 @@ export function MapWorkbenchPage({
const [conditionFocusRequest, setConditionFocusRequest] = useState<{
conditionId: string;
requestId: number;
actionId?: string | null;
} | null>(null);
const [activeBaseLayerId, setActiveBaseLayerId] = useState(
hasMapboxToken ? "mapbox-light" : hasTiandituToken ? "tianditu-vector" : "none"
@@ -766,13 +768,9 @@ export function MapWorkbenchPage({
}
function handleSelectAlert(alert: WorkbenchAlert) {
if (alert.id.startsWith("operational-task-notification:")) {
const notificationId = alert.id.slice("operational-task-notification:".length);
const notificationId = alert.id.startsWith("operational-task-notification:")
? alert.id.slice("operational-task-notification:".length) : null;
const notification = operationalTasks.notifications.find((item) => item.notification_id === notificationId);
if (notification?.status === "unread") {
void operationalTasks.acknowledgeNotification(notificationId, notification.version);
}
}
const conditionId = alert.conditionId ?? getScheduledConditionIdFromAlertId(alert.id);
if (conditionId) {
const condition = scheduledConditions.find((item) => item.id === conditionId);
@@ -788,8 +786,12 @@ export function MapWorkbenchPage({
openConditionWorkspaceForViewport();
setConditionFocusRequest((current) => ({
conditionId,
requestId: (current?.requestId ?? 0) + 1
requestId: (current?.requestId ?? 0) + 1,
actionId: notification?.subject_type === "action" ? notification.subject_id : null
}));
if (notification?.status === "unread") {
void operationalTasks.acknowledgeNotification(notification.notification_id, notification.version);
}
return;
}
@@ -871,9 +873,10 @@ export function MapWorkbenchPage({
await agent.loadHistorySession(sessionId);
}
async function handleOpenTaskCollaboration(taskId: string) {
async function handleOpenTaskCollaboration(taskId: string, discussion?: InvestigationDiscussion) {
mapController.clearScadaAnalysis();
await agent.openTaskCollaboration(taskId);
await agent.openTaskCollaboration(taskId, discussion?.context);
if (discussion?.prompt) await agent.submitPrompt(discussion.prompt);
}
async function handleDeleteAgentHistorySession(sessionId: string) {
@@ -1330,6 +1333,8 @@ export function MapWorkbenchPage({
onTaskReconcile={operationalTasks.reconcileAction}
onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot}
onTaskRefresh={operationalTasks.refresh}
onLoadTaskReports={operationalTasks.loadReports}
onReportViewed={operationalTasks.markReportRead}
onRefresh={workbenchSync.refresh}
/>
</div>
@@ -1480,6 +1485,8 @@ export function MapWorkbenchPage({
onTaskReconcile={operationalTasks.reconcileAction}
onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot}
onTaskRefresh={operationalTasks.refresh}
onLoadTaskReports={operationalTasks.loadReports}
onReportViewed={operationalTasks.markReportRead}
onRefresh={workbenchSync.refresh}
/>
)}
+1
View File
@@ -134,6 +134,7 @@ export type ScheduledConditionRecord = ScheduledConditionBase<ScheduledCondition
kpis?: ScheduledConditionKpi[];
report?: ScheduledConditionReport;
operationalTask?: OperationalTask;
hasNewReport?: boolean;
};
export type ScheduledWorkOrderItem = ScheduledConditionBase<ScheduledWorkOrderStatus> & {
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { styleInvestigationDiagram } from "./investigation-diagram-style";
describe("investigation diagram presentation", () => {
it("retains source relationships and only styles declared business references", () => {
const source = 'flowchart TD\nA["核查"] --> B;\nB -->|资料不足| C["记录缺口"]';
const styled = styleInvestigationDiagram({
title: "调查脉络",
mermaid: source,
node_refs: [
{ node_id: "B", label: "判断", evidence_refs: ["e1"], action_refs: [] },
{ node_id: "missing", label: "历史引用", evidence_refs: ["e2"], action_refs: [] }
]
});
expect(styled.startsWith(source)).toBe(true);
expect(styled).toContain("class B tjEvidence;");
expect(styled).not.toContain("class missing ");
expect(styled).not.toMatch(/completed|running|succeeded/);
});
});
@@ -0,0 +1,109 @@
import { mermaid, type MermaidConfig } from "@streamdown/mermaid";
import type { InvestigationDiagram } from "../api/investigation-report";
export const INVESTIGATION_DIAGRAM_FONT =
'"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif';
export const investigationDiagramConfig: MermaidConfig = {
securityLevel: "strict",
theme: "base",
htmlLabels: true,
fontFamily: INVESTIGATION_DIAGRAM_FONT,
themeVariables: {
fontFamily: INVESTIGATION_DIAGRAM_FONT,
fontSize: "15px",
background: "#FFFFFF",
primaryColor: "#FFFFFF",
primaryTextColor: "#26334D",
primaryBorderColor: "#A5B5CC",
secondaryColor: "#FFF6E8",
secondaryTextColor: "#78531B",
secondaryBorderColor: "#C3A575",
tertiaryColor: "#F6F8FC",
tertiaryBorderColor: "#CCD6E5",
tertiaryTextColor: "#475976",
lineColor: "#7689A6",
edgeLabelBackground: "#FFFFFF",
clusterBkg: "#F6F8FC",
clusterBorder: "#CCD6E5"
},
flowchart: {
curve: "stepAfter",
nodeSpacing: 56,
rankSpacing: 64,
padding: 22,
diagramPadding: 24,
subGraphTitleMargin: { top: 12, bottom: 48 },
wrappingWidth: 260
},
themeCSS: `
.node rect { rx: 6px; ry: 6px; stroke-width: 1.4px; }
.node polygon { stroke-width: 1.6px; }
.nodeLabel { font-weight: 500; }
.nodeLabel p { text-wrap: balance; line-height: 1.6; }
.edgePath path, .flowchart-link { stroke-width: 1.35px; }
.edgeLabel { color: #536784; font-size: 12px; }
.edgeLabel p { padding: 3px 7px; border-radius: 4px; line-height: 1.5; }
.cluster rect { rx: 8px; ry: 8px; stroke-width: 1px; }
.cluster-label { font-weight: 600; font-size: 13px; }
.cluster-label .nodeLabel { background: #F6F8FC; padding: 0 4px; }
`
};
/** Keep group captions beside crossing links, without moving nodes or changing edges. */
export const investigationDiagramPlugins = {
mermaid: {
...mermaid,
getMermaid(config?: MermaidConfig) {
const renderer = mermaid.getMermaid(config);
return {
initialize: (nextConfig: MermaidConfig) => renderer.initialize(nextConfig),
async render(id: string, source: string) {
const result = await renderer.render(id, source);
const svg = new DOMParser().parseFromString(result.svg, "image/svg+xml");
svg.querySelectorAll(".cluster").forEach((cluster) => {
const rect = Array.from(cluster.children).find((child) => child.tagName === "rect");
const caption = cluster.querySelector(".cluster-label");
if (!rect || !caption) return;
const x = Number(rect.getAttribute("x"));
const y = Number(rect.getAttribute("y"));
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
caption.setAttribute(
"transform",
`translate(${x + 18}, ${y + (config?.flowchart?.subGraphTitleMargin?.top ?? 12)})`
);
// Mermaid paints crossing edges after clusters; keep captions above those edges.
const clusters = cluster.parentElement;
if (clusters?.classList.contains("clusters")) clusters.parentElement?.append(caption);
});
return { ...result, svg: new XMLSerializer().serializeToString(svg.documentElement) };
}
};
}
}
};
const roleStyles = [
"classDef tjAgent fill:#EEF3FF,stroke:#7A93CB,color:#284574,stroke-width:1.4px;",
"classDef tjHuman fill:#FFF6E8,stroke:#C4A168,color:#735018,stroke-width:1.4px;",
"classDef tjDecision fill:#4263C7,stroke:#4263C7,color:#FFFFFF,stroke-width:1.5px;",
"classDef tjEvidence fill:#EDF7F4,stroke:#6B9E8B,color:#2D5E4E,stroke-width:1.6px;",
"classDef tjAction fill:#FFF6E8,stroke:#C4A168,color:#735018,stroke-width:1.6px;"
];
/** Style supplied relationships; never create nodes from a dangling display reference. */
export function styleInvestigationDiagram(diagram: InvestigationDiagram) {
const assignments = diagram.node_refs.flatMap((node) => {
const escaped = node.node_id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const inSource = new RegExp(`\\b${escaped}(?=\\s*(?:[\\[({@;]|--|==|-\\.|$))`, "m").test(
diagram.mermaid
);
if (!inSource) return [];
return node.action_refs.length
? [`class ${node.node_id} tjAction;`]
: node.evidence_refs.length
? [`class ${node.node_id} tjEvidence;`]
: [];
});
return [diagram.mermaid, ...roleStyles, ...assignments].join("\n");
}
@@ -0,0 +1,104 @@
import type { InvestigationDiagram } from "../api/investigation-report";
import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client";
const diagramLabel = (value: string) =>
value
.replace(/[\r\n]/g, " ")
.replace(/"/g, "#quot;")
.replace(/</g, "#lt;")
.replace(/>/g, "#gt;")
.replace(/`/g, "#96;");
/** Preserve recorded relationships, without turning plan statuses into execution facts. */
export function investigationDiagram(
task: OperationalTask,
snapshot: OperationalTaskReport | null
): InvestigationDiagram | null {
if (snapshot?.report.diagram) return snapshot.report.diagram;
const plan = task.latest_plan;
if (
!plan ||
(snapshot
? snapshot.revision !== task.latest_report?.revision ||
plan.execution_generation !== snapshot.execution_generation
: plan.execution_generation !== task.execution_generation)
)
return null;
const ids = new Map(plan.plan.nodes.map((node, index) => [node.id, `n${index}`]));
const nodeDefinition = (node: (typeof plan.plan.nodes)[number]) => {
const label = `\`${diagramLabel(node.title)}\``;
return node.kind === "decision" || node.kind === "gate"
? `${ids.get(node.id)}{"${label}"}:::tjDecision`
: `${ids.get(node.id)}["${label}"]:::${node.owner === "human" ? "tjHuman" : "tjAgent"}`;
};
const agentNodes = plan.plan.nodes.filter((node) => node.owner === "agent");
const humanNodes = plan.plan.nodes.filter((node) => node.owner === "human");
const nodes =
agentNodes.length && humanNodes.length
? [
'subgraph tj_agent_group["Agent · 资料核查"]',
...agentNodes.map(nodeDefinition),
"end",
'subgraph tj_human_group["人工 · 现场协作"]',
...humanNodes.map(nodeDefinition),
"end"
]
: plan.plan.nodes.map(nodeDefinition);
const edges = plan.plan.edges.map((edge) =>
edge.condition
? `${ids.get(edge.from)} -->|"${diagramLabel(edge.condition)}"| ${ids.get(edge.to)}`
: `${ids.get(edge.from)} --> ${ids.get(edge.to)}`
);
return {
title: "调查脉络",
mermaid: ["flowchart TD", ...nodes, ...edges].join("\n"),
node_refs: plan.plan.nodes
.filter((node) => node.evidence_refs?.length || node.action_refs?.length)
.map((node) => ({
node_id: ids.get(node.id)!,
label: node.title,
evidence_refs: node.evidence_refs ?? [],
action_refs: node.action_refs ?? []
}))
};
}
export function investigationStatus(task: OperationalTask): string {
if (task.active_run?.status === "queued") return "等待调查";
if (task.active_run?.status === "claimed") return "调查启动中";
if (task.active_run?.status === "running") return "调查中";
if (task.lifecycle_status === "failed") return "本轮执行失败";
if (task.lifecycle_status === "cancelled") return "本轮已取消";
if (task.actions.some((action) => action.approval_status === "pending")) return "待人工审核";
if (
task.actions.some(
(action) =>
action.approval_status === "approved" && action.execution_status === "not_submitted"
)
)
return "待执行动作";
if (
task.actions.some(
(action) =>
action.approval_status === "approved" &&
["submitted", "unknown"].includes(action.execution_status)
)
)
return "待核对回执";
if (
task.latest_report?.execution_generation === task.execution_generation &&
task.latest_report.report.outcome === "insufficient_data" &&
task.lifecycle_status === "completed"
)
return "已结束 · 证据不足";
if (task.lifecycle_status === "completed") return "本轮调查完成";
return "调查中";
}
export function isPendingInvestigationAction(action: OperationalTask["actions"][number]) {
return (
action.approval_status === "pending" ||
(action.approval_status === "approved" &&
["not_submitted", "submitted", "unknown"].includes(action.execution_status))
);
}
+239
View File
@@ -1257,6 +1257,245 @@ button.agent-history-session-trigger:not(:disabled):active {
column-gap: 0;
}
/* Investigation reading surface: diagrams and evidence share the report's typography. */
.investigation-report {
min-width: 0;
border: 1px solid var(--surface-divider);
border-radius: 0.75rem;
overflow: hidden;
}
.investigation-report-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem;
}
.investigation-report-header > button { flex-shrink: 0; }
.investigation-report-section {
min-width: 0;
padding: 1rem;
border-top: 1px solid var(--surface-divider);
}
.investigation-report-update {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--action-selection-soft);
color: var(--ink);
font-size: 0.75rem;
}
.investigation-action-summary {
display: grid;
gap: 0.125rem;
}
.investigation-action-summary-detail {
color: var(--ink-muted);
font-size: 0.6875rem;
font-weight: 400;
line-height: 1.25rem;
}
.investigation-timeline {
position: relative;
display: grid;
gap: 0.75rem;
padding-block: 0.25rem 0.5rem;
}
.investigation-timeline::before {
position: absolute;
inset-block: 0.65rem 0.9rem;
inset-inline-start: 0.1875rem;
width: 1px;
background: var(--surface-divider);
content: "";
}
.investigation-timeline-item {
position: relative;
padding-inline-start: 1rem;
}
.investigation-timeline-item::before {
position: absolute;
inset-block-start: 0.45rem;
inset-inline-start: 0;
width: 0.4375rem;
height: 0.4375rem;
border: 1px solid var(--action-blue);
border-radius: 999px;
background: var(--surface-reading);
content: "";
}
.investigation-diagram {
min-width: 0;
overflow: hidden;
border: 1px solid var(--surface-divider);
border-radius: 0.75rem;
background: var(--palette-paper);
}
.investigation-diagram [data-streamdown="mermaid"] { margin-block: 0; }
.investigation-diagram .font-mono { display: none; }
.investigation-diagram [data-streamdown="mermaid"] { background: var(--palette-paper); }
.investigation-diagram [data-streamdown="mermaid"] [role="img"] { width: 100%; }
.investigation-diagram .node { cursor: pointer; }
.investigation-diagram .node:hover { filter: brightness(0.97); }
/* Match Button's compact icon-sm size, including controls portalled to body. */
button[type="button"]:has(> .investigation-diagram-control-icon) {
--diagram-control-size: 2rem;
--diagram-control-icon-size: 1rem;
position: relative;
display: grid;
width: var(--diagram-control-size);
height: var(--diagram-control-size);
flex: 0 0 auto;
place-items: center;
padding: 0;
border: 1px solid transparent;
border-radius: 0.5rem;
background: transparent;
color: var(--ink-muted);
cursor: pointer;
}
button[type="button"]:has(> .investigation-diagram-control-icon)::after {
position: absolute;
inset: -4px;
content: "";
}
.investigation-diagram-control-icon {
width: var(--diagram-control-icon-size);
height: var(--diagram-control-icon-size);
flex-shrink: 0;
pointer-events: none;
}
div:has(> button > [data-investigation-diagram-control="zoom-in"]),
.investigation-diagram [data-streamdown="mermaid-block-actions"] {
gap: 8px;
padding: 4px;
border: 1px solid var(--surface-border);
border-radius: var(--radius-control);
background: var(--surface-control);
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.investigation-diagram [data-streamdown="mermaid-block"] > div:first-child { display: none; }
.investigation-diagram [data-streamdown="mermaid-block"] > div:has(> [data-streamdown="mermaid-block-actions"]) {
height: auto;
margin-top: 0;
}
button[type="button"]:has(> [data-investigation-diagram-control="close"]) {
position: absolute;
border-color: var(--surface-border);
background: var(--surface-control);
}
@media (hover: hover) {
button[type="button"]:has(> .investigation-diagram-control-icon):hover:not(:disabled) {
border-color: var(--action-border-hover);
background: var(--action-soft);
color: var(--action-blue-hover);
}
}
button[type="button"]:has(> .investigation-diagram-control-icon):active:not(:disabled) {
border-color: var(--action-border-pressed);
background: var(--action-soft-pressed);
color: var(--action-blue-pressed);
box-shadow: var(--action-shadow-secondary-pressed);
transform: scale(0.98);
}
button[type="button"]:has(> .investigation-diagram-control-icon):focus-visible {
outline: 2px solid var(--action-focus-ring);
outline-offset: 2px;
}
button[type="button"]:has(> .investigation-diagram-control-icon):disabled {
pointer-events: none;
opacity: 0.45;
}
.investigation-node-link,
.investigation-history-row {
border: 1px solid var(--surface-divider);
border-radius: 0.5rem;
padding: 0.5rem 0.625rem;
color: var(--ink-muted);
text-align: left;
font-size: 0.75rem;
line-height: 1.25rem;
transition: background-color 120ms, color 120ms, transform 120ms;
}
.investigation-node-link[aria-pressed="true"],
.investigation-history-row[aria-pressed="true"] {
border-color: var(--action-selection-border);
background: var(--action-selection-soft);
color: var(--ink);
}
.investigation-history-row {
display: flex;
width: 100%;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.5rem;
margin-block: 0.5rem;
}
.investigation-text-action {
padding-block: 0.375rem;
color: var(--action-blue-hover);
font-size: 0.75rem;
}
.investigation-node-link:focus-visible,
.investigation-history-row:focus-visible,
.investigation-text-action:focus-visible {
outline: 2px solid var(--action-focus-ring);
outline-offset: 2px;
}
.investigation-node-link:active,
.investigation-history-row:active,
.investigation-text-action:active { transform: scale(0.98); }
@media (hover: hover) {
.investigation-node-link:hover,
.investigation-history-row:hover { background: var(--action-selection-soft); color: var(--ink); }
.investigation-text-action:hover { text-decoration: underline; }
}
@media (max-width: 400px) {
.investigation-report-header { flex-wrap: wrap; }
.investigation-report-section,
.investigation-report-header { padding: 0.75rem; }
}
@media (prefers-reduced-motion: reduce) {
.investigation-node-link,
.investigation-history-row { transition: none; }
}
.scheduled-feed-shell-enter {
animation:
scheduled-feed-shell-show 170ms cubic-bezier(0.22, 1, 0.36, 1),
+233 -20
View File
@@ -1,6 +1,8 @@
import { expect, test } from "@playwright/test";
import type { OperationalTask, OperationalTaskSummary } from "../../src/features/workbench/api/operational-task-client";
import { writeFile } from "node:fs/promises";
import type { OperationalTask, OperationalTaskNotification, OperationalTaskSummary } from "../../src/features/workbench/api/operational-task-client";
import { mockAgentApi } from "./support/mock-agent-api";
import { expectThemeColor } from "./support/theme-colors";
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
const taskId = "82000000-0000-4000-8000-000000000001";
@@ -8,7 +10,7 @@ const actionId = "83000000-0000-4000-8000-000000000001";
const accessId = "84000000-0000-4000-8000-000000000001";
const now = "2026-09-24T08:00:00Z";
test("动态调查显示选定路线与嵌套并行,并完成授权和工单草稿确认", async ({ page }, testInfo) => {
test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常结束", async ({ page }, testInfo) => {
test.setTimeout(90_000);
await mockAgentApi(page);
await page.route("**/runtime-config.js", (route) => route.fulfill({ contentType: "text/javascript", body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
@@ -34,7 +36,7 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单
planNode("split", "同步核对现场与数据", "parallel_split", "completed"),
planNode("field", "现场采样", "activity", "waiting"),
planNode("data-split", "多源数据核对", "parallel_split", "completed"),
planNode("upstream", "上游监测", "activity", "completed"),
{ ...planNode("upstream", "上游监测", "activity", "completed"), evidence_refs: ["monitor-1"] },
planNode("network", "管网运行", "activity", "completed"),
planNode("data-join", "数据证据汇合", "parallel_join", "completed"),
planNode("join", "现场与数据汇合", "parallel_join", "waiting"),
@@ -64,7 +66,14 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单
agent_session_id: "dynamic-flow-session", agent_lease_expires_at: null, current_plan_revision: 1, current_report_revision: null,
created_at: now, updated_at: now,
latest_plan: { revision: 1, execution_generation: 1, source_session_id: "dynamic-flow-session", published_at: now, published_by: "agent", plan },
latest_report: null, evidence: [],
latest_report: { revision: 1, execution_generation: 1, source_session_id: "dynamic-flow-session",
published_at: now, published_by: "agent", report: {
title: "水质投诉调查报告", current_conclusion: "上游记录未见同期异常,需现场核查用户端。", conclusion_boundary: "仅覆盖已有上游监测记录。",
evidence_refs: ["monitor-1"], recommendations: ["现场核查"], pending_items: [], stage: "interim", outcome: "concluded", stop_reason: null, data_gaps: [],
diagram: { title: "调查脉络", mermaid: 'flowchart TD\nA["水质投诉"] --> B{"紧急风险?"}\nB -->|有| C["现场核查"]\nB -->|无| D["常规核查"]\nA --> E["供水路径"]\nsubgraph F["多源证据核对"]\nE --> G["上游监测"]\nE --> H["管网运行"]\nE --> I["相关投诉"]\nend\nG --> J["汇总判断"]\nH --> J\nI --> J\nC --> J\nD --> J\nJ --> K["动作草稿"]\nK --> L{"人工审核"}\nL -->|批准| M["执行与回执"]\nL -->|修改| K',
node_refs: [{ node_id: "G", label: "上游监测", evidence_refs: ["monitor-1"], action_refs: [] },
{ node_id: "K", label: "动作草稿", evidence_refs: [], action_refs: [actionId] }] }
} }, evidence: [{ evidence_id: "monitor-1", kind: "system_data", summary: "上游监测数据质量已核对", source_label: "SCADA 水质监测", source_ref: "WQ-01", observed_at: now, recorded_at: now, recorded_by: "agent", has_snapshot: false }],
access_requests: [{ access_request_id: accessId, task_id: taskId, node_id: "data-split", scopes: ["scada_devices", "scada_readings"],
reason: "需要核对上游设备和读数", status: "pending", approved_scopes: [], version: 1,
requested_by: "agent", requested_at: now, decided_by: null, decided_at: null, comment: null }],
@@ -77,6 +86,8 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单
can_decide_work_orders: true, can_execute_work_orders: true, can_decide_commands: false, can_execute_commands: false, can_decide_access: true },
work_order_snapshot: null
};
const firstReport = task.latest_report!;
let notifications: OperationalTaskNotification[] = [];
const summary = (item: OperationalTask): OperationalTaskSummary => {
const { latest_plan, latest_report, evidence, actions, access_requests, permissions, work_order_snapshot, ...value } = item;
void latest_plan; void latest_report; void evidence; void actions; void access_requests; void permissions; void work_order_snapshot;
@@ -87,16 +98,24 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单
runtime: { availability: "not_configured", source: "none", scenario_availability: "not_configured", artifact_availability: "not_configured",
schedule_availability: "not_configured", work_order_availability: "not_configured", alert_availability: "not_configured",
generated_at: now, conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: [] },
tasks: [summary(task)], notifications: [], generated_at: now
tasks: [summary(task)], notifications, generated_at: now
} }));
await page.route("http://server.test/api/v1/workbench/events**", (route) => route.fulfill({ contentType: "text/event-stream", body: ": keepalive\n\n" }));
await page.route("http://server.test/api/v1/operational-task-notifications**", (route) => route.fulfill({ json: { items: [], limit: 100, offset: 0, next_offset: null } }));
await page.route("http://server.test/api/v1/operational-task-notifications**", (route) => {
if (route.request().method() === "POST") {
expect(route.request().postDataJSON().expected_version).toBe(1);
notifications = notifications.map((item) => ({ ...item, version: 2, status: "read", read_at: now, read_by: "reviewer" }));
return route.fulfill({ json: notifications[0] });
}
return route.fulfill({ json: { items: notifications, limit: 100, offset: 0, next_offset: null } });
});
await page.route("http://server.test/api/v1/operational-tasks**", async (route) => {
const path = new URL(route.request().url()).pathname;
const method = route.request().method();
if (path.endsWith("/operational-tasks/schedules")) return route.fulfill({ json: { items: [] } });
if (method === "GET" && path.endsWith("/operational-tasks")) return route.fulfill({ json: { items: [summary(task)], limit: 50, offset: 0, next_offset: null } });
if (method === "GET" && path.endsWith(`/operational-tasks/${taskId}`)) return route.fulfill({ json: task });
if (method === "GET" && path.endsWith(`/operational-tasks/${taskId}/reports`)) return route.fulfill({ json: { items: [task.latest_report, firstReport].filter((item, index, items) => items.findIndex((other) => other?.revision === item?.revision) === index), next_offset: null } });
if (method === "POST" && path.endsWith(`/access-requests/${accessId}/decision`)) {
const body = route.request().postDataJSON();
expect(body.approved_scopes).toEqual(["scada_devices"]);
@@ -107,6 +126,7 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单
if (method === "PATCH" && path.endsWith(`/actions/${actionId}/draft`)) {
const body = route.request().postDataJSON();
expect(body.payload.title).toBe("现场采样修订稿");
expect(body.payload.work_order_type).toBe("general");
const action = { ...task.actions[0], version: 2, payload: body.payload, edited_by: "reviewer", edited_at: now };
task = { ...task, actions: [action] };
return route.fulfill({ json: action });
@@ -129,35 +149,185 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单
const panel = page.getByRole("region", { name: "工况任务", exact: true });
const expand = panel.getByRole("button", { name: "展开工况任务" });
if (await expand.isVisible()) await expand.click();
const desktopColumns = panel.locator(".scheduled-feed-layout-expanded > div");
const captureColumns = async () => {
const first = await desktopColumns.nth(0).boundingBox();
const second = await desktopColumns.nth(1).boundingBox();
expect(first).not.toBeNull();
expect(second).not.toBeNull();
return [first!, second!].map((box) => ({ x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) }));
};
await expect(panel).toHaveCSS("width", "880px");
const scheduledColumns = await captureColumns();
await testInfo.attach("scheduled-tab-alignment", { body: await panel.screenshot({ path: testInfo.outputPath("scheduled-tab-alignment.png") }), contentType: "image/png" });
await panel.getByRole("button", { name: /预约任务/ }).click();
await expect.poll(captureColumns).toEqual(scheduledColumns);
const appointmentColumns = await captureColumns();
await panel.getByRole("button", { name: /调查任务/ }).click();
await panel.getByText("水质投诉动态调查", { exact: true }).first().click();
await expect(panel.getByText(/路线选择:/).first().locator("..")).toContainText("有紧急风险");
await expect(panel.getByLabel("并行处理 2 路")).toHaveCount(2);
await expect(panel.locator('[data-execution-node="data-join"]')).toHaveCount(1);
await expect(panel.getByText("其他规划路线 1 项")).toBeVisible();
await expect(panel.getByRole("heading", { name: "当前结果" })).toBeVisible();
await expect.poll(captureColumns).toEqual(scheduledColumns);
await writeFile(testInfo.outputPath("tab-column-geometry.json"), JSON.stringify({
viewport: page.viewportSize(), scheduled: scheduledColumns, appointment: appointmentColumns,
investigation: await captureColumns()
}, null, 2));
const reportBounds = await desktopColumns.nth(0).boundingBox();
const itemBounds = await desktopColumns.nth(1).boundingBox();
expect(reportBounds).not.toBeNull();
expect(itemBounds).not.toBeNull();
expect(reportBounds!.x + reportBounds!.width).toBeLessThanOrEqual(itemBounds!.x);
expect(reportBounds!.width).toBeGreaterThan(itemBounds!.width);
await testInfo.attach("investigation-tab-alignment", { body: await panel.screenshot({ path: testInfo.outputPath("investigation-tab-alignment.png") }), contentType: "image/png" });
const diagram = panel.getByLabel("调查脉络", { exact: true });
await diagram.scrollIntoViewIfNeeded();
await expect(diagram.locator("svg[id]")).toHaveCount(1);
await expect(diagram).toContainText("常规核查");
const captureDiagramControls = async (surface: typeof diagram) => {
return surface.locator("button[title]").evaluateAll((buttons) => buttons.map((button) => {
const rect = button.getBoundingClientRect();
const style = getComputedStyle(button);
const icon = button.querySelector("svg");
const iconRect = icon?.getBoundingClientRect();
return { title: button.getAttribute("title"), x: rect.x, y: rect.y, width: rect.width, height: rect.height,
radius: style.borderRadius, iconWidth: iconRect?.width, iconHeight: iconRect?.height };
}));
};
const inlineControls = await captureDiagramControls(diagram);
const assertControlGeometry = (controls: typeof inlineControls) => {
expect(controls).toHaveLength(4);
for (const control of controls) {
expect({ width: control.width, height: control.height, radius: control.radius,
iconWidth: control.iconWidth, iconHeight: control.iconHeight }).toEqual({
width: 32, height: 32, radius: "8px", iconWidth: 16, iconHeight: 16
});
}
};
assertControlGeometry(inlineControls);
const zoomIn = diagram.getByRole("button", { name: "Zoom in", exact: true });
const zoomOut = diagram.getByRole("button", { name: "Zoom out", exact: true });
const resetZoom = diagram.getByRole("button", { name: "Reset zoom and pan", exact: true });
const diagramCanvas = diagram.locator('[role="application"]');
await zoomIn.hover();
await expectThemeColor(zoomIn, "background-color", "--action-soft");
await expectThemeColor(zoomIn, "color", "--action-blue-hover");
const zoomBox = (await zoomIn.boundingBox())!;
// The compact icon keeps a 40px hit area outside its 32px visible frame.
await page.mouse.move(zoomBox.x - 2, zoomBox.y + zoomBox.height / 2);
await page.mouse.down();
await expectThemeColor(zoomIn, "background-color", "--action-soft-pressed");
await page.mouse.up();
await expect(diagramCanvas).toHaveCSS("transform", "matrix(1.1, 0, 0, 1.1, 0, 0)");
await resetZoom.click();
await expect(diagramCanvas).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 0)");
for (let index = 0; index < 8 && await zoomOut.isEnabled(); index++) await zoomOut.click();
await expect(zoomOut).toBeDisabled();
await expect(zoomOut).toHaveCSS("opacity", "0.45");
await resetZoom.click();
await expect(zoomOut).toBeEnabled();
await zoomIn.focus();
await page.keyboard.press("Tab");
await page.keyboard.press("Shift+Tab");
await expect(zoomIn).toBeFocused();
await expect(zoomIn).toHaveCSS("outline-width", "2px");
await expectThemeColor(zoomIn, "outline-color", "--action-focus-ring");
await zoomIn.locator("..").screenshot({ path: testInfo.outputPath("diagram-controls-keyboard-focus.png") });
await testInfo.attach("styled-investigation-diagram", { body: await diagram.screenshot({ path: testInfo.outputPath("styled-investigation-diagram.png") }), contentType: "image/png" });
await diagram.getByRole("button", { name: "全屏查看", exact: true }).click();
const fullscreen = page.locator('body > div[role="button"]').filter({ has: page.getByRole("button", { name: "退出全屏", exact: true }) });
await expect(fullscreen.locator("svg[id]")).toHaveCount(1);
const fullscreenControls = await captureDiagramControls(fullscreen);
assertControlGeometry(fullscreenControls);
await writeFile(testInfo.outputPath("diagram-control-geometry.json"), JSON.stringify({ viewport: page.viewportSize(), inline: inlineControls, fullscreen: fullscreenControls }, null, 2));
await testInfo.attach("styled-investigation-fullscreen", { body: await page.screenshot({ path: testInfo.outputPath("styled-investigation-fullscreen.png") }), contentType: "image/png" });
await fullscreen.getByRole("button", { name: "退出全屏", exact: true }).click();
await expect(fullscreen).toHaveCount(0);
await diagram.getByRole("button", { name: "全屏查看", exact: true }).click();
await expect(fullscreen).toBeVisible();
await page.keyboard.press("Escape");
await expect(fullscreen).toHaveCount(0);
await diagram.locator(".node").filter({ hasText: "上游监测" }).click({ timeout: 5000 });
await expect(panel.getByLabel("节点详情 上游监测")).toBeVisible();
await diagram.getByRole("button", { name: "上游监测 · 1 条依据" }).click();
await diagram.getByRole("button", { name: "上游监测 · 1 条依据" }).focus();
await page.keyboard.press("Enter");
await expect(panel.getByLabel("节点详情 上游监测")).toContainText("上游监测数据质量已核对");
await testInfo.attach("dynamic-investigation-desktop", {
body: await panel.screenshot({ path: testInfo.outputPath("dynamic-investigation-desktop.png") }), contentType: "image/png"
});
await panel.getByRole("button", { name: "Agent 协作" }).click();
const agentPanel = page.getByRole("complementary", { name: "Agent 命令面板" });
const review = agentPanel.getByRole("region", { name: "当前任务审核" });
await expect(review).toContainText("需要核对上游设备和读数");
const review = agentPanel.getByRole("region", { name: "任务协作状态" });
await expect(review).toContainText("现场采样需要派单");
await expect(review.getByRole("heading", { name: "关联预约工单" })).toBeVisible();
await expect(review).toContainText("调查任务只保留创建关联");
await expect(review).toContainText("预约工单");
await expect(review.getByText("数据访问申请", { exact: true })).toHaveCount(0);
await testInfo.attach("linked-appointment-review-desktop", {
body: await review.screenshot({ path: testInfo.outputPath("linked-appointment-review-desktop.png") }), contentType: "image/png"
});
await page.setViewportSize({ width: 375, height: 720 });
const mobileAgentSheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
if (!await mobileAgentSheet.isVisible()) await page.getByRole("button", { name: "打开 Agent 面板" }).click();
await expect(mobileAgentSheet.getByRole("region", { name: "当前任务审核" })).toBeVisible();
await expect(mobileAgentSheet.getByRole("region", { name: "任务协作状态" })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
await page.setViewportSize({ width: 1280, height: 720 });
await expect(review).toBeVisible();
await review.getByLabel("SCADA 监测读数").uncheck();
await review.getByRole("button", { name: "批准所选范围" }).click();
await expect(panel.getByText("已授权:SCADA 设备")).toBeVisible();
await expect(panel.getByLabel("数据访问审核")).toHaveCount(0);
await review.getByRole("button", { name: "编辑草稿" }).click();
await expect(review.getByRole("combobox", { name: "工单类型" })).toHaveCount(0);
await expect(review).toContainText("工单归属");
await expect(review).toContainText("预约任务");
await review.getByRole("textbox", { name: "工单标题" }).fill("现场采样修订稿");
await review.getByRole("button", { name: "保存草稿" }).click();
await expect(panel.getByText("现场采样修订稿").first()).toBeVisible();
await review.getByRole("button", { name: "确认并下发工单" }).click();
await expect(panel.getByRole("region", { name: "执行回执" })).toContainText("WO-20260924-1");
await review.getByRole("button", { name: "确认并创建预约工单" }).click();
await expect(review.getByRole("button", { name: "确认并创建预约工单" })).toHaveCount(0);
task = { ...task, status: "completed", lifecycle_status: "completed", revision: 8,
access_requests: [], current_report_revision: 2,
evidence: [...task.evidence, { evidence_id: "sample-query", kind: "synthetic", summary: "已查询投诉时段,采样记录为空", source_label: "外围采样记录", source_ref: "sample-query", observed_at: now, recorded_at: now, recorded_by: "agent", has_snapshot: false }],
latest_plan: { ...task.latest_plan!, plan: { ...plan, nodes: plan.nodes.map((node) => ({
...node, status: node.status === "waiting" ? "skipped" as const : node.status,
detail: node.status === "waiting" ? "关键数据不足,本轮未进入" : node.detail
})) } },
latest_report: { revision: 2, execution_generation: 1, source_session_id: "dynamic-flow-session",
published_at: now, published_by: "agent", report: {
title: "水质投诉调查报告", current_conclusion: "无法确定投诉原因", conclusion_boundary: "仅完成上游与管网数据核对",
evidence_refs: ["monitor-1", "sample-query"], recommendations: [], pending_items: [], stage: "final", outcome: "insufficient_data",
stop_reason: "缺少投诉时段的末梢采样结果", data_gaps: [{ gap_id: "sample-gap", node_id: "field",
missing_data: "末梢采样结果", scope: "投诉小区,2026-09-24 08:00 至 09:00", impact: "无法区分管网与用户端异常",
required_data: "补齐该时段末梢采样结果", evidence_refs: ["sample-query"], blocking: true }]
} }
};
notifications = [{ notification_id: "87000000-0000-4000-8000-000000000001", task_id: taskId, kind: "report_updated",
title: "有新报告", description: "末梢采样缺失", lifecycle_status: "active", subject_type: "task", subject_id: taskId,
report_reference: { execution_generation: 1, revision: 2 }, resolved_at: null, resolution_reason: null,
status: "unread", version: 1, required_permission: null, created_at: now, read_at: null, read_by: null }];
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
await expect(panel.getByRole("button", { name: "查看新报告" })).toBeVisible();
await expect(panel.getByText("新报告", { exact: true })).toBeVisible();
expect(notifications[0].status).toBe("unread");
await expect(panel.getByLabel("任务报告", { exact: true })
.getByText("上游记录未见同期异常,需现场核查用户端。", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "查看新报告" }).click();
await expect.poll(() => notifications[0].status).toBe("read");
await expect(panel.getByText("本轮已结束 · 无法形成结论", { exact: true })).toBeVisible();
await expect(panel.getByLabel("数据缺口")).toContainText("无法区分管网与用户端异常");
await expect(panel.getByText("已结束 · 证据不足", { exact: true }).first()).toHaveAttribute("data-status-tone", "warning");
await expect(panel.getByRole("button", { name: "确认并创建预约工单" })).toHaveCount(0);
await page.getByRole("button", { name: "折叠 Agent 面板", exact: true }).click();
await expect(page.getByRole("button", { name: /展开 Agent 助手面板/ })).toBeVisible();
await expect(panel).toHaveCSS("width", "880px");
await panel.getByRole("heading", { name: "当前结果", exact: true }).scrollIntoViewIfNeeded();
await testInfo.attach("insufficient-report-desktop", { body: await panel.screenshot({
path: testInfo.outputPath("insufficient-report-desktop.png") }), contentType: "image/png" });
await panel.getByText("任务时间线", { exact: true }).click();
const timeline = panel.getByLabel("任务时间线记录");
await expect(timeline).toContainText("采样工单已创建");
await testInfo.attach("investigation-timeline-desktop", { body: await timeline.screenshot({
path: testInfo.outputPath("investigation-timeline-desktop.png") }), contentType: "image/png" });
await panel.getByText("任务时间线", { exact: true }).click();
for (const width of [375, 320]) {
await page.setViewportSize({ width, height: 720 });
@@ -171,11 +341,54 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单
const mobileExpand = mobilePanel.getByRole("button", { name: "展开工况任务" });
if (await mobileExpand.isVisible()) await mobileExpand.click();
await mobilePanel.getByRole("button", { name: /调查任务/ }).click();
await expect(mobilePanel.getByLabel("并行处理 2 路")).toHaveCount(2);
await mobilePanel.getByLabel("调查脉络", { exact: true }).scrollIntoViewIfNeeded();
await expect(mobilePanel.getByLabel("调查脉络", { exact: true }).locator("svg[id]")).toHaveCount(1);
const mobileDiagram = mobilePanel.getByLabel("调查脉络", { exact: true });
assertControlGeometry(await captureDiagramControls(mobileDiagram));
if (width === 320) {
await mobileDiagram.getByRole("button", { name: "全屏查看", exact: true }).click();
await expect(fullscreen.locator("svg[id]")).toHaveCount(1);
assertControlGeometry(await captureDiagramControls(fullscreen));
await testInfo.attach("diagram-fullscreen-320", { body: await page.screenshot({ path: testInfo.outputPath("diagram-fullscreen-320.png") }), contentType: "image/png" });
await fullscreen.getByRole("button", { name: "退出全屏", exact: true }).click();
}
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await mobilePanel.locator('[data-execution-node="split"]').scrollIntoViewIfNeeded();
await mobilePanel.getByLabel("任务报告", { exact: true }).scrollIntoViewIfNeeded();
await expect(mobilePanel.getByText("本轮已结束 · 无法形成结论", { exact: true })).toBeVisible();
await testInfo.attach(`dynamic-investigation-${width}`, {
body: await sheet.screenshot({ path: testInfo.outputPath(`dynamic-investigation-${width}.png`) }), contentType: "image/png"
});
}
await page.setViewportSize({ width: 1280, height: 720 });
await panel.getByText("报告版本", { exact: true }).click();
await expect(panel.getByRole("button", { name: /报告第 2 版 · 最新版/ })).toBeVisible();
await panel.getByRole("button", { name: /第 1 轮 · 报告第 1 版/ }).click();
await expect(panel.getByLabel("任务报告", { exact: true })
.getByText("上游记录未见同期异常,需现场核查用户端。", { exact: true })).toBeVisible();
await page.route("**/api/v1/agent/chat/stream", async (route) => {
if (route.request().method() !== "POST") return route.fallback();
expect(route.request().postDataJSON().task_context).toEqual({ report_revision: 1, evidence_id: "monitor-1" });
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream", "X-Vercel-AI-UI-Message-Stream": "v1" },
body: [{ type: "start", messageId: "selected-report-answer" },
{ type: "data-task_context", data: { task_id: taskId, execution_generation: 1, report_revision: 1 } },
{ type: "text-start", id: "answer" }, { type: "text-delta", id: "answer", delta: "这条依据仅覆盖上游监测范围。" },
{ type: "text-end", id: "answer" }, { type: "finish", finishReason: "stop" }].map((part) => `data: ${JSON.stringify(part)}\n\n`).join("") });
});
await panel.getByLabel("主要依据").getByRole("button", { name: "围绕此证据提问" }).click();
await expect(agentPanel).toContainText("依据:第 1 轮 · 报告第 1 版");
task = { ...task, revision: 9, execution_generation: 2, current_report_revision: 3,
latest_report: { ...firstReport, revision: 3, execution_generation: 2, report: {
...firstReport.report, stage: "final", current_conclusion: "合成报告:仅用于检查图示失败时的阅读体验。",
diagram: { title: "调查脉络", mermaid: "flowchart TD\nA[未闭合", node_refs: [] }
} } };
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
await page.getByRole("button", { name: "折叠 Agent 面板", exact: true }).click();
const reopen = panel.getByRole("button", { name: "展开工况任务" });
if (await reopen.isVisible()) await reopen.click();
const viewNew = panel.getByRole("button", { name: "查看新报告" });
if (await viewNew.isVisible()) await viewNew.click();
await panel.getByLabel("调查脉络", { exact: true }).scrollIntoViewIfNeeded();
await expect(panel.getByText("图示暂不可用,仍可查看结论、证据和待审核动作。", { exact: true })).toBeVisible();
await expect(panel.getByLabel("任务报告", { exact: true })
.getByText("合成报告:仅用于检查图示失败时的阅读体验。", { exact: true })).toBeAttached();
});
@@ -0,0 +1,125 @@
import { expect, test } from "@playwright/test";
import { writeFile } from "node:fs/promises";
import { mockAgentApi } from "./support/mock-agent-api";
test("调查图样式对照保留中文标题、分工和真实 SVG 关系", async ({ page }, testInfo) => {
await page.route("**/api/v1/**", (route) => route.fulfill({ json: { items: [] } }));
await mockAgentApi(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.setViewportSize({ width: 1536, height: 1250 });
await page.setContent(`<html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body>
<main id="style-comparison" style="padding:32px;background:#EFF2F7;min-height:100vh;color:#26334D;font-family:'Noto Sans SC Variable',sans-serif">
<h1 style="font-size:22px;font-weight:600;margin-bottom:8px">调查脉络 · 三种视觉表达</h1>
<p style="font-size:13px;color:#68778E;margin-bottom:24px">使用截图中的节点与关系制作的样式设计示例</p>
<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px">
<section><h2 style="font-size:16px;font-weight:600">A · 工程流程</h2><p style="font-size:12px;color:#68778E;margin:8px 0 16px">白底、分工分组、明确的节点和连线层次</p><div id="engineering"></div></section>
<section><h2 style="font-size:16px;font-weight:600">B · 墨色报告</h2><p style="font-size:12px;color:#68778E;margin:8px 0 16px">黑白、直角、虚实线区分分工,适合打印</p><div id="ink"></div></section>
<section><h2 style="font-size:16px;font-weight:600">C · 手绘白板</h2><p style="font-size:12px;color:#68778E;margin:8px 0 16px">纸色、手绘线条,适合讲解设计思路</p><div id="sketch"></div></section>
</div>
</main><style>
#style-comparison [data-streamdown="mermaid"], #style-comparison [data-streamdown="mermaid"] > div {max-height:none!important;background:transparent;}
#style-comparison [data-streamdown="mermaid"] [role="img"] {width:100%;}
#style-comparison .font-mono {display:none;}
</style></body></html>`);
const renderCandidate = async (candidate: string) => page.evaluate(async (selected) => {
const reactModule = await import("/node_modules/.vite/deps/react.js");
const React = reactModule.default ?? reactModule;
const clientModule = await import("/node_modules/.vite/deps/react-dom_client.js");
const { createRoot } = clientModule.default ?? clientModule;
const { MessageResponse } = await import("/src/shared/ai-elements/message.tsx");
const { investigationDiagramConfig, investigationDiagramPlugins, INVESTIGATION_DIAGRAM_FONT, styleInvestigationDiagram } = await import("/src/features/workbench/utils/investigation-diagram-style.ts");
const { investigationDiagram } = await import("/src/features/workbench/utils/investigation-presentation.ts");
const titles = ["读取后端任务上下文与授权范围", "查询授权范围内的现有数据", "核实事件位置与生产资产映射", "现场核验爆管情况与影响范围", "停水隔离", "抢修作业", "复水与回访"];
const edges = ["已读上下文后查询数据", "依据查询结果核实资产映射", "映射缺失需现场核验", "确认爆管后停水", "停水后抢修", "抢修后复水"];
const diagram = investigationDiagram({ execution_generation: 1, latest_report: null, latest_plan: { execution_generation: 1, plan: {
nodes: titles.map((title, index) => ({ id: `node${index}`, title, owner: index < 3 ? "agent" : "human", kind: "activity", evidence_refs: [], action_refs: [] })),
edges: edges.map((condition, index) => ({ from: `node${index}`, to: `node${index + 1}`, condition }))
} } }, null);
await document.fonts.load(`500 15px ${INVESTIGATION_DIAGRAM_FONT}`, titles.join(""));
let source = styleInvestigationDiagram(diagram);
let config = investigationDiagramConfig;
if (selected === "ink") {
source += "\nclassDef tjAgent fill:#FFFFFF,stroke:#4B5262,color:#29303E,stroke-width:1.3px;\nclassDef tjHuman fill:#FFFFFF,stroke:#4B5262,color:#29303E,stroke-width:1.3px,stroke-dasharray:5 3;";
config = { ...config, themeVariables: { ...config.themeVariables, clusterBkg: "#FFFFFF", clusterBorder: "#C5CBD5", lineColor: "#606A7B" },
themeCSS: config.themeCSS + " .node rect,.cluster rect {rx:0;ry:0;} .nodeLabel{font-weight:400;} .cluster-label .nodeLabel{background:#FFFFFF;}" };
}
if (selected === "sketch") {
source += "\nclassDef tjAgent fill:#EDF4E9,stroke:#788A6D,color:#3B5135,stroke-width:1.3px;\nclassDef tjHuman fill:#FFF4DC,stroke:#B39964,color:#6B572B,stroke-width:1.3px;";
config = { ...config, look: "handDrawn", flowchart: { ...config.flowchart, curve: "basis", rankSpacing: 44 },
themeVariables: { ...config.themeVariables, fontSize: "16px", clusterBkg: "#FFFCF4", clusterBorder: "#CAC3AF", lineColor: "#8E8979" },
themeCSS: config.themeCSS + " .cluster-label .nodeLabel{background:#FFFCF4;}" };
}
createRoot(document.getElementById(selected)).render(React.createElement(MessageResponse, {
plugins: investigationDiagramPlugins,
mermaid: { config }, controls: { mermaid: { panZoom: false, fullscreen: false, download: false, copy: false } },
children: "```mermaid\n" + source + "\n```"
}));
}, candidate);
for (const candidate of ["engineering", "ink", "sketch"]) {
await renderCandidate(candidate);
const surface = page.locator(`#${candidate}`);
await surface.scrollIntoViewIfNeeded();
await expect(surface.locator("svg[id]")).toHaveCount(1);
await expect(surface).toContainText("读取后端任务上下文与授权范围");
await expect(surface).toContainText("人工 · 现场协作");
await writeFile(testInfo.outputPath(`${candidate}.svg`), await surface.locator("svg[id]").evaluate((svg) => svg.outerHTML));
await surface.screenshot({ path: testInfo.outputPath(`${candidate}.png`) });
}
await page.locator("#style-comparison").screenshot({ path: testInfo.outputPath("investigation-style-comparison.png") });
await page.setViewportSize({ width: 1740, height: 1450 });
await page.evaluate(async () => {
const reactModule = await import("/node_modules/.vite/deps/react.js");
const React = reactModule.default ?? reactModule;
const clientModule = await import("/node_modules/.vite/deps/react-dom_client.js");
const { createRoot } = clientModule.default ?? clientModule;
const { MessageResponse } = await import("/src/shared/ai-elements/message.tsx");
const { investigationDiagramConfig, investigationDiagramPlugins, INVESTIGATION_DIAGRAM_FONT, styleInvestigationDiagram } = await import("/src/features/workbench/utils/investigation-diagram-style.ts");
const { investigationDiagram } = await import("/src/features/workbench/utils/investigation-presentation.ts");
const labels = ["工单接入与信息登记", "上游监测点数据质量核查", "上游演示指标阈值比对", "相邻片区同类投诉排查", "投诉分布范围确认", "阶段性判断", "二次供水水箱清洗记录核验", "现场采样", "水箱清洗处置", "复测", "居民回访"];
const links = [[0,1,"并行核查"],[0,2,"并行核查"],[0,3,"并行核查"],[0,4,"并行核查"],
[1,5,"数据可用性结论"],[2,5,"上游水质结论"],[3,5,"范围排除依据"],[4,5,"投诉分布依据"],
[5,7,"条件分支:需现场核验"],[5,6,"条件分支:疑似二次供水问题 → 核验清洗记录"],
[6,8,"条件分支:清洗记录缺失或异常 → 清洗处置"],[7,9,"条件分支:采样支持处置后复测"],
[8,9,"条件分支:处置后复测"],[9,10,"条件分支:复测结果反馈回访"]];
const diagram = investigationDiagram({ execution_generation: 1, latest_report: null, latest_plan: { execution_generation: 1, plan: {
nodes: labels.map((title, index) => ({ id: `parallel${index}`, title, owner: index < 6 ? "agent" : "human", kind: "activity",
evidence_refs: index < 7 ? [`design-example-${index}`] : [], action_refs: [] })),
edges: links.map(([from,to,condition]) => ({ from: `parallel${from}`, to: `parallel${to}`, condition }))
} } }, null);
await document.fonts.load(`500 15px ${INVESTIGATION_DIAGRAM_FONT}`, labels.join(""));
document.getElementById("style-comparison").remove();
const container = document.createElement("main"); container.id = "parallel-comparison";
container.style.cssText = "padding:32px;background:#FFFFFF;font-family:'Noto Sans SC Variable',sans-serif";
document.body.append(container);
createRoot(container).render(React.createElement(MessageResponse, {
plugins: investigationDiagramPlugins,
mermaid: { config: investigationDiagramConfig }, controls: { mermaid: { panZoom: false, fullscreen: false, download: false, copy: false } },
children: "```mermaid\n" + styleInvestigationDiagram(diagram) + "\n```"
}));
const style = document.createElement("style");
style.textContent = '#parallel-comparison [data-streamdown="mermaid"],#parallel-comparison [data-streamdown="mermaid"] > div {max-height:none!important;background:#FFFFFF} #parallel-comparison [data-streamdown="mermaid"] [role="img"]{width:100%} #parallel-comparison .font-mono{display:none}';
document.head.append(style);
});
const parallel = page.locator("#parallel-comparison");
await parallel.scrollIntoViewIfNeeded();
await expect(parallel.locator("svg[id]")).toHaveCount(1);
const titleGap = await parallel.evaluate((element) => {
const title = [...element.querySelectorAll(".cluster-label")].find((item) => item.textContent?.includes("Agent"));
const firstNode = [...element.querySelectorAll(".node")].find((item) => item.textContent?.includes("工单接入"));
return firstNode!.getBoundingClientRect().top - title!.getBoundingClientRect().bottom;
});
const captionOverlaps = await parallel.evaluate((element) => {
const labels = [...element.querySelectorAll(".edgeLabel")].map((label) => label.getBoundingClientRect());
return [...element.querySelectorAll(".cluster-label")].flatMap((caption) => {
const box = caption.getBoundingClientRect();
return labels.some((label) => box.left < label.right && box.right > label.left && box.top < label.bottom && box.bottom > label.top)
? [caption.textContent]
: [];
});
});
await writeFile(testInfo.outputPath("parallel-diagram.svg"), await parallel.locator("svg[id]").evaluate((svg) => svg.outerHTML));
await parallel.screenshot({ path: testInfo.outputPath("parallel-diagram.png") });
await writeFile(testInfo.outputPath("parallel-diagram-spacing.json"), JSON.stringify({ viewport: page.viewportSize(), titleGap }, null, 2));
expect(titleGap).toBeGreaterThanOrEqual(18);
expect(captionOverlaps).toEqual([]);
});