fix(agent): validate UI envelopes
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
|||||||
PromptInputSubmit,
|
PromptInputSubmit,
|
||||||
PromptInputTextarea
|
PromptInputTextarea
|
||||||
} from "@/shared/ai-elements/prompt-input";
|
} from "@/shared/ai-elements/prompt-input";
|
||||||
|
import { Suggestion, Suggestions } from "@/shared/ai-elements/suggestion";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -93,6 +94,12 @@ type AgentCommandPanelProps = {
|
|||||||
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const AGENT_PROMPT_SUGGESTIONS = [
|
||||||
|
"当前监测数据质量如何,是否存在缺失、失真或可信度不足的问题?",
|
||||||
|
"当前监测数据中有哪些异常问题需要重点关注?",
|
||||||
|
"当前管网哪些区域可能存在雨污混接,判断依据是什么?"
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function AgentCommandPanel({
|
export function AgentCommandPanel({
|
||||||
collapsing = false,
|
collapsing = false,
|
||||||
personaState,
|
personaState,
|
||||||
@@ -305,7 +312,18 @@ export function AgentCommandPanel({
|
|||||||
</Conversation>
|
</Conversation>
|
||||||
</AgentContent>
|
</AgentContent>
|
||||||
|
|
||||||
<div className="agent-panel-band border-t border-white/70 p-3">
|
<div className="agent-panel-band space-y-2 border-t border-white/70 p-3">
|
||||||
|
<Suggestions aria-label="推荐问题" role="group">
|
||||||
|
{AGENT_PROMPT_SUGGESTIONS.map((suggestion) => (
|
||||||
|
<Suggestion
|
||||||
|
key={suggestion}
|
||||||
|
suggestion={suggestion}
|
||||||
|
disabled={streaming}
|
||||||
|
onClick={submitPrompt}
|
||||||
|
className="border-slate-200/80 bg-white/75 text-slate-600 shadow-none hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Suggestions>
|
||||||
<PromptInput
|
<PromptInput
|
||||||
className="agent-panel-control rounded-2xl shadow-sm"
|
className="agent-panel-control rounded-2xl shadow-sm"
|
||||||
onSubmit={(message) => {
|
onSubmit={(message) => {
|
||||||
@@ -626,4 +644,3 @@ function BackendMessageList({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,20 +11,26 @@ describe("toTrustedMapAction", () => {
|
|||||||
expect(toTrustedMapAction("run_javascript", { code: "alert(1)" })).toBeNull();
|
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(
|
expect(
|
||||||
toTrustedMapAction("apply_layer_style", {
|
toTrustedMapAction("apply_layer_style", {
|
||||||
layer_group_id: 123,
|
layer_group_id: 123,
|
||||||
layer_id: [],
|
layer_id: [],
|
||||||
visible: "true"
|
visible: "true"
|
||||||
})
|
})
|
||||||
).toEqual({
|
).toBeNull();
|
||||||
type: "apply_layer_style",
|
expect(toTrustedMapAction("apply_layer_style", {})).toBeNull();
|
||||||
layerGroupId: undefined,
|
});
|
||||||
layerId: undefined,
|
|
||||||
visible: undefined,
|
it("rejects out-of-range coordinates and zoom", () => {
|
||||||
fallbackText: undefined
|
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", () => {
|
it("accepts valid zoom_to_map coordinates", () => {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function toTrustedMapAction(action: string, params: unknown, fallbackText
|
|||||||
|
|
||||||
if (action === "locate_features" || action in LEGACY_LOCATE_ACTION_LAYERS) {
|
if (action === "locate_features" || action in LEGACY_LOCATE_ACTION_LAYERS) {
|
||||||
const featureIds = readLocateIds(params);
|
const featureIds = readLocateIds(params);
|
||||||
|
if (featureIds.length === 0 || featureIds.length > 100 || featureIds.some((id) => id.length > 128)) return null;
|
||||||
return {
|
return {
|
||||||
type: "locate_features",
|
type: "locate_features",
|
||||||
featureIds,
|
featureIds,
|
||||||
@@ -47,27 +48,35 @@ export function toTrustedMapAction(action: string, params: unknown, fallbackText
|
|||||||
return null;
|
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 {
|
return {
|
||||||
type: "zoom_to_map",
|
type: "zoom_to_map",
|
||||||
center,
|
center,
|
||||||
zoom: numberValue(params.zoom),
|
zoom,
|
||||||
fallbackText
|
fallbackText
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "apply_layer_style") {
|
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 {
|
return {
|
||||||
type: "apply_layer_style",
|
type: "apply_layer_style",
|
||||||
layerGroupId: stringValue(params.layer_group_id ?? params.layerGroupId),
|
layerGroupId,
|
||||||
layerId: stringValue(params.layer_id ?? params.layerId),
|
layerId,
|
||||||
visible: booleanValue(params.visible),
|
visible,
|
||||||
fallbackText
|
fallbackText
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "render_junctions") {
|
if (action === "render_junctions") {
|
||||||
const renderRef = stringValue(params.render_ref ?? params.renderRef);
|
const renderRef = stringValue(params.render_ref ?? params.renderRef);
|
||||||
if (!renderRef) {
|
if (!renderRef || !RESULT_REF_PATTERN.test(renderRef)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +143,9 @@ const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
|
|||||||
locate_tanks: "geo_tanks"
|
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 = [
|
const LOCATE_ID_PARAM_KEYS = [
|
||||||
"feature_ids",
|
"feature_ids",
|
||||||
"feature_id",
|
"feature_id",
|
||||||
|
|||||||
@@ -13,6 +13,19 @@ const registry: UIRegistry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("UIEnvelope validation", () => {
|
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", () => {
|
it("rejects registered components outside the frontend allowlist", () => {
|
||||||
const envelope = parseUiEnvelope({
|
const envelope = parseUiEnvelope({
|
||||||
kind: "registered_component",
|
kind: "registered_component",
|
||||||
@@ -22,8 +35,7 @@ describe("UIEnvelope validation", () => {
|
|||||||
props: {}
|
props: {}
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(envelope).not.toBeNull();
|
expect(envelope).toBeNull();
|
||||||
expect(envelope ? isUiEnvelopeAllowed(envelope, registry) : false).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects registered components on unsupported surfaces", () => {
|
it("rejects registered components on unsupported surfaces", () => {
|
||||||
@@ -32,7 +44,7 @@ describe("UIEnvelope validation", () => {
|
|||||||
schemaVersion: "agent-ui@1",
|
schemaVersion: "agent-ui@1",
|
||||||
component: "HistoryPanel",
|
component: "HistoryPanel",
|
||||||
surface: "chat_inline",
|
surface: "chat_inline",
|
||||||
props: {}
|
props: { feature_infos: [["J1", "junction"]], data_type: "realtime" }
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(envelope).not.toBeNull();
|
expect(envelope).not.toBeNull();
|
||||||
@@ -46,8 +58,8 @@ describe("UIEnvelope validation", () => {
|
|||||||
schemaVersion: "agent-ui@1",
|
schemaVersion: "agent-ui@1",
|
||||||
grammar: "vega-lite",
|
grammar: "vega-lite",
|
||||||
surface: "chat_inline",
|
surface: "chat_inline",
|
||||||
spec: {},
|
spec: { chart_type: "line" },
|
||||||
data: {}
|
data: { x_data: ["a"], series: [{ name: "s", data: [1] }] }
|
||||||
})
|
})
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
|
||||||
@@ -56,14 +68,33 @@ describe("UIEnvelope validation", () => {
|
|||||||
schemaVersion: "agent-ui@1",
|
schemaVersion: "agent-ui@1",
|
||||||
grammar: "echarts-safe-subset",
|
grammar: "echarts-safe-subset",
|
||||||
surface: "chat_inline",
|
surface: "chat_inline",
|
||||||
spec: {},
|
spec: { chart_type: "line" },
|
||||||
data: {}
|
data: { x_data: ["a"], series: [{ name: "s", data: [1] }] }
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(envelope).not.toBeNull();
|
expect(envelope).not.toBeNull();
|
||||||
expect(envelope ? isUiEnvelopeAllowed(envelope, registry) : false).toBe(true);
|
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", () => {
|
it("filters invalid registry surfaces before allowlist checks", () => {
|
||||||
const parsed = parseUiRegistry({
|
const parsed = parseUiRegistry({
|
||||||
schema_version: "agent-ui-registry@1",
|
schema_version: "agent-ui-registry@1",
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
|
import { normalizeSafeChartData, parseChartSpec } from "../chart-data";
|
||||||
|
import { toTrustedMapAction } from "./map-actions";
|
||||||
import type { UIEnvelope, UIEnvelopePayload, UIRegistry, UISurface } from "./types";
|
import type { UIEnvelope, UIEnvelopePayload, UIRegistry, UISurface } from "./types";
|
||||||
|
|
||||||
const surfaces = new Set<UISurface>(["chat_inline", "side_panel", "canvas", "map_overlay"]);
|
const surfaces = new Set<UISurface>(["chat_inline", "side_panel", "canvas", "map_overlay"]);
|
||||||
|
const MAX_ID_LENGTH = 128;
|
||||||
|
const LOCAL_COMPONENT_SURFACES = new Map<string, UISurface[]>([
|
||||||
|
["HistoryPanel", ["side_panel", "canvas"]],
|
||||||
|
["ScadaPanel", ["side_panel", "canvas"]]
|
||||||
|
]);
|
||||||
|
const LOCAL_ACTION_SURFACES = new Map<string, UISurface[]>([
|
||||||
|
["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 {
|
export function parseUiEnvelopePayload(value: unknown): UIEnvelopePayload | null {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
@@ -12,10 +25,16 @@ export function parseUiEnvelopePayload(value: unknown): UIEnvelopePayload | null
|
|||||||
return 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 {
|
return {
|
||||||
session_id: typeof value.session_id === "string" ? value.session_id : "",
|
session_id: sessionId,
|
||||||
envelope_id: typeof value.envelope_id === "string" ? value.envelope_id : "",
|
envelope_id: envelopeId,
|
||||||
created_at: typeof value.created_at === "number" ? value.created_at : Date.now(),
|
created_at: value.created_at,
|
||||||
envelope
|
envelope
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -26,7 +45,7 @@ export function parseUiEnvelope(value: unknown): UIEnvelope | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (value.kind === "registered_component") {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +61,7 @@ export function parseUiEnvelope(value: unknown): UIEnvelope | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (value.kind === "chart") {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +77,7 @@ export function parseUiEnvelope(value: unknown): UIEnvelope | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (value.kind === "map_action") {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,18 +96,20 @@ export function parseUiEnvelope(value: unknown): UIEnvelope | null {
|
|||||||
|
|
||||||
export function isUiEnvelopeAllowed(envelope: UIEnvelope, registry: UIRegistry | null) {
|
export function isUiEnvelopeAllowed(envelope: UIEnvelope, registry: UIRegistry | null) {
|
||||||
if (!registry) {
|
if (!registry) {
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (envelope.kind === "registered_component") {
|
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);
|
const manifest = registry.components.find((item) => item.id === envelope.component);
|
||||||
return Boolean(manifest?.supportedSurfaces.includes(envelope.surface));
|
return Boolean(manifest?.supportedSurfaces.includes(envelope.surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (envelope.kind === "chart") {
|
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);
|
const manifest = registry.actions.find((item) => item.id === envelope.action);
|
||||||
return Boolean(manifest?.supportedSurfaces.includes(envelope.surface));
|
return Boolean(manifest?.supportedSurfaces.includes(envelope.surface));
|
||||||
}
|
}
|
||||||
@@ -137,5 +158,45 @@ function optionalString(value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
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<string, unknown>, 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-nowrap items-center gap-2 overflow-x-auto overscroll-x-contain pb-0.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "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 (
|
||||||
|
<Button
|
||||||
|
className={cn("shrink-0 cursor-pointer rounded-full px-3.5", className)}
|
||||||
|
onClick={handleClick}
|
||||||
|
size={size}
|
||||||
|
type="button"
|
||||||
|
variant={variant}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children || suggestion}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user