diff --git a/src/features/agent/api/client.test.ts b/src/features/agent/api/client.test.ts index 79b2eb0..be39449 100644 --- a/src/features/agent/api/client.test.ts +++ b/src/features/agent/api/client.test.ts @@ -33,7 +33,10 @@ describe("Agent API client sessions", () => { status: undefined, parentSessionId: undefined, isStreaming: undefined, - runStatus: "running" + runStatus: "running", + purpose: "ordinary_chat", + taskId: undefined, + trigger: undefined }, { id: "old", @@ -43,7 +46,10 @@ describe("Agent API client sessions", () => { status: undefined, parentSessionId: undefined, isStreaming: undefined, - runStatus: undefined + runStatus: undefined, + purpose: "ordinary_chat", + taskId: undefined, + trigger: undefined } ]); }); diff --git a/src/features/agent/api/client.ts b/src/features/agent/api/client.ts index 151291f..695a848 100644 --- a/src/features/agent/api/client.ts +++ b/src/features/agent/api/client.ts @@ -2,6 +2,7 @@ import { env } from "@/shared/config/env"; import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; export type AgentRunStatus = "running" | "completed" | "error" | "aborted"; +export type AgentSessionPurpose = "ordinary_chat" | "task_execution" | "task_collaboration"; export type AgentEventTriggerContext = { schema_version: "agent-trigger@1"; @@ -51,9 +52,12 @@ export type AgentChatSession = { created?: boolean; event_duplicate?: boolean; trigger_id?: string; + purpose?: AgentSessionPurpose; + task_id?: string; }; export type AgentChatSessionSummary = { + trigger?: { subjectId: string; workflow: "water_quality_complaint"; occurredAt: string }; id: string; title: string; createdAt: number; @@ -62,6 +66,8 @@ export type AgentChatSessionSummary = { parentSessionId?: string; isStreaming?: boolean; runStatus?: AgentRunStatus; + purpose: AgentSessionPurpose; + taskId?: string; }; export type AgentLoadedChatSession = AgentChatSessionSummary & { @@ -71,6 +77,7 @@ export type AgentLoadedChatSession = AgentChatSessionSummary & { }; export type AgentSessionStreamDataEventType = + | "execution_plan" | "token" | "progress" | "todo_update" @@ -90,6 +97,7 @@ export type AgentSessionStreamDataEventType = export type AgentSessionStreamEvent = | { type: "state"; + executionPlans?: unknown[]; sessionId: string; messages: unknown[]; isStreaming: boolean; @@ -103,7 +111,8 @@ export type AgentSessionStreamEvent = export type AgentApiClient = { createSession: (options?: { triggerContext?: AgentEventTriggerContext }) => Promise; - listSessions: () => Promise; + listSessions: (options?: { purpose?: AgentSessionPurpose; taskId?: string }) => Promise; + createTaskCollaborationSession: (taskId: string) => Promise; loadSession: (sessionId: string) => Promise; streamSession: ( sessionId: string, @@ -184,11 +193,23 @@ export function createAgentApiClient( }); }, - async listSessions() { - const payload = await requestJson<{ sessions?: unknown[] }>("/sessions"); + async listSessions(listOptions) { + const params = new URLSearchParams(); + if (listOptions?.purpose) params.set("purpose", listOptions.purpose); + if (listOptions?.taskId) params.set("task_id", listOptions.taskId); + const payload = await requestJson<{ sessions?: unknown[] }>( + `/sessions${params.size ? `?${params.toString()}` : ""}` + ); return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries); }, + async createTaskCollaborationSession(taskId) { + return requestJson( + `/task/${encodeURIComponent(taskId)}/collaboration/session`, + { method: "POST" } + ); + }, + async getFrontendActionRegistry() { return requestJson("/frontend-action-registry"); }, @@ -432,6 +453,10 @@ function readRunStatus(value: unknown): AgentRunStatus | undefined { : undefined; } +function readSessionPurpose(value: unknown): AgentSessionPurpose { + return value === "task_execution" || value === "task_collaboration" ? value : "ordinary_chat"; +} + function toSessionSummary(value: unknown): AgentChatSessionSummary | null { if (!isRecord(value)) { return null; @@ -450,7 +475,14 @@ function toSessionSummary(value: unknown): AgentChatSessionSummary | null { status: typeof value.status === "string" ? value.status : undefined, parentSessionId: typeof value.parent_session_id === "string" ? value.parent_session_id : undefined, isStreaming: typeof value.is_streaming === "boolean" ? value.is_streaming : undefined, - runStatus: readRunStatus(value.run_status) + runStatus: readRunStatus(value.run_status), + purpose: readSessionPurpose(value.purpose), + taskId: typeof value.task_id === "string" ? value.task_id : undefined, + trigger: isRecord(value.trigger) && value.trigger.workflow === "water_quality_complaint" && + typeof value.trigger.subject_id === "string" && typeof value.trigger.occurred_at === "string" && + Number.isFinite(Date.parse(value.trigger.occurred_at)) + ? { subjectId: value.trigger.subject_id, workflow: "water_quality_complaint", occurredAt: value.trigger.occurred_at } + : undefined }; } @@ -530,6 +562,7 @@ function emitSessionSseEvent( const sessionId = typeof data.session_id === "string" ? data.session_id : ""; onEvent({ type: "state", + executionPlans: Array.isArray(data.execution_plans) ? data.execution_plans : undefined, sessionId, messages: Array.isArray(data.messages) ? data.messages : [], isStreaming: data.is_streaming === true, @@ -554,6 +587,7 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea value === "token" || value === "progress" || value === "todo_update" || + value === "execution_plan" || value === "permission_request" || value === "permission_response" || value === "question_request" || diff --git a/src/features/agent/api/swr.test.ts b/src/features/agent/api/swr.test.ts index 404c98b..62f634c 100644 --- a/src/features/agent/api/swr.test.ts +++ b/src/features/agent/api/swr.test.ts @@ -35,13 +35,15 @@ describe("Agent API SWR helpers", () => { id: "session-1", title: "会话", createdAt: 1, - updatedAt: 2 + updatedAt: 2, + purpose: "ordinary_chat" as const } ]; const client = { listSessions: vi.fn(async () => sessions) } as Pick as AgentApiClient; - await expect(fetchAgentSessions(client)).resolves.toBe(sessions); + await expect(fetchAgentSessions(client)).resolves.toEqual(sessions); + expect(client.listSessions).toHaveBeenCalledWith({ purpose: "ordinary_chat" }); }); }); diff --git a/src/features/agent/api/swr.ts b/src/features/agent/api/swr.ts index 27b38f1..523645a 100644 --- a/src/features/agent/api/swr.ts +++ b/src/features/agent/api/swr.ts @@ -32,5 +32,7 @@ export async function fetchAgentBootstrap(client: AgentApiClient): Promise { - return client.listSessions(); + const sessions = await client.listSessions({ purpose: "ordinary_chat" }); + // Keep the product boundary even while an older Agent service is rolling forward. + return sessions.filter((session) => session.purpose === "ordinary_chat"); } diff --git a/src/features/agent/components/agent-command-panel.tsx b/src/features/agent/components/agent-command-panel.tsx index d5427c5..9166d1d 100644 --- a/src/features/agent/components/agent-command-panel.tsx +++ b/src/features/agent/components/agent-command-panel.tsx @@ -108,6 +108,7 @@ type AgentCommandPanelProps = { sessionHistory?: AgentChatSessionSummary[]; sessionHistoryLoading?: boolean; activeSessionId?: string | null; + qaOnly?: boolean; statusLabel?: string; connectionStatus?: AgentConnectionStatus; streaming?: boolean; @@ -162,6 +163,7 @@ export function AgentCommandPanel({ sessionHistory = [], sessionHistoryLoading = false, activeSessionId, + qaOnly = false, statusLabel = "Agent 后端连接中", connectionStatus = "connecting", streaming = false, @@ -561,6 +563,7 @@ export function AgentCommandPanel({ onPauseSpeech={pauseSpeech} onResumeSpeech={resumeSpeech} onStopSpeech={stopSpeech} + qaOnly={qaOnly} /> ) : ( - + {!qaOnly ? : null}
- + {!qaOnly ? : 任务协作问答}
void; onResumeSpeech: () => void; onStopSpeech: () => void; + qaOnly: boolean; }) { return (
- {message.role === "assistant" ? ( + {message.role === "assistant" && !qaOnly ? ( 正在生成... ) : null} - {message.role === "assistant" ? ( + {message.role === "assistant" && !qaOnly ? ( injected["', evidence: [] + })), + edges: [{ from: "0", to: "1", label: "" }, { from: "0", to: "2", label: "" }, { from: "1", to: "3", label: "上游正常" }, { from: "2", to: "3", label: "投诉集中" }] + }; + const { container } = render(); + expect(screen.getByText("并行处理")).toBeInTheDocument(); + expect(screen.getByLabelText("并行分枝 2 项")).toHaveAttribute("data-execution-layout", "branch"); + expect(screen.getByText("2 条独立分枝")).toBeInTheDocument(); + expect(container.querySelectorAll("[data-execution-node]")).toHaveLength(4); + expect(container.querySelector("script")).toBeNull(); + expect(screen.queryByText(/前置:/)).not.toBeInTheDocument(); + expect(screen.getByText("分枝 1")).toBeInTheDocument(); + expect(screen.getByText("分枝 2")).toBeInTheDocument(); + expect(screen.queryByText("每条分枝按自身依赖推进,不要求同层节点同时完成。")).not.toBeInTheDocument(); + expect(screen.getByText("待人工/资料")).toBeInTheDocument(); + }); + + it("renders a four-way fork and a later two-way fork without inventing cross-branch edges", () => { + const node = (id: string, title: string, status: AgentExecutionPlan["nodes"][number]["status"] = "completed") => ({ + id, title, status, owner: "agent" as const, detail: `${title}详情`, evidence: [] + }); + const plan: AgentExecutionPlan = { + schema_version: "execution-plan@1", session_id: "s-nested", revision: 1, + updated_at: "2026-09-23T01:00:00.000Z", title: "多轮调查", reason: "按真实依赖推进", + nodes: [ + node("start", "接收事件"), + node("a1", "上游核查"), node("a2", "上游复核"), + node("b1", "邻区投诉"), + node("c1", "压力比对"), node("c2", "水力校核"), node("c3", "阀门排查"), + node("d1", "运维记录"), + node("join1", "阶段判断"), + node("e1", "二次供水核查", "running"), node("e2", "水箱记录", "waiting"), + node("f1", "现场采样", "waiting"), + node("join2", "形成结论", "pending") + ], + edges: [ + { from: "start", to: "a1", label: "" }, { from: "a1", to: "a2", label: "" }, { from: "a2", to: "join1", label: "" }, + { from: "start", to: "b1", label: "" }, { from: "b1", to: "join1", label: "" }, + { from: "start", to: "c1", label: "" }, { from: "c1", to: "c2", label: "" }, { from: "c2", to: "c3", label: "" }, { from: "c3", to: "join1", label: "" }, + { from: "start", to: "d1", label: "" }, { from: "d1", to: "join1", label: "" }, + { from: "join1", to: "e1", label: "" }, { from: "e1", to: "e2", label: "" }, { from: "e2", to: "join2", label: "" }, + { from: "join1", to: "f1", label: "" }, { from: "f1", to: "join2", label: "" } + ] + }; + + const { container } = render(); + expect(screen.getByLabelText("并行分枝 4 项")).toBeVisible(); + expect(screen.getByLabelText("并行分枝 2 项")).toBeVisible(); + expect(screen.getAllByText("分枝汇合")).toHaveLength(2); + expect(container.querySelectorAll("[data-execution-node]")).toHaveLength(plan.nodes.length); + expect(screen.queryByText(/前置工作与条件|前置:/)).not.toBeInTheDocument(); + }); +}); + +const taskBase = { + project_id: "11111111-1111-4111-8111-111111111111", + task_id: "22222222-2222-4222-8222-222222222222", + task_type: "event_investigation", + workflow: "water_quality_complaint", + lifecycle_status: "waiting_human", + active_run: null, + status: "waiting_human", + priority: "normal", + revision: 2, + execution_generation: 1, + execution_request_id: "33333333-3333-4333-8333-333333333333", + title: "水质投诉调查", + summary: "居民反映异味", + source: { kind: "operator", external_id: null, external_revision: null, event_kind: "water_quality_complaint" }, + trigger: { kind: "manual", occurred_at: "2026-09-23T01:00:00Z", detail: "演示小区" }, + scheduled_for: null, + agent_session_id: "execution-session", + agent_lease_expires_at: null, + current_plan_revision: null, + current_report_revision: 1, + created_at: "2026-09-23T01:00:00Z", + updated_at: "2026-09-23T01:10:00Z", + latest_plan: null, + latest_report: { + revision: 1, execution_generation: 1, source_session_id: "execution-session", published_at: "2026-09-23T01:09:00Z", published_by: "agent", + report: { title: "调查报告", current_conclusion: "上游指标正常,待核查二次供水。", conclusion_boundary: "等待现场采样。", evidence_refs: ["e-1"], recommendations: ["安排现场采样"], pending_items: ["确认水箱清洗记录"] } + }, + evidence: [{ evidence_id: "e-1", kind: "system_data", summary: "上游余氯处于正常范围", source_label: "SCADA 水质监测", source_ref: "WQ-01", observed_at: "2026-09-23T00:55:00Z", recorded_at: "2026-09-23T01:08:00Z", recorded_by: "agent", has_snapshot: false }], + actions: [{ action_id: "44444444-4444-4444-8444-444444444444", task_id: "22222222-2222-4222-8222-222222222222", version: 3, kind: "create_work_order", node_id: null, reason: "需要现场采样", payload: { title: "现场采样", location: "演示小区", scheduled_at: "2026-09-23T02:00:00Z" }, evidence_refs: ["e-1"], required_approval_permission: "operational_action.work_order.approve", required_execution_permission: "operational_action.work_order.execute", approval_status: "pending", execution_status: "not_submitted", execution_request_id: null, proposed_by: "agent", proposed_at: "2026-09-23T01:09:00Z", decided_by: null, decided_at: null, decision_comment: null, executed_by: null, executed_at: null, receipt: null, execution_detail: null }], + permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false, can_decide_work_orders: false, can_execute_work_orders: false, can_decide_commands: false, can_execute_commands: false }, + work_order_snapshot: null +} as OperationalTask; + +function taskPanel(task: OperationalTask, overrides: Partial> = {}) { + const props = { + task, + onContinue: vi.fn(), + onDecision: vi.fn(async () => undefined), + onExecute: vi.fn(async () => undefined), + onReconcile: vi.fn(async () => undefined), + onRefresh: vi.fn(), + onLoadEvidenceSnapshot: vi.fn(async () => { throw new Error("no snapshot"); }), + ...overrides + }; + render(); + return props; +} + +describe("operational task approval and evidence", () => { + it("distinguishes the current report from a previous execution without exposing generation numbers", () => { + taskPanel(taskBase); + expect(screen.getByText("当前结论", { exact: true })).toBeVisible(); + + const previousReport = { + ...taskBase, + status: "running" as const, + execution_generation: 2, + latest_report: { ...taskBase.latest_report!, execution_generation: 1 } + }; + const { rerender } = render(); + expect(screen.getByText("上一轮报告,本轮分析中", { exact: true })).toBeVisible(); + expect(screen.queryByText(/第\s*[12]\s*代|generation/i)).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByText("上一轮报告,当前任务失败", { exact: true })).toBeVisible(); + expect(screen.queryByText("上一轮报告,本轮分析中", { exact: true })).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByText("上一轮报告,当前任务已取消", { exact: true })).toBeVisible(); + + rerender(); + expect(screen.getByText("历史报告", { exact: true })).toBeVisible(); + }); + + it("labels current, previous and legacy execution plans without presenting old progress as current", () => { + const latestPlan: NonNullable = { + revision: 1, + execution_generation: 1, + source_session_id: "execution-session", + published_at: "2026-09-23T01:05:00Z", + published_by: "agent", + plan: { + schema: "execution-plan@1", + title: "调查流程", + reason: "核对真实状态", + nodes: [{ + id: "investigate", + title: "核查事件", + owner: "agent", + status: "completed", + detail: "上一轮已完成。", + evidence_refs: [], + action_refs: [] + }], + edges: [] + } + }; + const currentPlanTask = { ...taskBase, latest_plan: latestPlan }; + const props = { + onContinue: vi.fn(), onDecision: vi.fn(), onExecute: vi.fn(), onReconcile: vi.fn(), + onRefresh: vi.fn(), onLoadEvidenceSnapshot: vi.fn() + }; + const { rerender } = render(); + expect(screen.getByText("已完成 1 项 · 执行中 0 项 · 待人工或资料 0 项", { exact: true })).toBeVisible(); + + rerender(); + expect(screen.getByText("上一轮流程,本轮规划中", { exact: true })).toBeVisible(); + expect(screen.queryByText(/已完成 1 项/)).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByText("上一轮流程,当前任务失败", { exact: true })).toBeVisible(); + + rerender(); + expect(screen.getByText("历史流程", { exact: true })).toBeVisible(); + expect(screen.queryByText(/generation|第\s*\d+\s*代/i)).not.toBeInTheDocument(); + }); + + it("shows exact payload and evidence metadata to a viewer without action controls", () => { + taskPanel(taskBase); + expect(screen.getByText("工单标题")).toBeVisible(); + expect(screen.getByText("位置")).toBeVisible(); + expect(screen.getByText("预约时间")).toBeVisible(); + expect(screen.getByText("现场采样")).toBeVisible(); + expect(screen.getByText("演示小区")).toBeVisible(); + expect(screen.getByText("确认依据")).toBeVisible(); + 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.getByText("SCADA 水质监测")).toBeVisible(); + expect(screen.getByText("系统数据")).toBeVisible(); + }); + + 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: "批准创建工单" })); + expect(await screen.findByRole("alert")).toHaveTextContent("当前状态未按成功处理"); + expect(screen.getByText("待确认")).toBeVisible(); + }); + + it("requires refresh and reconfirmation after a version conflict", async () => { + 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: "批准创建工单" })); + const refresh = await screen.findByRole("button", { name: "刷新后重新确认" }); + fireEvent.click(refresh); + await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce()); + }); + + it("allows unknown submissions to reconcile and shows a persisted receipt", async () => { + const onReconcile = vi.fn(async () => undefined); + const unknownAction = { + ...taskBase.actions[0], + approval_status: "approved" as const, + execution_status: "unknown" as const, + execution_request_id: "55555555-5555-4555-8555-555555555555" + }; + const receiptAction = { + ...unknownAction, + execution_status: "succeeded" as const, + receipt: { + adapter: "peripheral", + receipt_id: "receipt-20260923-01", + submitted_at: "2026-09-23T01:10:00Z", + completed_at: "2026-09-23T01:11:00Z", + external_status: "completed", + detail: "外围工单创建完成" + } + }; + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "核对执行回执" })); + await waitFor(() => expect(onReconcile).toHaveBeenCalledWith(unknownAction.action_id, unknownAction.version)); + + rerender(); + expect(screen.getByRole("region", { name: "执行回执" })).toHaveTextContent("receipt-20260923-01"); + expect(screen.getByRole("region", { name: "执行回执" })).toHaveTextContent("外围工单创建完成"); + }); +}); diff --git a/src/features/workbench/components/agent-execution-plan-panel.tsx b/src/features/workbench/components/agent-execution-plan-panel.tsx new file mode 100644 index 0000000..80ed1ba --- /dev/null +++ b/src/features/workbench/components/agent-execution-plan-panel.tsx @@ -0,0 +1,555 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { + AlertTriangle, + CheckCircle2, + Clock3, + FileText, + GitFork, + Loader2, + MessageSquare, + RefreshCw, + Route, + ShieldCheck, + UserCheck +} from "lucide-react"; +import type { AgentExecutionPlan } from "@/features/agent"; +import { Button } from "@/shared/ui/button"; +import { cn } from "@/shared/ui/cn"; +import { Disclosure } from "@/shared/ui/disclosure"; +import { Input } from "@/shared/ui/input"; +import { StatusBadge, StatusIcon, type StatusTone } from "@/shared/ui/status"; +import type { + OperationalTask, + OperationalTaskAction, + OperationalTaskDecision, + OperationalTaskEvidence, + OperationalTaskPlan, + OperationalTaskDataSnapshot +} from "../api/operational-task-client"; + +type ExecutionNode = { + id: string; + title: string; + status: "pending" | "running" | "waiting" | "completed" | "failed" | "skipped" | "cancelled"; + owner: "agent" | "human"; + detail: string; +}; + +type ExecutionEdge = { + from: string; + to: string; + label?: string | null; + condition?: string | null; +}; + +export type ExecutionPlanView = { + nodes: ExecutionNode[]; + edges: ExecutionEdge[]; +}; + +type AgentExecutionPlanPanelProps = { + task: OperationalTask; + onContinue: () => void; + onDecision: (actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise; + onExecute: (actionId: string, expectedVersion: number) => Promise; + onReconcile: (actionId: string, expectedVersion: number) => Promise; + onLoadEvidenceSnapshot: (evidenceId: string) => Promise; + onRefresh: () => void; +}; + +export function AgentExecutionPlanPanel({ + task, + onContinue, + onDecision, + onExecute, + onReconcile, + onLoadEvidenceSnapshot, + onRefresh +}: AgentExecutionPlanPanelProps) { + const plan = task.latest_plan?.plan; + const report = task.latest_report?.report; + const planGeneration = task.latest_plan?.execution_generation; + const planState = planGeneration == null + ? "historical" + : planGeneration === task.execution_generation + ? "current" + : "previous"; + const reportGeneration = task.latest_report?.execution_generation; + const reportState = reportGeneration == null + ? "historical" + : reportGeneration === task.execution_generation + ? "current" + : "previous"; + const 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 externalEvidence = task.evidence.filter((item) => item.kind === "external_feedback"); + const taskActive = task.status === "queued" || task.status === "running"; + const planSummary = !plan + ? "任务已持久化,等待 Agent 发布首版处理计划。" + : planState === "current" + ? `已完成 ${completed} 项 · 执行中 ${active} 项 · 待人工或资料 ${waiting} 项` + : planState === "previous" + ? taskActive + ? "上一轮流程,本轮规划中" + : `上一轮流程,当前任务${taskStatusLabel(task.status)}` + : "历史流程"; + const reportLabel = reportState === "current" + ? "当前结论" + : reportState === "previous" + ? taskActive + ? "上一轮报告,本轮分析中" + : `上一轮报告,当前任务${taskStatusLabel(task.status)}` + : "历史报告"; + const nodeActions = plan ? Object.fromEntries(plan.nodes.map((node) => [ + node.id, + task.actions.filter((action) => action.node_id === node.id).map((action) => ( + + )) + ])) : {}; + const unlinkedActions = task.actions.filter((action) => !action.node_id || !plan?.nodes.some((node) => node.id === action.node_id)); + + return ( +
+
+
+
+

+

+ + {taskStatusLabel(task.status)} + +
+

+ {planSummary} +

+

+ {taskTypeLabel(task.task_type)} · 更新于 {formatDateTime(task.updated_at)} +

+
+ {task.task_type === "external_work_order" && externalEvidence.length > 0 ? ( + + ) : plan ? ( + + ) : ( +

尚无执行节点,页面不会按时间推算或虚构完成进度。

+ )} +
+ +
+
+

+

结论与证据独立呈现,不展示模型推理或节点执行日志。

+
+ {report ? ( + <> +
+

+ {reportLabel} +

+

{report.current_conclusion}

+
+ {report.recommendations.length > 0 ? ( + + ) : null} + {report.pending_items.length > 0 ? ( + + ) : null} +

结论边界:{report.conclusion_boundary}

+ + ) : ( +

尚未形成任务报告。

+ )} +
+ + + + {unlinkedActions.length > 0 ? ( +
+ {unlinkedActions.map((action) => ( + + ))} +
+ ) : null} + +
+ +
+
+ ); +} + +function ReportList({ title, items, tone }: { title: string; items: string[]; tone: "blue" | "amber" }) { + return ( +
+

{title}

+
    + {items.map((item) => ( +
  • +
  • + ))} +
+
+ ); +} + +export function ExecutionPlanSteps({ plan, nodeActions = {} }: { plan: ExecutionPlanView | AgentExecutionPlan | OperationalTaskPlan; nodeActions?: Record }) { + return
; +} + +function ExecutionFlow({ plan, nodeActions, depth }: { plan: ExecutionPlanView; nodeActions: Record; depth: number }) { + const layout = createBranchLayout(plan); + if (!layout || depth > 8) return ; + return ( +
+ + {layout.branches.length > 0 ? ( +
+
+ + 并行处理 + {layout.branches.length} 条独立分枝 +
+
+ {layout.branches.map((branch, index) => { + const firstEdge = plan.edges.find((edge) => edge.to === branch[0]?.id); + return ( +
node.id).join("-")} className="surface-control min-w-0 snap-start rounded-xl border border-slate-200/70 p-2.5"> +

分枝 {index + 1}{firstEdge?.condition ? ` · ${firstEdge.condition}` : ""}

+ +
+ ); + })} +
+
+ ) : null} + {layout.suffix.length > 0 ? ( +
+
分枝汇合
+ node.id))} nodeActions={nodeActions} depth={depth + 1} /> +
+ ) : null} +
+ ); +} + +function ExecutionChain({ nodes, plan, nodeActions, compact = false }: { nodes: ExecutionNode[]; plan: ExecutionPlanView; nodeActions: Record; compact?: boolean }) { + return
{nodes.map((node, index) => { + const edge = index > 0 ? plan.edges.find((item) => item.from === nodes[index - 1].id && item.to === node.id) : undefined; + return
{index > 0 ?
{edge?.condition ? {edge.condition} : null}
: null}
; + })}
; +} + +function ExactDependencyList({ plan, nodeActions }: { plan: ExecutionPlanView; nodeActions: Record }) { + return
    {topologicalNodes(plan).map((node) =>
  1. )}
; +} + +function subPlan(plan: ExecutionPlanView, nodeIds: string[]): ExecutionPlanView { + const included = new Set(nodeIds); + return { + nodes: plan.nodes.filter((node) => included.has(node.id)), + edges: plan.edges.filter((edge) => included.has(edge.from) && included.has(edge.to)) + }; +} + +function ExecutionNodeCard({ node, action, compact = false }: { node: ExecutionNode; action?: ReactNode; compact?: boolean }) { + const { tone, Icon, label } = executionNodePresentation(node); + return ( +
+
+ +
+
{node.title}{label}
+

{node.detail || (node.owner === "human" ? "等待人工补充执行事实。" : "等待 Agent 更新执行事实。")}

+ {action ?
{action}
: null} +
+
+
+ ); +} + +function ExternalProgress({ evidence }: { evidence: OperationalTaskEvidence[] }) { + const ordered = [...evidence].sort((left, right) => Date.parse(left.observed_at) - Date.parse(right.observed_at)); + return ( +
    + {ordered.map((item, index) => ( +
  1. + + {index < ordered.length - 1 ? +
    +
    {item.source_label}{formatDateTime(item.observed_at)}
    +

    {item.summary}

    +
    +
  2. + ))} +
+ ); +} + +function EvidenceSection({ evidence, referencedIds, onLoadSnapshot }: { evidence: OperationalTaskEvidence[]; referencedIds: string[]; onLoadSnapshot: (evidenceId: string) => Promise }) { + const ordered = useMemo(() => { + const referenced = new Set(referencedIds); + return [...evidence].sort((left, right) => Number(referenced.has(right.evidence_id)) - Number(referenced.has(left.evidence_id))); + }, [evidence, referencedIds]); + return ( +
+

关键证据

{ordered.length} 条
+ {ordered.length > 0 ?
{ordered.map((item) => )}
:

尚无可核验的支撑证据。

} +
+ ); +} + +function EvidenceDisclosure({ item, onLoadSnapshot }: { item: OperationalTaskEvidence; onLoadSnapshot: (evidenceId: string) => Promise }) { + const [snapshot, setSnapshot] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const load = async () => { + setLoading(true); setError(null); + try { setSnapshot(await onLoadSnapshot(item.evidence_id)); } + catch (nextError) { setError(nextError instanceof Error ? nextError.message : "数据快照读取失败。"); } + finally { setLoading(false); } + }; + return {item.summary}} className="surface-control rounded-lg border border-slate-200/70 px-2.5"> +
+
{item.source_ref ? : null}
+ {item.has_snapshot && !snapshot ? : null} + {error ?

{error}

: null} + {snapshot ? : null} +
+
; +} + +function SnapshotSummary({ snapshot }: { snapshot: OperationalTaskDataSnapshot }) { + const rows = snapshotRows(snapshot.data).slice(0, 12); + return

{snapshot.source_ref} · 查询于 {formatDateTime(snapshot.queried_at)}

{rows.map(([key, value]) => )}
{snapshot.truncated ?

数据已按有界查询范围截断。

: null}
; +} + +function snapshotRows(value: unknown, prefix = ""): [string, string][] { + if (Array.isArray(value)) return value.slice(0, 6).flatMap((item, index) => snapshotRows(item, `${prefix}${index + 1}`)); + if (value && typeof value === "object") return Object.entries(value as Record).flatMap(([key, item]) => snapshotRows(item, prefix ? `${prefix}.${key}` : key)); + return [[prefix || "值", value === null || value === undefined ? "空" : String(value)]]; +} + +function EvidenceMeta({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return
{label}
{value}
; +} + +function HumanActionCard({ task, action, onDecision, onExecute, onReconcile, onRefresh }: { + task: OperationalTask; + action: OperationalTaskAction; + onDecision: AgentExecutionPlanPanelProps["onDecision"]; + onExecute: AgentExecutionPlanPanelProps["onExecute"]; + onReconcile: AgentExecutionPlanPanelProps["onReconcile"]; + onRefresh: () => void; +}) { + const [submitting, setSubmitting] = useState<"approve" | "reject" | "execute" | "reconcile" | null>(null); + const [error, setError] = useState(null); + const [stale, setStale] = useState(false); + const [rejectComment, setRejectComment] = useState(""); + const command = action.kind === "issue_command"; + 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") => { + setSubmitting(kind); setError(null); + try { + if (kind === "execute") await onExecute(action.action_id, action.version); + else if (kind === "reconcile") await onReconcile(action.action_id, action.version); + else { + if (kind === "reject" && !rejectComment.trim()) { + setError("请填写退回理由后再拒绝动作。"); + return; + } + await onDecision(action.action_id, action.version, kind === "approve" ? "approved" : "rejected", kind === "reject" ? rejectComment.trim() : undefined); + } + } catch (nextError) { + const message = nextError instanceof Error ? nextError.message : "人工动作提交失败。"; + setError(message); + setStale(/version|版本|409|conflict/i.test(message)); + } finally { setSubmitting(null); } + }; + return ( +
+
+ +
+

{command ? "命令下发确认" : "工单创建确认"}

{approvalLabel(action.approval_status)}{executionLabel(action.execution_status)}
+

原因:{action.reason}

+ + {action.evidence_refs.length > 0 ? ( +
+

确认依据

+
    + {action.evidence_refs.map((evidenceId) => { + const evidence = task.evidence.find((item) => item.evidence_id === evidenceId); + return
  • {evidence ? `${evidence.summary} · ${evidence.source_label}` : `证据 ${evidenceId}`}
  • ; + })} +
+
+ ) : null} + {action.receipt ? : null} +
{JSON.stringify(action.payload, null, 2)}
+

确认版本 v{action.version}。内容或版本变化后必须重新确认。

+ {error ?

{error} 当前状态未按成功处理。

: null} + {stale ? : null} + {!canDecide && action.approval_status === "pending" ?

当前账号仅可查看,需要具备此动作审批权限的人员确认。

: null} + {action.approval_status === "pending" && canDecide ? setRejectComment(event.target.value)} /> : null} +
+ {action.approval_status === "pending" && canDecide ? <> : null} + {action.approval_status === "approved" && action.execution_status === "not_submitted" ? : null} + {action.approval_status === "approved" && ["submitted", "unknown", "failed", "unsupported"].includes(action.execution_status) ? : null} +
+
+
+
+ ); +} + +function ActionReceipt({ receipt }: { receipt: NonNullable }) { + return
+

执行回执

+
+ + + + {receipt.completed_at ? : null} + +
+
; +} + +function ActionPayloadSummary({ payload }: { payload: Record }) { + const rows = Object.entries(payload).flatMap(([key, value]) => { + if (key !== "parameters" && value && typeof value === "object" && !Array.isArray(value)) { + return Object.entries(value as Record).map(([childKey, childValue]) => ({ key: `${key}.${childKey}`, value: childValue })); + } + return [{ key, value }]; + }); + return
{rows.map((row) =>
{payloadLabel(row.key)}
{payloadValue(row.key, row.value)}
)}
; +} + +function payloadLabel(key: string) { + const labels: Record = { + title: "工单标题", description: "工作内容", work_order_type: "工单类型", priority: "优先级", + scheduled_at: "预约时间", location: "位置", assignee: "处理班组", command_type: "命令类型", + "target.kind": "对象类型", "target.id": "目标对象", parameters: "执行参数", impact_summary: "影响说明", + preconditions: "执行条件", expires_at: "失效时间" + }; + return labels[key] ?? key; +} + +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 === "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("、") || "无"; + if (key === "parameters" && typeof value === "object") { + return Object.entries(value as Record).map(([parameter, item]) => + parameter === "opening" ? `开度 ${String(item)}%` : `${parameter}=${String(item)}` + ).join(";"); + } + if (typeof value === "object") return Object.entries(value as Record).map(([itemKey, item]) => `${itemKey}=${String(item)}`).join(";"); + return String(value); +} + +function knownLabel(value: unknown, labels: Record) { + const raw = String(value); + return labels[raw] ?? raw; +} + +function externalStatusLabel(value: string) { + return ({ completed: "已完成", submitted: "已提交", accepted: "已接收", failed: "失败", pending: "处理中" } as Record)[value] ?? value; +} + +function createBranchLayout(plan: ExecutionPlanView) { + const ordered = topologicalNodes(plan); + if (ordered.length !== plan.nodes.length) return null; + const outgoing = new Map(plan.nodes.map((node) => [node.id, plan.edges.filter((edge) => edge.from === node.id)])); + const incoming = new Map(plan.nodes.map((node) => [node.id, plan.edges.filter((edge) => edge.to === node.id)])); + const fork = ordered.find((node) => (outgoing.get(node.id)?.length ?? 0) > 1); + if (!fork) return { prefix: ordered, branches: [] as ExecutionNode[][], suffix: [] as ExecutionNode[] }; + const starts = outgoing.get(fork.id)!.map((edge) => edge.to); + const descendants = starts.map((start) => reachableFrom(start, outgoing)); + const join = ordered.find((node) => node.id !== fork.id && (incoming.get(node.id)?.length ?? 0) > 1 && descendants.every((set) => set.has(node.id))); + if (!join) return null; + const nodeById = new Map(plan.nodes.map((node) => [node.id, node])); + const branches: ExecutionNode[][] = []; + for (const start of starts) { + const branch: ExecutionNode[] = []; + let current = start; + const seen = new Set(); + while (current !== join.id && !seen.has(current)) { + seen.add(current); + const node = nodeById.get(current); + if (!node) return null; + branch.push(node); + const nextEdges = outgoing.get(current) ?? []; + if (nextEdges.length !== 1) return null; + current = nextEdges[0].to; + } + if (current !== join.id || branch.length === 0) return null; + branches.push(branch); + } + const branchIds = new Set(branches.flatMap((branch) => branch.map((node) => node.id))); + const forkIndex = ordered.findIndex((node) => node.id === fork.id); + const joinIndex = ordered.findIndex((node) => node.id === join.id); + return { prefix: ordered.slice(0, forkIndex + 1), branches, suffix: ordered.slice(joinIndex).filter((node) => !branchIds.has(node.id)) }; +} + +function reachableFrom(start: string, outgoing: Map) { + const seen = new Set(); + const stack = [start]; + while (stack.length > 0) { const current = stack.pop()!; if (seen.has(current)) continue; seen.add(current); for (const edge of outgoing.get(current) ?? []) stack.push(edge.to); } + return seen; +} + +function topologicalNodes(plan: ExecutionPlanView) { + const remaining = new Map(plan.nodes.map((node) => [node.id, node])); + const result: ExecutionNode[] = []; + while (remaining.size > 0) { + const ready = plan.nodes.filter((node) => remaining.has(node.id) && plan.edges.filter((edge) => edge.to === node.id).every((edge) => !remaining.has(edge.from))); + if (ready.length === 0) return result; + ready.forEach((node) => { result.push(node); remaining.delete(node.id); }); + } + return result; +} + +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; + return { tone, Icon, label: labels[node.status] }; +} + +function taskTypeLabel(type: OperationalTask["task_type"]) { return { scheduled_flow: "定时流程", external_work_order: "外部工单", event_investigation: "事件调查" }[type]; } +function taskStatusLabel(status: OperationalTask["status"]) { return { queued: "排队中", running: "处理中", waiting_human: "待人工", completed: "已完成", failed: "失败", cancelled: "已取消" }[status]; } +function evidenceKindLabel(kind: OperationalTaskEvidence["kind"]) { return { system_data: "系统数据", external_feedback: "外部反馈", human_input: "人工提供", synthetic: "合成演练" }[kind]; } +function formatDateTime(value: string) { const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? value : new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(parsed); } +function approvalLabel(status: OperationalTaskAction["approval_status"]) { return { pending: "待确认", approved: "已批准", rejected: "已拒绝" }[status]; } +function approvalTone(status: OperationalTaskAction["approval_status"]): StatusTone { return status === "approved" ? "success" : status === "rejected" ? "danger" : "warning"; } +function executionLabel(status: OperationalTaskAction["execution_status"]) { return { not_submitted: "未提交", submitted: "已提交", succeeded: "执行成功", failed: "执行失败", unsupported: "未接执行器", unknown: "结果未知" }[status]; } +function executionTone(status: OperationalTaskAction["execution_status"]): StatusTone { return status === "succeeded" ? "success" : status === "failed" || status === "unsupported" ? "danger" : status === "submitted" || status === "unknown" ? "warning" : "neutral"; } diff --git a/src/features/workbench/components/agent-task-ticker-utils.ts b/src/features/workbench/components/agent-task-ticker-utils.ts index 050b67c..193a2fe 100644 --- a/src/features/workbench/components/agent-task-ticker-utils.ts +++ b/src/features/workbench/components/agent-task-ticker-utils.ts @@ -68,6 +68,12 @@ function createConditionStepView( runningConditionCount: number, dataSource: WorkbenchRuntimeDataSource ): ConditionStepTickerView { + if (condition.taskId === "water-quality-complaint") { + return { + title: condition.title, + currentStep: { id: "agent-plan", title: "Agent 执行计划", detail: "打开任务查看实际节点状态与等待事项。" } + }; + } const effectiveDataSource = condition.dataSource ?? dataSource; if (effectiveDataSource !== "mock") { return { diff --git a/src/features/workbench/components/scheduled-condition-detail-panel.test.tsx b/src/features/workbench/components/scheduled-condition-detail-panel.test.tsx index cbe1a4c..fe3c188 100644 --- a/src/features/workbench/components/scheduled-condition-detail-panel.test.tsx +++ b/src/features/workbench/components/scheduled-condition-detail-panel.test.tsx @@ -1,7 +1,9 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ScheduledConditionRecord } from "../types"; -import { ConditionDetailPanel } from "./scheduled-condition-detail-panel"; +import type { ScheduledConditionRecord, ScheduledWorkOrderItem } from "../types"; +import { ConditionDetailPanel, WorkOrderSubtasksPanel } from "./scheduled-condition-detail-panel"; +import { createVisibleTickerCards } from "./agent-task-ticker-utils"; +import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts"; const baseCondition: ScheduledConditionRecord = { id: "schedule-run-1", @@ -21,9 +23,61 @@ const baseCondition: ScheduledConditionRecord = { modelName: "TJWater scheduler" }; +it("does not infer water-quality progress or conversation steps from elapsed time", () => { + const condition: ScheduledConditionRecord = { ...baseCondition, taskId: "water-quality-complaint", dataSource: "mock", status: "running" }; + const [card] = createVisibleTickerCards([condition], 0, Date.now()); + expect(card.view.progressValue).toBeUndefined(); + expect(card.view.currentStep?.title).toBe("Agent 执行计划"); + expect(createConditionConversationPrompt(condition)).not.toContain("工作流步骤"); +}); + describe("scheduled condition backend boundaries", () => { afterEach(cleanup); + it("keeps Chinese work-order time labels in the application font", () => { + const workOrder: ScheduledWorkOrderItem = { + id: "demo-work-order", + kind: "work_order", + code: "WO-DEMO", + scheduledAt: "2026-09-23T10:00:00Z", + title: "演示预约维修", + summary: "验证中文时间标签", + status: "pending", + riskLevel: "normal", + updatedAt: Date.parse("2026-09-23T10:00:00Z"), + source: "演示数据", + dispatcher: "调度岗", + priority: "normal", + dataSource: "mock", + subtasks: [{ + id: "field-service", + title: "现场维修", + summary: "按预约窗口处理", + status: "pending", + scheduledAt: "2026-09-23T10:00:00Z", + location: "测试点", + assignee: "维修班组", + durationMinutes: 60, + replyWindowMinutes: 30, + stages: [{ + id: "dispatch", + sequence: 1, + title: "派发", + status: "pending", + owner: "调度岗", + timeLabel: "待安排", + description: "确认预约窗口。" + }], + replyRequirements: ["回填处理结果"] + }] + }; + + render(); + const timeLabel = screen.getByText("待安排", { exact: true }); + expect(timeLabel).toHaveClass("tabular-nums"); + expect(timeLabel).not.toHaveClass("font-mono"); + }); + it("shows only persisted evidence for a failed backend condition", () => { render( { expect(screen.queryByText("演示进度")).not.toBeInTheDocument(); }); + it("does not expose operational-task model, session or duration metadata", () => { + render( + + ); + + expect(screen.queryByText("TJWater operational task")).not.toBeInTheDocument(); + expect(screen.queryByText("browser-session-1")).not.toBeInTheDocument(); + expect(screen.queryByText("未记录")).not.toBeInTheDocument(); + }); + it("keeps the mock work-order interaction but labels it as a local demo draft", () => { render( { expect(screen.queryByText("系统工单已提交")).not.toBeInTheDocument(); }); - it("renders an event-triggered water-quality task as a workflow before opening chat", () => { + it("does not expose the removed mock Agent execution path", () => { const onContinueConversation = vi.fn(); render( { /> ); - expect(screen.getByText("演示执行流程 · 水质投诉调查流程")).toBeVisible(); - expect(screen.getAllByText("校验上游水质监测").at(-1)).toBeVisible(); - expect(screen.getAllByText("核查二次供水").at(-1)).toBeVisible(); - fireEvent.click(screen.getByRole("button", { name: "与 Agent 协同" })); - expect(onContinueConversation).toHaveBeenCalledOnce(); + expect(screen.getByText("后端运行状态")).toBeVisible(); + expect(screen.queryByText(/演示执行流程/)).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "与 Agent 协同" })).not.toBeInTheDocument(); + expect(onContinueConversation).not.toHaveBeenCalled(); }); }); diff --git a/src/features/workbench/components/scheduled-condition-detail-panel.tsx b/src/features/workbench/components/scheduled-condition-detail-panel.tsx index 89b22f0..e4d5025 100644 --- a/src/features/workbench/components/scheduled-condition-detail-panel.tsx +++ b/src/features/workbench/components/scheduled-condition-detail-panel.tsx @@ -18,6 +18,12 @@ import { } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useEffect, useState } from "react"; +import { AgentExecutionPlanPanel } from "./agent-execution-plan-panel"; +import type { OperationalTaskDecision } from "../api/operational-task-client"; +import type { + ExternalWorkOrderSnapshot, + OperationalTaskDataSnapshot +} from "../api/operational-task-client"; import { cn } from "@/shared/ui/cn"; import { Disclosure } from "@/shared/ui/disclosure"; import { @@ -105,6 +111,11 @@ type ConditionDetailPanelProps = { dataSource?: WorkbenchRuntimeDataSource; workOrderDataSource?: WorkbenchWorkOrderDataSource; onContinueConversation: () => void; + onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise; + onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise; + onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise; + onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise; + onTaskRefresh?: () => void; }; export function StatusPill({ @@ -115,23 +126,24 @@ export function StatusPill({ className?: string; }) { const isWorkOrder = condition.kind === "work_order"; + const isAgentEvent = condition.kind === "condition" && condition.taskId === "water-quality-complaint"; const currentStage = isWorkOrder ? getWorkOrderCurrentStage(condition) : null; return ( - {isWorkOrder ? currentStage?.title ?? "工单" : statusLabels[condition.status]} + {isAgentEvent ? "事件调查" : isWorkOrder ? currentStage?.title ?? "工单" : statusLabels[condition.status]} ); } @@ -140,7 +152,12 @@ export function ConditionDetailPanel({ condition, dataSource = "mock", workOrderDataSource = "mock", - onContinueConversation + onContinueConversation, + onTaskDecision, + onTaskExecute, + onTaskReconcile, + onLoadTaskEvidenceSnapshot, + onTaskRefresh }: ConditionDetailPanelProps) { const detailKey = condition.kind === "work_order" @@ -148,13 +165,21 @@ export function ConditionDetailPanel({ : condition.id; const content = condition.kind === "work_order" ? ( - + ) : ( ); @@ -178,12 +203,22 @@ function ConditionRecordDetailPanel({ condition, dataSource, workOrderDataSource, - onContinueConversation + onContinueConversation, + onTaskDecision, + onTaskExecute, + onTaskReconcile, + onLoadTaskEvidenceSnapshot, + onTaskRefresh }: { condition: ScheduledConditionRecord; dataSource: WorkbenchRuntimeDataSource; workOrderDataSource: WorkbenchWorkOrderDataSource; onContinueConversation: () => void; + onTaskDecision?: ConditionDetailPanelProps["onTaskDecision"]; + onTaskExecute?: ConditionDetailPanelProps["onTaskExecute"]; + onTaskReconcile?: ConditionDetailPanelProps["onTaskReconcile"]; + onLoadTaskEvidenceSnapshot?: ConditionDetailPanelProps["onLoadTaskEvidenceSnapshot"]; + onTaskRefresh?: () => void; }) { const backendCondition = (condition.dataSource ?? dataSource) !== "mock"; const insight = backendCondition ? null : getConditionAnalysisInsight(condition); @@ -191,6 +226,9 @@ function ConditionRecordDetailPanel({ const header = getConditionDetailHeader(condition); const riskLevel = getConditionReportRiskLevel(condition.status, condition.riskLevel); const bodyKey = `${condition.id}-${condition.status}-${condition.riskLevel}`; + const externalWorkOrder = condition.operationalTask?.work_order_snapshot?.subtasks + ? toScheduledWorkOrderSnapshot(condition.operationalTask.work_order_snapshot) + : null; function handleSubmitIncidentWorkOrder() { const nextWorkOrder = createSubmittedIncidentWorkOrder(condition); @@ -222,7 +260,41 @@ function ConditionRecordDetailPanel({ exit={{ opacity: 0, y: -8, scale: 0.996 }} transition={{ duration: 0.26, ease: [0.22, 1, 0.36, 1] }} > - {condition.status === "running" ? ( + {condition.operationalTask ? ( + <> + {externalWorkOrder ? : null} + {condition.operationalTask.task_type === "external_work_order" + && !condition.operationalTask.work_order_snapshot?.subtasks ? ( + + +

后端尚未提供工单过程,不根据总状态推测阶段。

+
+ ) : null} + { + if (!onTaskDecision) return Promise.reject(new Error("当前未配置人工确认接口。")); + return onTaskDecision(condition.operationalTask!.task_id, actionId, version, decision, comment); + }} + onExecute={(actionId, version) => { + if (!onTaskExecute) return Promise.reject(new Error("当前未配置动作执行接口。")); + return onTaskExecute(condition.operationalTask!.task_id, actionId, version); + }} + onReconcile={(actionId, version) => { + if (!onTaskReconcile) return Promise.reject(new Error("当前未配置回执核对接口。")); + return onTaskReconcile(condition.operationalTask!.task_id, actionId, version); + }} + onLoadEvidenceSnapshot={(evidenceId) => { + if (!onLoadTaskEvidenceSnapshot) return Promise.reject(new Error("当前未配置证据快照接口。")); + return onLoadTaskEvidenceSnapshot(condition.operationalTask!.task_id, evidenceId); + }} + onRefresh={onTaskRefresh ?? (() => undefined)} + /> + + ) : condition.taskId === "water-quality-complaint" ? ( + + ) : condition.status === "running" ? ( backendCondition ? ( ) : ( @@ -397,7 +469,7 @@ function WorkOrderDetailPanel({ />
@@ -524,7 +598,7 @@ function DetailHeader({ iconClassName )} > -
); @@ -1201,7 +1275,7 @@ function TaskExecutionStepRow({ {step.meta.map((item) => ( {item.label} - {item.value} + {item.value} ))} @@ -1212,7 +1286,7 @@ function TaskExecutionStepRow({ ); } -function WorkOrderSubtasksPanel({ condition }: { condition: ScheduledWorkOrderItem }) { +export function WorkOrderSubtasksPanel({ condition }: { condition: ScheduledWorkOrderItem }) { const currentSubtask = getWorkOrderCurrentSubtask(condition); const completedCount = condition.subtasks.filter((subtask) => subtask.status === "completed" || subtask.status === "cancelled" @@ -1303,6 +1377,55 @@ function WorkOrderSubtasksPanel({ condition }: { condition: ScheduledWorkOrderIt ); } +function toScheduledWorkOrderSnapshot(snapshot: ExternalWorkOrderSnapshot): ScheduledWorkOrderItem { + return { + id: snapshot.work_order_id, + kind: "work_order", + code: snapshot.scenario_code ?? snapshot.work_order_id, + scheduledAt: snapshot.scheduled_at ?? snapshot.updated_at, + title: snapshot.title, + summary: snapshot.summary, + status: snapshot.status === "in_progress" + ? "running" + : snapshot.status === "awaiting_reply" + ? "awaiting_reply" + : snapshot.status === "completed" + ? "completed" + : snapshot.status === "cancelled" + ? "cancelled" + : "pending", + riskLevel: snapshot.priority === "urgent" || snapshot.priority === "high" ? "attention" : "normal", + updatedAt: Date.parse(snapshot.updated_at), + durationMinutes: snapshot.subtasks?.reduce((total, item) => total + item.duration_minutes, 0), + detail: snapshot.summary, + source: "外围工单系统", + dispatcher: "外围系统同步", + priority: snapshot.priority === "urgent" || snapshot.priority === "high" ? "urgent" : "normal", + dataSource: "backend", + subtasks: (snapshot.subtasks ?? []).map((subtask) => ({ + id: subtask.id, + title: subtask.title, + summary: subtask.summary, + status: subtask.status, + scheduledAt: subtask.scheduled_at, + location: subtask.location, + assignee: subtask.assignee, + durationMinutes: subtask.duration_minutes, + replyWindowMinutes: subtask.reply_window_minutes, + stages: subtask.stages.map((stage) => ({ + id: stage.id, + sequence: stage.sequence, + title: stage.title, + status: stage.status, + owner: stage.owner, + timeLabel: stage.time_label, + description: stage.description + })), + replyRequirements: subtask.reply_requirements + })) + }; +} + function getWorkOrderSubtaskTone(status: ScheduledWorkOrderItem["subtasks"][number]["status"]): StatusTone { if (status === "completed") return "success"; if (status === "running") return "info"; diff --git a/src/features/workbench/components/scheduled-condition-feed-utils.ts b/src/features/workbench/components/scheduled-condition-feed-utils.ts index 833c9ae..57eec3a 100644 --- a/src/features/workbench/components/scheduled-condition-feed-utils.ts +++ b/src/features/workbench/components/scheduled-condition-feed-utils.ts @@ -19,6 +19,7 @@ export const statusLabels: Record = { completed: "完成", running: "执行中", awaiting_reply: "待复令", + cancelled: "已取消", warning: "关注", error: "异常", pending: "预约" @@ -28,6 +29,7 @@ const statusTones = { completed: "success", running: "info", awaiting_reply: "warning", + cancelled: "neutral", warning: "warning", error: "danger", pending: "neutral" @@ -37,6 +39,7 @@ const statusActivities = { completed: "static", running: "loading", awaiting_reply: "static", + cancelled: "static", warning: "static", error: "static", pending: "static" @@ -46,6 +49,7 @@ const statusIcons: Record = { completed: CheckCircle2, running: Loader2, awaiting_reply: Clock3, + cancelled: XCircle, warning: AlertTriangle, error: XCircle, pending: Clock3 @@ -73,6 +77,7 @@ const conditionStatusOrder: ScheduledConditionItemStatus[] = [ "warning", "error", "completed", + "cancelled", "pending" ]; diff --git a/src/features/workbench/components/scheduled-condition-feed.tsx b/src/features/workbench/components/scheduled-condition-feed.tsx index 83fe581..2c91002 100644 --- a/src/features/workbench/components/scheduled-condition-feed.tsx +++ b/src/features/workbench/components/scheduled-condition-feed.tsx @@ -1,5 +1,6 @@ import { CalendarClock, + CalendarPlus, ChevronDown, ChevronRight, ClipboardList, @@ -17,6 +18,8 @@ import type { WorkbenchWorkOrderDataSource } from "../types"; import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts"; +import type { OperationalTaskDecision } from "../api/operational-task-client"; +import type { OperationalTaskDataSnapshot } from "../api/operational-task-client"; import { cn } from "@/shared/ui/cn"; import { DropdownMenu, @@ -26,7 +29,18 @@ import { DropdownMenuTrigger } from "@/shared/ui/dropdown-menu"; import { StatusIcon as SemanticStatusIcon } from "@/shared/ui/status"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { Textarea } from "@/shared/ui/textarea"; +import { DateTimeField } from "@/shared/ui/date-time-field"; +import { FieldSelect } from "@/shared/ui/field-select"; import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel"; +import { + WORKBENCH_TASK_CATEGORIES, + getWorkbenchTaskCategory, + groupConditionsByTaskCategory, + type WorkbenchTaskCategory +} from "./task-category"; import { CONDITION_FILTER_ALL, createStatusFilterOptions, @@ -56,12 +70,22 @@ export type ScheduledConditionFeedPresentation = | "desktop-floating" | "mobile-sheet"; +export type ScheduleCreateRequest = { + scheduleKey: string; + title: string; + summary: string; + intervalSeconds: number; + nextRunAt: string; +}; + type ScheduledConditionFeedProps = { presentation?: ScheduledConditionFeedPresentation; conditions?: ScheduledConditionItem[]; expanded?: boolean; focusRequest?: ScheduledConditionFeedFocusRequest | null; loading?: boolean; + backendLoading?: boolean; + backendError?: string | null; dataSource?: WorkbenchRuntimeDataSource; workOrderDataSource?: WorkbenchWorkOrderDataSource; visible?: boolean; @@ -73,6 +97,13 @@ type ScheduledConditionFeedProps = { onClose?: () => void; onLoadHistorySession: (sessionId: string) => void | Promise; onSubmitPrompt: (prompt: string) => void | Promise; + onOpenTaskCollaboration?: (taskId: string) => Promise; + onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise; + onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise; + onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise; + onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise; + onTaskRefresh?: () => void; + onCreateSchedule?: (input: ScheduleCreateRequest) => Promise; }; type ScheduledConditionFeedFocusRequest = { @@ -86,6 +117,8 @@ export function ScheduledConditionFeed({ expanded: controlledExpanded, focusRequest, loading = false, + backendLoading = false, + backendError = null, dataSource = "mock", workOrderDataSource = "mock", visible = true, @@ -96,7 +129,14 @@ export function ScheduledConditionFeed({ onExpandAgent, onClose, onLoadHistorySession, - onSubmitPrompt + onSubmitPrompt, + onOpenTaskCollaboration, + onTaskDecision, + onTaskExecute, + onTaskReconcile, + onLoadTaskEvidenceSnapshot, + onTaskRefresh, + onCreateSchedule }: ScheduledConditionFeedProps) { const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false); const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState< @@ -105,27 +145,46 @@ export function ScheduledConditionFeed({ const [taskFilter, setTaskFilter] = useState(CONDITION_FILTER_ALL); const [statusFilter, setStatusFilter] = useState(CONDITION_FILTER_ALL); + const [taskCategory, setTaskCategory] = useState("scheduled"); + const [scheduleOpen, setScheduleOpen] = useState(false); + const [scheduleTitle, setScheduleTitle] = useState(""); + const [scheduleKey, setScheduleKey] = useState("scada-diagnosis"); + const [scheduleSummary, setScheduleSummary] = useState(""); + const [scheduleIntervalMinutes, setScheduleIntervalMinutes] = useState("15"); + const [scheduleIntervalPreset, setScheduleIntervalPreset] = useState("15"); + const [scheduleNextRunAt, setScheduleNextRunAt] = useState(() => formatLocalDateTimeInput(new Date(Date.now() + 15 * 60_000))); + const [scheduleSubmitting, setScheduleSubmitting] = useState(false); + const [scheduleError, setScheduleError] = useState(null); const mobileSheet = presentation === "mobile-sheet"; const desktopDock = presentation === "desktop-dock"; const compactPresentation = mobileSheet || desktopDock; const desktopFloating = presentation === "desktop-floating"; const expanded = controlledExpanded ?? uncontrolledExpanded; const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId; - const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo( - () => groupScheduledConditions(conditions), + const conditionsByCategory = useMemo( + () => groupConditionsByTaskCategory(conditions), [conditions] ); + const categoryConditions = conditionsByCategory[taskCategory]; + const activeLoading = taskCategory === "scheduled" ? loading : backendLoading; + const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo( + () => groupScheduledConditions(categoryConditions), + [categoryConditions] + ); const allTimelineConditions = useMemo( () => [...allRecentConditions, ...allFutureWorkOrders], [allFutureWorkOrders, allRecentConditions] ); - const taskFilterOptions = useMemo(() => createTaskFilterOptions(conditions), [conditions]); + const taskFilterOptions = useMemo( + () => createTaskFilterOptions(categoryConditions), + [categoryConditions] + ); const taskFilteredConditions = useMemo( () => - conditions.filter( + categoryConditions.filter( (condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter ), - [conditions, taskFilter] + [categoryConditions, taskFilter] ); const statusFilterOptions = useMemo( () => createStatusFilterOptions(taskFilteredConditions), @@ -150,24 +209,9 @@ export function ScheduledConditionFeed({ const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL; const totalConditionCount = allRecentConditions.length + allFutureWorkOrders.length; const filteredConditionCount = recentConditions.length + futureWorkOrders.length; - const conditionSourceLabel = { - mock: "工况演示数据", - backend: "后端当天数据", - scheduled_tasks: "后端定时任务" - }[dataSource]; - const workOrderSourceLabel = { - mock: "工单演示回退", - backend: "后端工单", - none: "未接入工单" - }[workOrderDataSource]; - const sourceLabel = `${conditionSourceLabel} · ${workOrderSourceLabel}`; - const headerSummary = loading + const headerSummary = loading && backendLoading ? "正在加载工况任务" - : expanded && filterActive - ? `筛选 ${filteredConditionCount}/${totalConditionCount} 条 · 预约 ${futureWorkOrders.length}/${allFutureWorkOrders.length} 条 · ${sourceLabel}` - : expanded - ? `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条 · ${sourceLabel}` - : `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条 · ${sourceLabel}`; + : `定时 ${conditionsByCategory.scheduled.length} · 调查 ${conditionsByCategory.investigation.length} · 预约 ${conditionsByCategory.appointment.length}`; const selectedCondition = timelineConditions.find((condition) => condition.id === selectedConditionId) ?? recentConditions[0] ?? @@ -222,20 +266,24 @@ export function ScheduledConditionFeed({ return; } - const targetCondition = allTimelineConditions.find( + const targetCondition = conditions.find( (condition) => condition.id === focusConditionId ); if (!targetCondition) { return; } + const targetCategory = getWorkbenchTaskCategory(targetCondition); + if (!targetCategory) return; + + setTaskCategory(targetCategory); setTaskFilter(CONDITION_FILTER_ALL); setStatusFilter(CONDITION_FILTER_ALL); updateSelectedConditionId(targetCondition.id); updateExpanded(true); onFocusRequestHandled?.(focusRequestId); }, [ - allTimelineConditions, + conditions, focusConditionId, focusRequestId, onFocusRequestHandled, @@ -250,6 +298,42 @@ export function ScheduledConditionFeed({ ); } + function handleTaskCategoryChange(nextCategory: WorkbenchTaskCategory) { + setTaskCategory(nextCategory); + if (nextCategory !== "scheduled") setScheduleOpen(false); + setTaskFilter(CONDITION_FILTER_ALL); + setStatusFilter(CONDITION_FILTER_ALL); + updateSelectedConditionId(conditionsByCategory[nextCategory][0]?.id ?? null); + } + + function handleToggleSchedule() { + if (!scheduleOpen) { + setTaskCategory("scheduled"); + updateExpanded(true); + } + setScheduleOpen((current) => !current); + } + + async function handleCreateSchedule() { + const title = scheduleTitle.trim(); + const summary = scheduleSummary.trim(); + const key = scheduleKey.trim(); + const intervalMinutes = Number(scheduleIntervalMinutes); + const nextRunAt = new Date(scheduleNextRunAt); + if (!title || !summary || !key) { setScheduleError("请填写名称、流程和任务说明。"); return; } + if (!Number.isInteger(intervalMinutes) || intervalMinutes < 1) { setScheduleError("执行周期不能少于 1 分钟。"); return; } + if (Number.isNaN(nextRunAt.getTime())) { setScheduleError("请选择有效的下次执行时间。"); return; } + if (!onCreateSchedule) { setScheduleError("当前未连接定时流程配置接口。"); return; } + setScheduleSubmitting(true); setScheduleError(null); + try { + await onCreateSchedule({ scheduleKey: key, title, summary, intervalSeconds: intervalMinutes * 60, nextRunAt: nextRunAt.toISOString() }); + setScheduleOpen(false); setScheduleTitle(""); setScheduleSummary(""); + showMapNotice({ tone: "success", title: "定时任务已添加", message: "仅保存在当前演示工作台,不会提交后端调度。" }); + } catch (error) { + setScheduleError(error instanceof Error ? error.message : "定时流程创建失败。"); + } finally { setScheduleSubmitting(false); } + } + function handleSelectTimelineCondition(conditionId: string) { updateSelectedConditionId(conditionId); if (!expanded) { @@ -262,6 +346,18 @@ export function ScheduledConditionFeed({ return; } + if (condition.operationalTask) { + onExpandAgent(); + void Promise.resolve(onOpenTaskCollaboration?.(condition.operationalTask.task_id)) + .catch((error) => showMapNotice({ + id: "operational-task-collaboration", + tone: "error", + title: "Agent 协作打开失败", + message: error instanceof Error ? error.message : "无法打开这条任务的协作问答。" + })); + return; + } + onExpandAgent(); if (!condition.sessionId) { @@ -346,6 +442,17 @@ export function ScheduledConditionFeed({

工况任务

{headerSummary}

+ {onCreateSchedule ? ( + + ) : null}