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
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { Crosshair, RotateCcw, X, ZoomIn } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { cn } from "@/shared/ui/cn";
import { Disclosure } from "@/shared/ui/disclosure";
import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input";
import {
@@ -30,6 +30,8 @@ type MapDevPanelProps = {
detailFeature: DetailFeature | null;
flowDataSource?: "realtime" | "mock";
onClose: () => void;
investigationFollow?: boolean;
onInvestigationFollowChange?: (enabled: boolean) => void;
};
const GROUP_LABELS = Object.fromEntries(
@@ -102,7 +104,9 @@ export function MapDevPanel({
controllerState,
detailFeature,
flowDataSource = "mock",
onClose
onClose,
investigationFollow = true,
onInvestigationFollowChange
}: MapDevPanelProps) {
const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes");
const [featureId, setFeatureId] = useState("440681");
@@ -231,13 +235,18 @@ export function MapDevPanel({
return (
<aside
aria-label="地图开发面板"
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] flex-col overflow-hidden rounded-2xl border lg:flex"
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
data-testid="map-dev-panel"
>
<div className="flex h-12 shrink-0 items-center justify-between border-b border-[var(--surface-divider)]/70 px-4">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[var(--surface-divider)]/70 px-4 py-3">
<div>
<h2 className="text-sm font-semibold text-slate-950">地图能力 Dev Panel</h2>
<p className="text-[11px] text-[var(--ink-muted)]">仅本次页面会话生效</p>
<h2 className="text-sm font-semibold text-foreground">
地图调试{" "}
<span className="ml-1 text-[11px] font-medium text-[var(--ink-muted)]">Dev Panel</span>
</h2>
<p className="mt-1 text-[11px] leading-4 text-[var(--ink-muted)]">
按功能展开 · 设置仅本次页面会话生效
</p>
</div>
<Button
size="icon"
@@ -250,18 +259,40 @@ export function MapDevPanel({
</Button>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 [scrollbar-gutter:stable]">
{controllerState.errorCode ? (
<div
role="alert"
className="status-tone-danger rounded-xl border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs font-medium text-[var(--status-foreground)]"
className="status-tone-danger my-3 break-words rounded-xl border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs font-medium text-[var(--status-foreground)]"
>
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
</div>
) : null}
<PanelSection title="Feature" description="WFS 验证后执行独立高亮或相机定位。">
<div className="grid grid-cols-[120px_1fr] gap-2">
{onInvestigationFollowChange ? (
<PanelSection
title="调查联动"
description="选中调查后,自动跟随新的高亮、图表与影响范围展示。"
initiallyOpen
status={investigationFollow ? "已开启" : "已关闭"}
>
<ToggleField
label="跟随所选调查"
checked={investigationFollow}
onChange={onInvestigationFollowChange}
/>
</PanelSection>
) : null}
<PanelSection
title="要素定位"
description="读取目标要素后,执行地图高亮或相机定位。"
initiallyOpen
status={
controllerState.pending ? "执行中" : controllerState.target ? "已高亮" : undefined
}
>
<div className="grid grid-cols-[120px_minmax(0,1fr)] gap-2">
<FieldSelect
label="业务源"
value={sourceId}
@@ -336,10 +367,13 @@ export function MapDevPanel({
</PanelSection>
<PanelSection
title="全网水流"
description={flowDataSource === "realtime"
? "已读取后端最近 15 分钟的管道流速;可用有符号流速覆盖单管方向。"
: "后端流速不可用,开启时使用正反向演示流向;有符号流速可覆盖单管方向。"}
title="水流调试"
status={controllerState.flowVisible ? "已开启" : "未开启"}
description={
flowDataSource === "realtime"
? "已读取后端最近 15 分钟的管道流速;可用有符号流速覆盖单管方向。"
: "后端流速不可用,开启时使用正反向演示流向;有符号流速可覆盖单管方向。"
}
>
<div className="grid grid-cols-2 gap-2">
<Button
@@ -407,8 +441,9 @@ export function MapDevPanel({
</div>
{flowSummary.total > 0 ? (
<StatusText>
{flowDataSource === "realtime" ? "后端实时流速" : "演示流向"} · 已赋值 {flowSummary.total} · 正向 {flowSummary.forward} · 反向{" "}
{flowSummary.reverse} · 停止 {flowSummary.stopped}
{flowDataSource === "realtime" ? "后端实时流速" : "演示流向"} · 已赋值{" "}
{flowSummary.total} · 正向 {flowSummary.forward} · 反向 {flowSummary.reverse} · 停止{" "}
{flowSummary.stopped}
</StatusText>
) : controllerState.flowVisible ? (
<StatusText>演示流向已启用 · 正反向交错显示</StatusText>
@@ -418,8 +453,13 @@ export function MapDevPanel({
</PanelSection>
<PanelSection
title="样式编辑"
description="统一配置业务几何与整层 MVT 标注,交互语义色保持不变。"
title="图层样式"
status={
controllerState.styledGroups.length > 0
? `${controllerState.styledGroups.length} 组已覆盖`
: undefined
}
description="调整业务图层的颜色、透明度和尺寸,交互语义色保持不变。"
>
<FieldSelect
label="业务图层组"
@@ -519,91 +559,6 @@ export function MapDevPanel({
) : null}
</>
)}
<div className="space-y-2.5 rounded-xl bg-[var(--surface-control)]/75 p-3 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.18)]">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-[11px] font-semibold text-slate-800">整层标注</h4>
<p className="mt-0.5 text-[10px] leading-4 text-[var(--ink-muted)]">
作用于当前图层所有可见 Feature,自动避让重叠。
</p>
</div>
<span className="shrink-0 rounded-md bg-teal-50 px-1.5 py-0.5 text-[9px] font-semibold text-teal-700">
MVT 原生
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
</div>
{showValueLabels ? (
<>
<FieldSelect
label="数值属性"
value={property}
onValueChange={setProperty}
options={LABEL_PRESETS[groupId].options}
/>
<div className="grid grid-cols-2 gap-2">
<Field label="精度 0-3">
<Input
type="number"
min={0}
max={3}
value={precision}
onChange={(event) => setPrecision(Number(event.target.value))}
className={inputClassName}
/>
</Field>
<Field label="单位">
<Input
maxLength={16}
value={unit}
onChange={(event) => setUnit(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
</>
) : null}
<RangeField
label="字号"
min={10}
max={18}
step={1}
value={labelSize}
onChange={setLabelSize}
/>
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!showValueLabels && !showFeatureIds}
onClick={applyLabels}
className="w-full text-xs"
>
应用标注
</Button>
<Button
type="button"
disabled={!hasActiveLabels}
onClick={() => {
setShowValueLabels(false);
setShowFeatureIds(false);
commands.clearValueLabel();
}}
className="w-full text-xs"
variant="outline"
>
清除全部标注
</Button>
</div>
{activeLabel ? (
<StatusText>
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
</StatusText>
) : null}
</div>
<div className="grid grid-cols-2 gap-2">
<Button type="button" onClick={applyLayerStyle} className="w-full text-xs">
应用图层样式
@@ -621,6 +576,95 @@ export function MapDevPanel({
<StatusText>已覆盖当前图层组样式</StatusText>
) : null}
</PanelSection>
<PanelSection
title="数值标注"
description="作用于所选图层的全部可见要素,使用原生 MVT 标注并自动避让。"
status={
hasActiveLabels
? `${Object.keys(controllerState.valueLabels).length} 组已标注`
: undefined
}
>
<FieldSelect
label="标注图层组"
value={groupId}
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
/>
<div className="grid grid-cols-2 gap-2">
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
</div>
{showValueLabels ? (
<>
<FieldSelect
label="数值属性"
value={property}
onValueChange={setProperty}
options={LABEL_PRESETS[groupId].options}
/>
<div className="grid grid-cols-2 gap-2">
<Field label="精度 0-3">
<Input
type="number"
min={0}
max={3}
value={precision}
onChange={(event) => setPrecision(Number(event.target.value))}
className={inputClassName}
/>
</Field>
<Field label="单位">
<Input
maxLength={16}
value={unit}
onChange={(event) => setUnit(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
</>
) : null}
<RangeField
label="字号"
min={10}
max={18}
step={1}
value={labelSize}
onChange={setLabelSize}
/>
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!showValueLabels && !showFeatureIds}
onClick={applyLabels}
className="w-full text-xs"
>
应用标注
</Button>
<Button
type="button"
disabled={!hasActiveLabels}
onClick={() => {
setShowValueLabels(false);
setShowFeatureIds(false);
commands.clearValueLabel();
}}
className="w-full text-xs"
variant="outline"
>
清除全部标注
</Button>
</div>
{activeLabel ? (
<StatusText>
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
</StatusText>
) : null}
</PanelSection>
</div>
<div className="shrink-0 border-t border-[var(--surface-divider)]/70 p-3">
@@ -631,7 +675,7 @@ export function MapDevPanel({
variant="outline"
>
<RotateCcw size={14} />
重置全部
重置地图调试效果
</Button>
</div>
</aside>
@@ -641,27 +685,43 @@ export function MapDevPanel({
function PanelSection({
title,
description,
initiallyOpen = false,
status,
children
}: {
title: string;
description: string;
initiallyOpen?: boolean;
status?: string;
children: React.ReactNode;
}) {
return (
<section className="space-y-2.5 border-b border-[var(--surface-divider)]/70 px-1 pb-4 last:border-b-0 last:pb-1">
<div>
<h3 className="text-xs font-semibold text-slate-900">{title}</h3>
<p className="mt-0.5 text-[11px] leading-4 text-[var(--ink-muted)]">{description}</p>
<Disclosure
initiallyOpen={initiallyOpen}
className="border-b border-[var(--surface-divider)]/70 py-1 last:border-b-0 [&>summary]:px-2 [&>summary]:text-foreground"
label={
<span className="flex min-w-0 items-center justify-between gap-2">
<span className="font-semibold">{title}</span>
{status ? (
<span className="shrink-0 text-[11px] font-normal text-[var(--ink-muted)]">
{status}
</span>
) : null}
</span>
}
>
<div className="space-y-3 px-2 pb-3 pt-1">
<p className="text-[11px] leading-5 text-[var(--ink-muted)]">{description}</p>
{children}
</div>
{children}
</section>
</Disclosure>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block min-w-0">
<span className="mb-1 block text-[11px] font-medium text-slate-600">{label}</span>
<span className="mb-1.5 block text-xs font-medium text-[var(--ink-muted)]">{label}</span>
{children}
</label>
);
@@ -677,11 +737,10 @@ function ToggleField({
onChange: (checked: boolean) => void;
}) {
return (
<label className="flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-[var(--surface-reading)]/80 px-2.5 text-[11px] font-medium text-slate-700 shadow-[inset_0_0_0_1px_rgba(203,213,225,0.9)]">
<label className="flex min-h-10 cursor-pointer items-center gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2.5 py-2 text-xs font-medium text-foreground">
<Checkbox
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="size-4 rounded"
/>
{label}
</label>
@@ -699,14 +758,15 @@ function ColorField({
}) {
return (
<Field label={label}>
<div className="flex h-9 items-center gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2">
<div className="flex min-h-10 items-center gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2">
<input
type="color"
value={value}
onChange={(event) => onChange(event.target.value.toUpperCase())}
className="h-5 w-7 cursor-pointer border-0 bg-transparent p-0"
aria-label={label}
className="h-10 w-10 shrink-0 cursor-pointer rounded border-0 bg-transparent p-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--action-blue)]"
/>
<span className="text-xs font-medium text-slate-600">{value}</span>
<span className="text-xs font-medium tabular-nums text-[var(--ink-muted)]">{value}</span>
</div>
</Field>
);
@@ -737,7 +797,7 @@ function RangeField({
step={step}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
className="h-6 w-full accent-[var(--action-blue)]"
className="h-10 w-full rounded accent-[var(--action-blue)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--action-blue)]"
/>
</Field>
);
@@ -747,7 +807,7 @@ function StatusText({ children }: { children: React.ReactNode }) {
return (
<p
role="status"
className="status-tone-success rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium text-[var(--status-foreground)]"
className="status-tone-success break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium leading-5 text-[var(--status-foreground)] [overflow-wrap:anywhere]"
>
{children}
</p>
@@ -755,4 +815,4 @@ function StatusText({ children }: { children: React.ReactNode }) {
}
const inputClassName =
"h-10 w-full rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2.5 text-xs text-slate-800 transition-colors";
"h-10 w-full rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2.5 text-xs text-foreground transition-colors";