feat: migrate drainage frontend to Vite

This commit is contained in:
2026-07-22 12:47:36 +08:00
parent e0cfa3d6eb
commit 72850761ce
213 changed files with 5726 additions and 5283 deletions
+8
View File
@@ -0,0 +1,8 @@
export type { UIEnvelope, UIEnvelopePayload, UIRegistry, UISurface } from "./types";
export { toTrustedMapAction, type TrustedMapAction } from "./map-actions";
export {
isUiEnvelopeAllowed,
parseUiEnvelope,
parseUiEnvelopePayload,
parseUiRegistry
} from "./validator";
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { toTrustedMapAction } from "./map-actions";
describe("toTrustedMapAction", () => {
it("rejects zoom_to_map actions without a valid center", () => {
expect(toTrustedMapAction("zoom_to_map", { zoom: 14 })).toBeNull();
expect(toTrustedMapAction("zoom_to_map", { center: ["east", 39.1] })).toBeNull();
});
it("rejects unsupported map actions", () => {
expect(toTrustedMapAction("run_javascript", { code: "alert(1)" })).toBeNull();
});
it("rejects malformed or empty apply_layer_style params", () => {
expect(
toTrustedMapAction("apply_layer_style", {
layer_group_id: 123,
layer_id: [],
visible: "true"
})
).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", () => {
expect(toTrustedMapAction("zoom_to_map", { lng: 117.2, lat: 39.1, zoom: 13 })).toEqual({
type: "zoom_to_map",
center: [117.2, 39.1],
zoom: 13,
fallbackText: undefined
});
});
it("accepts legacy zoom coordinate aliases and numeric strings", () => {
expect(toTrustedMapAction("zoom_to_map", { coordinate: ["117.2", "39.1"], zoom: "13" })).toEqual({
type: "zoom_to_map",
center: [117.2, 39.1],
zoom: 13,
fallbackText: undefined
});
expect(toTrustedMapAction("zoom_to_map", { x: "117.2", y: "39.1" })).toMatchObject({
type: "zoom_to_map",
center: [117.2, 39.1]
});
});
it("converts EPSG:3857 zoom coordinates", () => {
const action = toTrustedMapAction("zoom_to_map", { x: 13046644.321, y: 4736005.854, source_crs: "EPSG:3857" });
expect(action?.type).toBe("zoom_to_map");
expect(action?.type === "zoom_to_map" ? action.center[0] : undefined).toBeCloseTo(117.2, 3);
expect(action?.type === "zoom_to_map" ? action.center[1] : undefined).toBeCloseTo(39.1, 3);
});
it("normalizes legacy locate tool actions", () => {
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
type: "locate_features",
featureIds: ["P1", "P2"],
layer: "geo_pipes_mat",
fallbackText: undefined
});
});
it("normalizes numeric locate ids", () => {
expect(toTrustedMapAction("locate_junctions", { junction_id: 42 })).toMatchObject({
type: "locate_features",
featureIds: ["42"],
layer: "geo_junctions_mat"
});
});
});
@@ -0,0 +1,218 @@
export type TrustedMapAction =
| {
type: "locate_features";
featureIds: string[];
layer?: string;
fallbackText?: string;
}
| {
type: "zoom_to_map";
center: [number, number];
zoom?: number;
durationMs?: number;
fallbackText?: string;
}
| {
type: "apply_layer_style";
layerGroupId?: string;
layerId?: string;
visible?: boolean;
fallbackText?: string;
}
| {
type: "render_junctions";
renderRef: string;
fallbackText?: string;
};
export function toTrustedMapAction(action: string, params: unknown, fallbackText?: string): TrustedMapAction | null {
if (!isRecord(params)) {
return null;
}
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,
layer:
stringValue(params.layer ?? params.target_layer ?? params.targetLayer) ??
LEGACY_LOCATE_ACTION_LAYERS[action],
fallbackText
};
}
if (action === "zoom_to_map") {
const center = parseCenter(params);
if (!center) {
return null;
}
const zoom = numberValue(params.zoom);
const durationMs = numberValue(params.duration_ms ?? params.durationMs);
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,
...(durationMs === undefined ? {} : { durationMs }),
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,
layerId,
visible,
fallbackText
};
}
if (action === "render_junctions") {
const renderRef = stringValue(params.render_ref ?? params.renderRef);
if (!renderRef || !RESULT_REF_PATTERN.test(renderRef)) {
return null;
}
return {
type: "render_junctions",
renderRef,
fallbackText
};
}
return null;
}
function parseCenter(params: Record<string, unknown>): [number, number] | null {
const sourceCrs = params.source_crs ?? params.sourceCrs;
if (Array.isArray(params.center) && params.center.length >= 2) {
const lng = numberValue(params.center[0]);
const lat = numberValue(params.center[1]);
return lng === undefined || lat === undefined ? null : normalizeCenter(lng, lat, sourceCrs);
}
const rawCoordinate = params.coordinate ?? params.coordinates;
if (Array.isArray(rawCoordinate) && rawCoordinate.length >= 2) {
const lng = numberValue(rawCoordinate[0]);
const lat = numberValue(rawCoordinate[1]);
return lng === undefined || lat === undefined ? null : normalizeCenter(lng, lat, sourceCrs);
}
const lng = numberValue(params.lng ?? params.lon ?? params.longitude ?? params.x);
const lat = numberValue(params.lat ?? params.latitude ?? params.y);
return lng === undefined || lat === undefined ? null : normalizeCenter(lng, lat, sourceCrs);
}
function normalizeCenter(x: number, y: number, sourceCrs: unknown): [number, number] {
if (sourceCrs === "EPSG:3857") {
return webMercatorToLngLat(x, y);
}
return [x, y];
}
function webMercatorToLngLat(x: number, y: number): [number, number] {
const lng = (x / WEB_MERCATOR_RADIUS) * (180 / Math.PI);
const lat = (2 * Math.atan(Math.exp(y / WEB_MERCATOR_RADIUS)) - Math.PI / 2) * (180 / Math.PI);
return [lng, lat];
}
function stringValue(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function numberValue(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function booleanValue(value: unknown) {
return typeof value === "boolean" ? value : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
locate_junctions: "geo_junctions_mat",
locate_pipes: "geo_pipes_mat",
locate_valves: "geo_valves",
locate_reservoirs: "geo_reservoirs",
locate_pumps: "geo_pumps",
locate_tanks: "geo_tanks"
};
const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/;
const ALLOWED_LAYER_IDS = new Set(["junctions", "pipes"]);
const WEB_MERCATOR_RADIUS = 6378137;
const LOCATE_ID_PARAM_KEYS = [
"feature_ids",
"feature_id",
"featureIds",
"featureId",
"ids",
"id",
"node_ids",
"node_id",
"junction_ids",
"junction_id",
"pipe_ids",
"pipe_id",
"valve_ids",
"valve_id",
"reservoir_ids",
"reservoir_id",
"pump_ids",
"pump_id",
"tank_ids",
"tank_id"
] as const;
function readLocateIds(params: Record<string, unknown>) {
for (const key of LOCATE_ID_PARAM_KEYS) {
const value = params[key];
const ids = normalizeIds(value);
if (ids.length > 0) {
return ids;
}
}
return [];
}
function normalizeIds(value: unknown) {
if (Array.isArray(value)) {
return value.map((item) => String(item).trim()).filter(Boolean);
}
if (typeof value === "string" || typeof value === "number") {
return String(value)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
return [];
}
+43
View File
@@ -0,0 +1,43 @@
export type UISurface = "chat_inline" | "side_panel" | "canvas" | "map_overlay";
export type UIEnvelope =
| {
kind: "registered_component";
schemaVersion: "agent-ui@1";
component: string;
surface: UISurface;
props: unknown;
data?: unknown;
fallbackText?: string;
}
| {
kind: "chart";
schemaVersion: "agent-ui@1";
grammar: "echarts-safe-subset";
surface: "chat_inline" | "side_panel" | "canvas";
spec: unknown;
data: unknown;
fallbackText?: string;
}
| {
kind: "map_action";
schemaVersion: "agent-ui@1";
action: string;
surface: "map_overlay";
params: unknown;
fallbackText?: string;
};
export type UIEnvelopePayload = {
session_id: string;
envelope_id: string;
created_at: number;
envelope: UIEnvelope;
};
export type UIRegistry = {
schema_version: "agent-ui-registry@1";
chart_grammars: string[];
components: Array<{ id: string; supportedSurfaces: UISurface[] }>;
actions: Array<{ id: string; supportedSurfaces: UISurface[] }>;
};
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { isUiEnvelopeAllowed, parseUiEnvelope, parseUiRegistry } from "./validator";
import type { UIRegistry } from "./types";
const registry: UIRegistry = {
schema_version: "agent-ui-registry@1",
chart_grammars: ["echarts-safe-subset"],
components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel", "canvas"] }],
actions: [{ id: "zoom_to_map", supportedSurfaces: ["map_overlay"] }]
};
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",
schemaVersion: "agent-ui@1",
component: "UnsafePanel",
surface: "side_panel",
props: {}
});
expect(envelope).toBeNull();
});
it("rejects registered components on unsupported surfaces", () => {
const envelope = parseUiEnvelope({
kind: "registered_component",
schemaVersion: "agent-ui@1",
component: "HistoryPanel",
surface: "chat_inline",
props: { feature_infos: [["J1", "junction"]], data_type: "realtime" }
});
expect(envelope).not.toBeNull();
expect(envelope ? isUiEnvelopeAllowed(envelope, registry) : false).toBe(false);
});
it("accepts only the echarts-safe-subset chart grammar", () => {
expect(
parseUiEnvelope({
kind: "chart",
schemaVersion: "agent-ui@1",
grammar: "vega-lite",
surface: "chat_inline",
spec: { chart_type: "line" },
data: { x_data: ["a"], series: [{ name: "s", data: [1] }] }
})
).toBeNull();
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, 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",
chart_grammars: ["echarts-safe-subset"],
components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel", "unsafe_surface"] }],
actions: []
});
expect(parsed?.components[0]?.supportedSurfaces).toEqual(["side_panel"]);
});
});
+196
View File
@@ -0,0 +1,196 @@
import { normalizeSafeChartData, parseChartSpec } from "../chart-data";
import { toTrustedMapAction } from "./map-actions";
import type { UIEnvelope, UIEnvelopePayload, UIRegistry, UISurface } from "./types";
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"]]
]);
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 {
if (!isRecord(value)) {
return null;
}
const envelope = parseUiEnvelope(value.envelope);
if (!envelope) {
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: sessionId,
envelope_id: envelopeId,
created_at: value.created_at,
envelope
};
}
export function parseUiEnvelope(value: unknown): UIEnvelope | null {
if (!isRecord(value) || value.schemaVersion !== "agent-ui@1") {
return null;
}
if (value.kind === "registered_component") {
if (typeof value.component !== "string" || !isSurface(value.surface) || !isValidComponentProps(value.component, value.props)) {
return null;
}
return {
kind: "registered_component",
schemaVersion: "agent-ui@1",
component: value.component,
surface: value.surface,
props: value.props,
data: value.data,
fallbackText: optionalString(value.fallbackText)
};
}
if (value.kind === "chart") {
if (value.grammar !== "echarts-safe-subset" || !isChartSurface(value.surface) || !isValidChart(value.spec, value.data)) {
return null;
}
return {
kind: "chart",
schemaVersion: "agent-ui@1",
grammar: "echarts-safe-subset",
surface: value.surface,
spec: value.spec,
data: value.data,
fallbackText: optionalString(value.fallbackText)
};
}
if (value.kind === "map_action") {
if (typeof value.action !== "string" || value.surface !== "map_overlay" || !toTrustedMapAction(value.action, value.params)) {
return null;
}
return {
kind: "map_action",
schemaVersion: "agent-ui@1",
action: value.action,
surface: "map_overlay",
params: value.params,
fallbackText: optionalString(value.fallbackText)
};
}
return null;
}
export function isUiEnvelopeAllowed(envelope: UIEnvelope, registry: UIRegistry | null) {
if (!registry) {
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 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));
}
export function parseUiRegistry(value: unknown): UIRegistry | null {
if (!isRecord(value) || value.schema_version !== "agent-ui-registry@1") {
return null;
}
return {
schema_version: "agent-ui-registry@1",
chart_grammars: stringArray(value.chart_grammars),
components: manifestArray(value.components),
actions: manifestArray(value.actions)
};
}
function manifestArray(value: unknown) {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((item) => {
if (!isRecord(item) || typeof item.id !== "string") {
return [];
}
return [{ id: item.id, supportedSurfaces: stringArray(item.supportedSurfaces).filter(isSurface) }];
});
}
function stringArray(value: unknown) {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}
function isChartSurface(value: unknown): value is "chat_inline" | "side_panel" | "canvas" {
return value === "chat_inline" || value === "side_panel" || value === "canvas";
}
function isSurface(value: unknown): value is UISurface {
return typeof value === "string" && surfaces.has(value as UISurface);
}
function optionalString(value: unknown) {
return typeof value === "string" ? value : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
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");
}
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);
}