From ed60a13f12d49a8eca69f4d3541ee4468398b3c5 Mon Sep 17 00:00:00 2001 From: Huarch Date: Tue, 14 Jul 2026 11:01:07 +0800 Subject: [PATCH] fix(agent): validate UI envelopes --- .../agent/components/agent-command-panel.tsx | 21 +++- .../agent/ui-envelope/map-actions.test.ts | 22 ++-- features/agent/ui-envelope/map-actions.ts | 22 +++- features/agent/ui-envelope/validator.test.ts | 45 +++++-- features/agent/ui-envelope/validator.ts | 79 ++++++++++-- shared/ai-elements/suggestion.tsx | 118 ++++++++++++++++++ 6 files changed, 276 insertions(+), 31 deletions(-) create mode 100644 shared/ai-elements/suggestion.tsx diff --git a/features/agent/components/agent-command-panel.tsx b/features/agent/components/agent-command-panel.tsx index 98009e3..b712508 100644 --- a/features/agent/components/agent-command-panel.tsx +++ b/features/agent/components/agent-command-panel.tsx @@ -29,6 +29,7 @@ import { PromptInputSubmit, PromptInputTextarea } from "@/shared/ai-elements/prompt-input"; +import { Suggestion, Suggestions } from "@/shared/ai-elements/suggestion"; import { DropdownMenu, DropdownMenuContent, @@ -93,6 +94,12 @@ type AgentCommandPanelProps = { onRejectQuestion?: (request: AgentQuestionRequest) => Promise | void; }; +const AGENT_PROMPT_SUGGESTIONS = [ + "当前监测数据质量如何,是否存在缺失、失真或可信度不足的问题?", + "当前监测数据中有哪些异常问题需要重点关注?", + "当前管网哪些区域可能存在雨污混接,判断依据是什么?" +] as const; + export function AgentCommandPanel({ collapsing = false, personaState, @@ -305,7 +312,18 @@ export function AgentCommandPanel({ -
+
+ + {AGENT_PROMPT_SUGGESTIONS.map((suggestion) => ( + + ))} + { @@ -626,4 +644,3 @@ function BackendMessageList({ ); } - diff --git a/features/agent/ui-envelope/map-actions.test.ts b/features/agent/ui-envelope/map-actions.test.ts index 9c43898..2ef3d7f 100644 --- a/features/agent/ui-envelope/map-actions.test.ts +++ b/features/agent/ui-envelope/map-actions.test.ts @@ -11,20 +11,26 @@ describe("toTrustedMapAction", () => { expect(toTrustedMapAction("run_javascript", { code: "alert(1)" })).toBeNull(); }); - it("downgrades malformed apply_layer_style params to safe undefined fields", () => { + it("rejects malformed or empty apply_layer_style params", () => { expect( toTrustedMapAction("apply_layer_style", { layer_group_id: 123, layer_id: [], visible: "true" }) - ).toEqual({ - type: "apply_layer_style", - layerGroupId: undefined, - layerId: undefined, - visible: undefined, - fallbackText: undefined - }); + ).toBeNull(); + expect(toTrustedMapAction("apply_layer_style", {})).toBeNull(); + }); + + it("rejects out-of-range coordinates and zoom", () => { + expect(toTrustedMapAction("zoom_to_map", { center: [181, 39.1] })).toBeNull(); + expect(toTrustedMapAction("zoom_to_map", { center: [117.2, -91] })).toBeNull(); + expect(toTrustedMapAction("zoom_to_map", { center: [117.2, 39.1], zoom: 25 })).toBeNull(); + }); + + it("rejects empty and oversized locate requests", () => { + expect(toTrustedMapAction("locate_features", { ids: [] })).toBeNull(); + expect(toTrustedMapAction("locate_features", { ids: Array.from({ length: 101 }, (_, index) => `P${index}`) })).toBeNull(); }); it("accepts valid zoom_to_map coordinates", () => { diff --git a/features/agent/ui-envelope/map-actions.ts b/features/agent/ui-envelope/map-actions.ts index c96c9e7..d7990be 100644 --- a/features/agent/ui-envelope/map-actions.ts +++ b/features/agent/ui-envelope/map-actions.ts @@ -31,6 +31,7 @@ export function toTrustedMapAction(action: string, params: unknown, fallbackText if (action === "locate_features" || action in LEGACY_LOCATE_ACTION_LAYERS) { const featureIds = readLocateIds(params); + if (featureIds.length === 0 || featureIds.length > 100 || featureIds.some((id) => id.length > 128)) return null; return { type: "locate_features", featureIds, @@ -47,27 +48,35 @@ export function toTrustedMapAction(action: string, params: unknown, fallbackText return null; } + const zoom = numberValue(params.zoom); + if (center[0] < -180 || center[0] > 180 || center[1] < -90 || center[1] > 90 || + (zoom !== undefined && (zoom < 0 || zoom > 24))) return null; + return { type: "zoom_to_map", center, - zoom: numberValue(params.zoom), + zoom, fallbackText }; } if (action === "apply_layer_style") { + const layerGroupId = stringValue(params.layer_group_id ?? params.layerGroupId); + const layerId = stringValue(params.layer_id ?? params.layerId); + const visible = booleanValue(params.visible); + if ((!layerGroupId && !layerId) || visible === undefined || (layerId && !ALLOWED_LAYER_IDS.has(layerId))) return null; return { type: "apply_layer_style", - layerGroupId: stringValue(params.layer_group_id ?? params.layerGroupId), - layerId: stringValue(params.layer_id ?? params.layerId), - visible: booleanValue(params.visible), + layerGroupId, + layerId, + visible, fallbackText }; } if (action === "render_junctions") { const renderRef = stringValue(params.render_ref ?? params.renderRef); - if (!renderRef) { + if (!renderRef || !RESULT_REF_PATTERN.test(renderRef)) { return null; } @@ -134,6 +143,9 @@ const LEGACY_LOCATE_ACTION_LAYERS: Record = { locate_tanks: "geo_tanks" }; +const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/; +const ALLOWED_LAYER_IDS = new Set(["junctions", "pipes"]); + const LOCATE_ID_PARAM_KEYS = [ "feature_ids", "feature_id", diff --git a/features/agent/ui-envelope/validator.test.ts b/features/agent/ui-envelope/validator.test.ts index c73e46f..fa1fc72 100644 --- a/features/agent/ui-envelope/validator.test.ts +++ b/features/agent/ui-envelope/validator.test.ts @@ -13,6 +13,19 @@ const registry: UIRegistry = { }; describe("UIEnvelope validation", () => { + it("fails closed when the backend registry is unavailable", () => { + const envelope = parseUiEnvelope({ + kind: "chart", + schemaVersion: "agent-ui@1", + grammar: "echarts-safe-subset", + surface: "chat_inline", + spec: { chart_type: "line" }, + data: { x_data: ["a"], series: [{ name: "s", data: [1] }] } + }); + expect(envelope).not.toBeNull(); + expect(envelope ? isUiEnvelopeAllowed(envelope, null) : true).toBe(false); + }); + it("rejects registered components outside the frontend allowlist", () => { const envelope = parseUiEnvelope({ kind: "registered_component", @@ -22,8 +35,7 @@ describe("UIEnvelope validation", () => { props: {} }); - expect(envelope).not.toBeNull(); - expect(envelope ? isUiEnvelopeAllowed(envelope, registry) : false).toBe(false); + expect(envelope).toBeNull(); }); it("rejects registered components on unsupported surfaces", () => { @@ -32,7 +44,7 @@ describe("UIEnvelope validation", () => { schemaVersion: "agent-ui@1", component: "HistoryPanel", surface: "chat_inline", - props: {} + props: { feature_infos: [["J1", "junction"]], data_type: "realtime" } }); expect(envelope).not.toBeNull(); @@ -46,8 +58,8 @@ describe("UIEnvelope validation", () => { schemaVersion: "agent-ui@1", grammar: "vega-lite", surface: "chat_inline", - spec: {}, - data: {} + spec: { chart_type: "line" }, + data: { x_data: ["a"], series: [{ name: "s", data: [1] }] } }) ).toBeNull(); @@ -56,14 +68,33 @@ describe("UIEnvelope validation", () => { schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "chat_inline", - spec: {}, - data: {} + spec: { chart_type: "line" }, + data: { x_data: ["a"], series: [{ name: "s", data: [1] }] } }); expect(envelope).not.toBeNull(); expect(envelope ? isUiEnvelopeAllowed(envelope, registry) : false).toBe(true); }); + it("rejects unsupported chart types and mismatched data", () => { + expect(parseUiEnvelope({ + kind: "chart", + schemaVersion: "agent-ui@1", + grammar: "echarts-safe-subset", + surface: "chat_inline", + spec: { chart_type: "pie" }, + data: { x_data: ["a"], series: [{ name: "s", data: [1] }] } + })).toBeNull(); + expect(parseUiEnvelope({ + kind: "chart", + schemaVersion: "agent-ui@1", + grammar: "echarts-safe-subset", + surface: "chat_inline", + spec: { chart_type: "line" }, + data: { x_data: ["a", "b"], series: [{ name: "s", data: [1] }] } + })).toBeNull(); + }); + it("filters invalid registry surfaces before allowlist checks", () => { const parsed = parseUiRegistry({ schema_version: "agent-ui-registry@1", diff --git a/features/agent/ui-envelope/validator.ts b/features/agent/ui-envelope/validator.ts index 9d8f3a3..ef0e139 100644 --- a/features/agent/ui-envelope/validator.ts +++ b/features/agent/ui-envelope/validator.ts @@ -1,6 +1,19 @@ +import { normalizeSafeChartData, parseChartSpec } from "../chart-data"; +import { toTrustedMapAction } from "./map-actions"; import type { UIEnvelope, UIEnvelopePayload, UIRegistry, UISurface } from "./types"; const surfaces = new Set(["chat_inline", "side_panel", "canvas", "map_overlay"]); +const MAX_ID_LENGTH = 128; +const LOCAL_COMPONENT_SURFACES = new Map([ + ["HistoryPanel", ["side_panel", "canvas"]], + ["ScadaPanel", ["side_panel", "canvas"]] +]); +const LOCAL_ACTION_SURFACES = new Map([ + ["locate_features", ["map_overlay"]], + ["zoom_to_map", ["map_overlay"]], + ["render_junctions", ["map_overlay"]], + ["apply_layer_style", ["map_overlay"]] +]); export function parseUiEnvelopePayload(value: unknown): UIEnvelopePayload | null { if (!isRecord(value)) { @@ -12,10 +25,16 @@ export function parseUiEnvelopePayload(value: unknown): UIEnvelopePayload | null return null; } + const sessionId = boundedString(value.session_id, MAX_ID_LENGTH); + const envelopeId = boundedString(value.envelope_id, MAX_ID_LENGTH); + if (!sessionId || !envelopeId || !isFiniteTimestamp(value.created_at)) { + return null; + } + return { - session_id: typeof value.session_id === "string" ? value.session_id : "", - envelope_id: typeof value.envelope_id === "string" ? value.envelope_id : "", - created_at: typeof value.created_at === "number" ? value.created_at : Date.now(), + session_id: sessionId, + envelope_id: envelopeId, + created_at: value.created_at, envelope }; } @@ -26,7 +45,7 @@ export function parseUiEnvelope(value: unknown): UIEnvelope | null { } if (value.kind === "registered_component") { - if (typeof value.component !== "string" || !isSurface(value.surface)) { + if (typeof value.component !== "string" || !isSurface(value.surface) || !isValidComponentProps(value.component, value.props)) { return null; } @@ -42,7 +61,7 @@ export function parseUiEnvelope(value: unknown): UIEnvelope | null { } if (value.kind === "chart") { - if (value.grammar !== "echarts-safe-subset" || !isChartSurface(value.surface)) { + if (value.grammar !== "echarts-safe-subset" || !isChartSurface(value.surface) || !isValidChart(value.spec, value.data)) { return null; } @@ -58,7 +77,7 @@ export function parseUiEnvelope(value: unknown): UIEnvelope | null { } if (value.kind === "map_action") { - if (typeof value.action !== "string" || value.surface !== "map_overlay") { + if (typeof value.action !== "string" || value.surface !== "map_overlay" || !toTrustedMapAction(value.action, value.params)) { return null; } @@ -77,18 +96,20 @@ export function parseUiEnvelope(value: unknown): UIEnvelope | null { export function isUiEnvelopeAllowed(envelope: UIEnvelope, registry: UIRegistry | null) { if (!registry) { - return true; + return false; } if (envelope.kind === "registered_component") { + if (!LOCAL_COMPONENT_SURFACES.get(envelope.component)?.includes(envelope.surface)) return false; const manifest = registry.components.find((item) => item.id === envelope.component); return Boolean(manifest?.supportedSurfaces.includes(envelope.surface)); } if (envelope.kind === "chart") { - return registry.chart_grammars.includes(envelope.grammar); + return envelope.grammar === "echarts-safe-subset" && registry.chart_grammars.includes(envelope.grammar); } + if (!LOCAL_ACTION_SURFACES.get(envelope.action)?.includes(envelope.surface)) return false; const manifest = registry.actions.find((item) => item.id === envelope.action); return Boolean(manifest?.supportedSurfaces.includes(envelope.surface)); } @@ -137,5 +158,45 @@ function optionalString(value: unknown) { } function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function boundedString(value: unknown, maxLength: number) { + return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength ? value.trim() : null; +} + +function isFiniteTimestamp(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +function hasOnlyKeys(value: Record, allowed: readonly string[]) { + const keys = new Set(allowed); + return Object.keys(value).every((key) => keys.has(key)); +} + +function isValidComponentProps(component: string, props: unknown) { + if (!isRecord(props)) return false; + if (component === "HistoryPanel") { + if (!hasOnlyKeys(props, ["reason", "feature_infos", "data_type", "start_time", "end_time"])) return false; + const items = props.feature_infos; + return Array.isArray(items) && items.length > 0 && items.length <= 100 && + items.every((item) => Array.isArray(item) && item.length === 2 && item.every((part) => boundedString(part, 128))) && + (props.data_type === "realtime" || props.data_type === "scheme" || props.data_type === "none"); + } + if (component === "ScadaPanel") { + if (!hasOnlyKeys(props, ["reason", "device_ids", "device_id", "start_time", "end_time"])) return false; + const ids = Array.isArray(props.device_ids) ? props.device_ids : props.device_id ? [props.device_id] : []; + return ids.length > 0 && ids.length <= 100 && ids.every((id) => Boolean(boundedString(id, 128))); + } + return false; +} + +function isValidChart(spec: unknown, data: unknown) { + if (!isRecord(spec) || !hasOnlyKeys(spec, ["title", "chart_type", "x_axis_name", "y_axis_name"])) return false; + if (spec.chart_type !== undefined && spec.chart_type !== "line" && spec.chart_type !== "bar") return false; + const parsedSpec = parseChartSpec(spec); + const parsedData = normalizeSafeChartData(data, 100); + return (parsedSpec.chartType === "line" || parsedSpec.chartType === "bar") && parsedData !== null && + parsedData.xData.length <= 100 && parsedData.series.length <= 8 && + parsedData.series.every((series) => series.data.length === parsedData.xData.length); } diff --git a/shared/ai-elements/suggestion.tsx b/shared/ai-elements/suggestion.tsx new file mode 100644 index 0000000..20db863 --- /dev/null +++ b/shared/ai-elements/suggestion.tsx @@ -0,0 +1,118 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { useCallback, useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/shared/ui/button"; + +export type SuggestionsProps = ComponentProps<"div">; + +const SCROLL_EASING = 0.28; +const SCROLL_END_THRESHOLD = 0.5; + +export const Suggestions = ({ className, children, ...props }: SuggestionsProps) => { + const containerRef = useRef(null); + + useEffect(() => { + const element = containerRef.current; + if (!element) { + return; + } + + let animationFrameId: number | null = null; + let targetScrollLeft = element.scrollLeft; + + const animateScroll = () => { + const distance = targetScrollLeft - element.scrollLeft; + if (Math.abs(distance) <= SCROLL_END_THRESHOLD) { + element.scrollLeft = targetScrollLeft; + animationFrameId = null; + return; + } + + element.scrollLeft += distance * SCROLL_EASING; + animationFrameId = window.requestAnimationFrame(animateScroll); + }; + + const handleWheel = (event: globalThis.WheelEvent) => { + const maxScrollLeft = element.scrollWidth - element.clientWidth; + const rawDelta = Math.abs(event.deltaY) >= Math.abs(event.deltaX) ? event.deltaY : event.deltaX; + const delta = + event.deltaMode === 1 + ? rawDelta * 16 + : event.deltaMode === 2 + ? rawDelta * element.clientWidth + : rawDelta; + const canScroll = delta > 0 ? element.scrollLeft < maxScrollLeft : element.scrollLeft > 0; + + if (maxScrollLeft <= 0 || delta === 0 || !canScroll) { + return; + } + + event.preventDefault(); + targetScrollLeft = Math.min(maxScrollLeft, Math.max(0, targetScrollLeft + delta)); + + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + element.scrollLeft = targetScrollLeft; + return; + } + + if (animationFrameId === null) { + animationFrameId = window.requestAnimationFrame(animateScroll); + } + }; + + element.addEventListener("wheel", handleWheel, { passive: false }); + return () => { + element.removeEventListener("wheel", handleWheel); + if (animationFrameId !== null) { + window.cancelAnimationFrame(animationFrameId); + } + }; + }, []); + + return ( +
+ {children} +
+ ); +}; + +export type SuggestionProps = Omit, "onClick"> & { + suggestion: string; + onClick?: (suggestion: string) => void; +}; + +export const Suggestion = ({ + suggestion, + onClick, + className, + variant = "outline", + size = "sm", + children, + ...props +}: SuggestionProps) => { + const handleClick = useCallback(() => { + onClick?.(suggestion); + }, [onClick, suggestion]); + + return ( + + ); +};