feat(agent): support Responses steering and project-aware tools

Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running
turns active while users steer or queue follow-ups and edit pending delivery.
Refresh the model catalog before generation and preserve approval mode selection.

Integrate scoped investigation messaging/presentation, geographic place selection,
current Agent composer styles and supporting workbench contracts.

Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and
production build. Preserve direct Server/Agent routing and project authentication.
This commit is contained in:
2026-10-19 18:46:58 +08:00
parent 2c5bf15cce
commit ec23d44b02
131 changed files with 6663 additions and 1204 deletions
@@ -12,12 +12,13 @@ import {
} from "lucide-react";
import { Button } from "@/shared/ui/button";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { Disclosure } from "@/shared/ui/disclosure";
import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input";
import { StatusBadge, type StatusTone } from "@/shared/ui/status";
import { Textarea } from "@/shared/ui/textarea";
import { WorkOrderReportPanel } from "../components/scheduled-condition-detail-panel";
import { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
import { WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
import type { ScheduledWorkOrderItem } from "../types";
import type {
PeripheralContext,
@@ -68,7 +69,7 @@ export function ManagedTestResources({ client, canWrite, capabilities, refreshGe
return (
<WorkbenchSection
title="工单接口测试"
description="验证单层预约工单、现场反馈与后端同步。"
description="分别验证预约工单、投诉与爆管事件工单及后端同步。"
icon={<ClipboardList size={15} />}
tone="teal"
bodyClassName="space-y-3"
@@ -220,6 +221,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
const canSave = canWrite && !busy && title.trim() && summary.trim();
const editorInputClassName = "mt-1 h-9 rounded-lg border-[var(--surface-divider)] bg-[var(--surface-reading)]";
const editingEvent = editing?.work_order_type !== undefined && editing.work_order_type !== "general";
const orderEditor = editorOpen ? (
<section
@@ -242,13 +244,13 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
<FieldSelect label="优先级" value={priority} onValueChange={(value) => setPriority(value as typeof priority)} disabled={!canWrite || Boolean(busy)} options={PRIORITY_OPTIONS} />
<label className="block text-xs font-medium text-slate-600">负责人<Input className={editorInputClassName} value={assignee} onChange={(event) => setAssignee(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="block text-xs font-medium text-slate-600">位置<Input className={editorInputClassName} value={location} onChange={(event) => setLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<DateTimeField label="预约时间" value={scheduledAt} onChange={setScheduledAt} disabled={!canWrite || Boolean(busy)} />
<DateTimeField label={editingEvent ? "发生时间" : "预约时间"} value={scheduledAt} onChange={setScheduledAt} disabled={!canWrite || Boolean(busy)} />
<label className="block text-xs font-medium text-slate-600">场景编号<Input className={editorInputClassName} value={scenarioCode} onChange={(event) => setScenarioCode(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
</div>
{editing ? <p className="mt-2 text-[11px] text-slate-500">工单类型:{workOrderTypeLabel(editing.work_order_type)},创建后不可修改。</p> : null}
<label className="mt-3 block text-xs font-medium text-slate-600">回执要求(每行一项)<Textarea className="mt-1 min-h-20" value={feedbackRequirements} onChange={(event) => setFeedbackRequirements(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
{editing ? <label className="mt-3 block text-xs font-medium text-slate-600">现场反馈(可选)<Textarea className="mt-1 min-h-20" value={progressNote} onChange={(event) => setProgressNote(event.target.value)} disabled={!canWrite || Boolean(busy)} placeholder="记录实际处置、复核或回执;留空则只更新工单信息" /></label> : null}
{editing ? <label className="mt-3 block text-xs font-medium text-slate-600">{editingEvent ? "处置反馈(可选)" : "现场反馈(可选)"}<Textarea className="mt-1 min-h-20" value={progressNote} onChange={(event) => setProgressNote(event.target.value)} disabled={!canWrite || Boolean(busy)} placeholder="记录实际处置、复核或回执;留空则只更新工单信息" /></label> : null}
<div className="mt-3 flex gap-2">
<Button size="sm" disabled={!canSave} onClick={() => void mutate("save-order", async () => {
@@ -295,12 +297,33 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
</section>
) : null;
const orderGroups = [
{
id: "appointment",
title: "预约工单",
description: "按预约时间记录派发、执行、复令和现场反馈。",
items: orders.filter((item) => item.work_order_type === "general")
},
{
id: "complaint",
title: "投诉工单",
description: "记录水质投诉的发生信息,并触发独立的调查任务。",
items: orders.filter((item) => item.work_order_type === "water_quality_complaint")
},
{
id: "burst",
title: "爆管上报工单",
description: "记录爆管事件信息,并触发独立的调查任务。",
items: orders.filter((item) => item.work_order_type === "burst_report")
}
];
return (
<div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h4 className="text-xs font-semibold text-slate-800">测试工单</h4>
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">每张预约工单对应一条任务;修改和现场反馈按时间记录。</p>
<h4 className="text-xs font-semibold text-slate-800">分类工单</h4>
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">预约工单进入预约任务;投诉与爆管上报进入事件调查。</p>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={!canWrite || Boolean(busy)} onClick={() => setEventEditorOpen((current) => !current)}>
@@ -341,35 +364,53 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
{editorOpen && !editing ? orderEditor : null}
<section aria-label="工单列表" className="mt-3">
{orders.length ? (
<ol className="space-y-2">
{orders.map((item, index) => (
<li key={item.work_order_id}>
<WorkOrderCard
item={item}
index={index}
canWrite={canWrite}
busy={busy}
editing={editing?.work_order_id === item.work_order_id}
editor={editing?.work_order_id === item.work_order_id ? orderEditor : null}
onEdit={(trigger) => edit(item, trigger)}
confirmingDelete={deleteCandidateId === item.work_order_id}
onRequestDelete={() => setDeleteCandidateId(item.work_order_id)}
onCancelDelete={() => setDeleteCandidateId(null)}
onConfirmDelete={() => void mutate(`delete-order-${item.work_order_id}`, async () => {
await client.deleteWorkOrder(item.work_order_id, item.revision);
setDeleteCandidateId(null);
if (editing?.work_order_id === item.work_order_id) clear();
})}
/>
</li>
))}
</ol>
) : (
<WorkbenchEmptyState icon={<ClipboardList size={15} />} title="暂无测试工单" description="创建预约工单或事件工单后,可在这里核对数据库返回结构。" />
)}
</section>
<div aria-label="分类工单列表" className="mt-3 space-y-3">
{orderGroups.map((group) => (
<Disclosure
key={group.id}
aria-label={`${group.title}列表`}
initiallyOpen
className="rounded-xl border border-slate-200/90 bg-white px-3"
label={(
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<h5 className="text-xs font-semibold text-slate-800">{group.title}</h5>
<p className="mt-0.5 text-[11px] font-normal leading-4 text-slate-500">{group.description}</p>
</div>
<StatusBadge tone="neutral">{group.items.length}</StatusBadge>
</div>
)}
>
{group.items.length ? (
<ol className="space-y-2 border-t border-slate-200/80 py-3">
{group.items.map((item, index) => (
<li key={item.work_order_id}>
<WorkOrderCard
item={item}
index={index}
canWrite={canWrite}
busy={busy}
editing={editing?.work_order_id === item.work_order_id}
editor={editing?.work_order_id === item.work_order_id ? orderEditor : null}
onEdit={(trigger) => edit(item, trigger)}
confirmingDelete={deleteCandidateId === item.work_order_id}
onRequestDelete={() => setDeleteCandidateId(item.work_order_id)}
onCancelDelete={() => setDeleteCandidateId(null)}
onConfirmDelete={() => void mutate(`delete-order-${item.work_order_id}`, async () => {
await client.deleteWorkOrder(item.work_order_id, item.revision);
setDeleteCandidateId(null);
if (editing?.work_order_id === item.work_order_id) clear();
})}
/>
</li>
))}
</ol>
) : (
<p className="mb-3 border-t border-slate-200/80 bg-slate-50 px-3 py-2 text-[11px] text-slate-500">暂无{group.title}</p>
)}
</Disclosure>
))}
</div>
</div>
);
}
@@ -387,7 +428,7 @@ function WorkOrderCard({ item, index, canWrite, busy, editing, editor, onEdit, c
onCancelDelete: () => void;
onConfirmDelete: () => void;
}) {
const workOrder = toScheduledWorkOrder(item);
const workOrder = item.work_order_type === "general" ? toScheduledWorkOrder(item) : null;
const deleteButtonRef = useRef<HTMLButtonElement>(null);
const cancelDelete = () => {
onCancelDelete();
@@ -398,7 +439,7 @@ function WorkOrderCard({ item, index, canWrite, busy, editing, editor, onEdit, c
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[10px] font-semibold text-slate-400">工单 {String(index + 1).padStart(2, "0")}</span>
<span className="text-[10px] font-semibold text-slate-400">{workOrderTypeLabel(item.work_order_type)} {String(index + 1).padStart(2, "0")}</span>
<StatusBadge tone={orderStatusTone(item.status)}>{orderStatus(item.status)}</StatusBadge>
<StatusBadge tone={item.work_order_type === "burst_report" ? "danger" : item.work_order_type === "water_quality_complaint" ? "warning" : "neutral"}>{workOrderTypeLabel(item.work_order_type)}</StatusBadge>
</div>
@@ -440,16 +481,47 @@ function WorkOrderCard({ item, index, canWrite, busy, editing, editor, onEdit, c
<span>{priorityLabel(item.priority)}优先级</span>
{item.location ? <span>位置:{item.location}</span> : null}
{item.assignee ? <span>负责人:{item.assignee}</span> : null}
{item.scheduled_at ? <span className="inline-flex items-center gap-1"><CalendarClock size={11} />{formatTimestamp(item.scheduled_at)}</span> : null}
{item.scheduled_at ? <span className="inline-flex items-center gap-1"><CalendarClock size={11} />{item.work_order_type === "general" ? "预约" : "发生"}:{formatTimestamp(item.scheduled_at)}</span> : null}
<span className="font-mono">r{item.revision}</span>
</div>
<div className="mt-3">
<WorkOrderReportPanel condition={workOrder} />
{workOrder ? <WorkOrderReportPanel condition={workOrder} /> : <EventWorkOrderDetails item={item} />}
</div>
</article>
);
}
function EventWorkOrderDetails({ item }: { item: PeripheralWorkOrder }) {
return (
<section aria-label={`${workOrderTypeLabel(item.work_order_type)}信息`} className="rounded-xl border border-slate-200 bg-white px-3 py-3">
<p className="text-[10px] font-semibold text-slate-500">事件信息</p>
<dl className="mt-2 grid grid-cols-2 gap-1.5 sm:grid-cols-3">
<EventWorkOrderField label="事件类型" value={workOrderTypeLabel(item.work_order_type)} />
<EventWorkOrderField label="发生时间" value={item.scheduled_at ? formatTimestamp(item.scheduled_at) : "未记录"} />
<EventWorkOrderField label="位置" value={item.location ?? "未填写"} />
<EventWorkOrderField label="场景编号" value={item.scenario_code ?? "未填写"} />
<EventWorkOrderField label="处理人" value={item.assignee ?? "待分配"} />
<EventWorkOrderField label="活动记录" value={`${item.activity?.length ?? 0} 条`} />
</dl>
{item.activity?.length ? (
<ol aria-label="事件活动记录" className="mt-3 space-y-1.5 border-t border-slate-200 pt-3">
{item.activity.map((entry) => (
<li key={entry.sequence} className="rounded-lg bg-slate-50 px-2.5 py-2 text-[11px] leading-4 text-slate-600">
<span className="font-semibold text-slate-800">{entry.title}</span>
<span className="ml-2">{entry.detail}</span>
<span className="mt-1 block text-[10px] text-slate-400">{formatTimestamp(entry.occurred_at)} · {entry.actor}</span>
</li>
))}
</ol>
) : <p className="mt-3 border-t border-slate-200 pt-3 text-[11px] text-slate-500">暂无事件活动记录</p>}
</section>
);
}
function EventWorkOrderField({ label, value }: { label: string; value: string }) {
return <div className="min-w-0 rounded-lg bg-slate-50 px-2.5 py-2"><dt className="text-[10px] font-semibold text-slate-500">{label}</dt><dd className="mt-0.5 break-words text-[11px] font-medium text-slate-800">{value}</dd></div>;
}
function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem {
return {
id: item.work_order_id,
@@ -501,4 +573,4 @@ function message(error: unknown) { return error instanceof Error ? error.message
function orderStatus(value: PeripheralWorkOrder["status"]) { return ({ draft: "草稿", open: "待处理", in_progress: "处理中", awaiting_reply: "待复令", completed: "已完成", cancelled: "已取消" } satisfies Record<typeof value, string>)[value]; }
function orderStatusTone(value: PeripheralWorkOrder["status"]): StatusTone { return ({ draft: "neutral", open: "info", in_progress: "warning", awaiting_reply: "warning", completed: "success", cancelled: "neutral" } satisfies Record<typeof value, StatusTone>)[value]; }
function priorityLabel(value: PeripheralWorkOrder["priority"]) { return ({ low: "低", normal: "普通", high: "高", urgent: "紧急" } satisfies Record<typeof value, string>)[value]; }
function workOrderTypeLabel(value: PeripheralWorkOrder["work_order_type"]) { return ({ general: "预约/维修", water_quality_complaint: "水质投诉", burst_report: "爆管上报" } satisfies Record<typeof value, string>)[value]; }
function workOrderTypeLabel(value: PeripheralWorkOrder["work_order_type"]) { return ({ general: "预约工单", water_quality_complaint: "投诉工单", burst_report: "爆管上报工单" } satisfies Record<typeof value, string>)[value]; }