407 lines
13 KiB
TypeScript
407 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
|
|
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
|
import type { ReactNode } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
|
|
import type { AgentUiResult } from "../types";
|
|
import type { UIEnvelope } from "../ui-envelope";
|
|
import {
|
|
agentLayoutTransition,
|
|
agentPanelItemVariants,
|
|
agentPanelListVariants
|
|
} from "./agent-motion";
|
|
|
|
type AgentUiEnvelopeRendererProps = {
|
|
results: AgentUiResult[];
|
|
};
|
|
|
|
export function AgentUiEnvelopeRenderer({ results }: AgentUiEnvelopeRendererProps) {
|
|
if (results.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<MotionConfig reducedMotion="user">
|
|
<motion.div className="space-y-3" variants={agentPanelListVariants} animate="animate">
|
|
<AnimatePresence initial={false} mode="popLayout">
|
|
{results.map((result) => (
|
|
<motion.div
|
|
key={result.id}
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
variants={agentPanelItemVariants}
|
|
transition={agentLayoutTransition}
|
|
>
|
|
<AgentUiResultCard result={result} />
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
</MotionConfig>
|
|
);
|
|
}
|
|
|
|
function AgentUiResultCard({ result }: { result: AgentUiResult }) {
|
|
const { envelope } = result;
|
|
|
|
return (
|
|
<div className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-sm">
|
|
<div className="mb-3 flex items-center justify-between gap-2">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-blue-100 text-blue-700">
|
|
{getEnvelopeIcon(envelope)}
|
|
</span>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-semibold text-slate-950">{getEnvelopeTitle(envelope)}</p>
|
|
<p className="text-xs text-slate-500">{getSurfaceLabel(envelope.surface)}</p>
|
|
</div>
|
|
</div>
|
|
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">
|
|
agent-ui@1
|
|
</span>
|
|
</div>
|
|
|
|
{envelope.kind === "chart" ? <SafeChartEnvelope envelope={envelope} /> : null}
|
|
{envelope.kind === "registered_component" ? <RegisteredComponentEnvelope envelope={envelope} /> : null}
|
|
{envelope.kind === "map_action" ? null : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind: "chart" }> }) {
|
|
const spec = parseChartSpec(envelope.spec);
|
|
const data = normalizeSafeChartData(envelope.data);
|
|
|
|
if (!data || data.series.length === 0 || data.xData.length === 0) {
|
|
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
|
|
}
|
|
|
|
return (
|
|
<div className="agent-panel-nested rounded-xl p-3">
|
|
<div className="mb-3 flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-semibold text-slate-950" title={spec.title}>
|
|
{spec.title ?? "Agent 图表"}
|
|
</p>
|
|
<p className="mt-1 text-xs text-slate-500">
|
|
{spec.chartType === "bar" ? "柱状图" : "折线图"}
|
|
{spec.yAxisName ? ` · ${spec.yAxisName}` : ""}
|
|
</p>
|
|
</div>
|
|
<div className="flex shrink-0 flex-wrap justify-end gap-1.5">
|
|
{data.series.slice(0, 3).map((series, index) => (
|
|
<span key={series.name} className="flex items-center gap-1 text-xs text-slate-600">
|
|
<span className={cn("h-2 w-2 rounded-full", chartColorClass(index))} />
|
|
{series.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<SafeChartSvg spec={spec} data={data} />
|
|
{spec.xAxisName ? <p className="mt-2 text-center text-xs text-slate-500">{spec.xAxisName}</p> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SafeChartSvg({ spec, data }: { spec: SafeChartSpec; data: SafeChartData }) {
|
|
const width = 360;
|
|
const height = 190;
|
|
const padding = { top: 16, right: 18, bottom: 32, left: 38 };
|
|
const plotWidth = width - padding.left - padding.right;
|
|
const plotHeight = height - padding.top - padding.bottom;
|
|
const values = data.series.flatMap((series) => series.data);
|
|
const min = Math.min(0, ...values);
|
|
const max = Math.max(...values);
|
|
const range = max - min || 1;
|
|
const xFor = (index: number) =>
|
|
padding.left + (data.xData.length === 1 ? plotWidth / 2 : (index / (data.xData.length - 1)) * plotWidth);
|
|
const yFor = (value: number) => padding.top + plotHeight - ((value - min) / range) * plotHeight;
|
|
const ticks = createTicks(min, max);
|
|
|
|
return (
|
|
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label={spec.title ?? "Agent 图表"} className="h-48 w-full">
|
|
<rect x="0" y="0" width={width} height={height} rx="12" className="fill-white/70" />
|
|
{ticks.map((tick) => {
|
|
const y = yFor(tick);
|
|
return (
|
|
<g key={tick}>
|
|
<line x1={padding.left} x2={width - padding.right} y1={y} y2={y} className="stroke-slate-200" />
|
|
<text x={padding.left - 8} y={y + 4} textAnchor="end" className="fill-slate-400 text-xs">
|
|
{formatChartNumber(tick)}
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
<line x1={padding.left} x2={padding.left} y1={padding.top} y2={height - padding.bottom} className="stroke-slate-300" />
|
|
<line x1={padding.left} x2={width - padding.right} y1={height - padding.bottom} y2={height - padding.bottom} className="stroke-slate-300" />
|
|
{data.xData.map((label, index) => (
|
|
<text
|
|
key={`${label}-${index}`}
|
|
x={xFor(index)}
|
|
y={height - 12}
|
|
textAnchor="middle"
|
|
className="fill-slate-500 text-xs"
|
|
>
|
|
{shortLabel(label)}
|
|
</text>
|
|
))}
|
|
{data.series.slice(0, 4).map((series, seriesIndex) =>
|
|
(series.type ?? spec.chartType) === "bar" ? (
|
|
<BarSeries
|
|
key={series.name}
|
|
series={series}
|
|
seriesIndex={seriesIndex}
|
|
seriesCount={Math.min(data.series.length, 4)}
|
|
xFor={xFor}
|
|
yFor={yFor}
|
|
baselineY={yFor(0)}
|
|
/>
|
|
) : (
|
|
<LineSeries key={series.name} series={series} seriesIndex={seriesIndex} xFor={xFor} yFor={yFor} />
|
|
)
|
|
)}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function LineSeries({
|
|
series,
|
|
seriesIndex,
|
|
xFor,
|
|
yFor
|
|
}: {
|
|
series: SafeChartSeries;
|
|
seriesIndex: number;
|
|
xFor: (index: number) => number;
|
|
yFor: (value: number) => number;
|
|
}) {
|
|
const points = series.data.map((value, index) => `${xFor(index)},${yFor(value)}`).join(" ");
|
|
const stroke = chartStroke(seriesIndex);
|
|
|
|
return (
|
|
<g>
|
|
<polyline
|
|
points={points}
|
|
fill="none"
|
|
stroke={stroke}
|
|
strokeWidth="2.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className="agent-chart-line"
|
|
/>
|
|
{series.data.map((value, index) => (
|
|
<circle
|
|
key={`${series.name}-${index}`}
|
|
cx={xFor(index)}
|
|
cy={yFor(value)}
|
|
r="2.8"
|
|
fill={stroke}
|
|
className="agent-chart-point"
|
|
style={{ animationDelay: `${80 + index * 18}ms` }}
|
|
/>
|
|
))}
|
|
</g>
|
|
);
|
|
}
|
|
|
|
function BarSeries({
|
|
series,
|
|
seriesIndex,
|
|
seriesCount,
|
|
xFor,
|
|
yFor,
|
|
baselineY
|
|
}: {
|
|
series: SafeChartSeries;
|
|
seriesIndex: number;
|
|
seriesCount: number;
|
|
xFor: (index: number) => number;
|
|
yFor: (value: number) => number;
|
|
baselineY: number;
|
|
}) {
|
|
const fill = chartStroke(seriesIndex);
|
|
const barWidth = Math.max(5, 22 / seriesCount);
|
|
const offset = (seriesIndex - (seriesCount - 1) / 2) * (barWidth + 2);
|
|
|
|
return (
|
|
<g>
|
|
{series.data.map((value, index) => {
|
|
const y = yFor(value);
|
|
return (
|
|
<rect
|
|
key={`${series.name}-${index}`}
|
|
x={xFor(index) - barWidth / 2 + offset}
|
|
y={Math.min(y, baselineY)}
|
|
width={barWidth}
|
|
height={Math.max(1, Math.abs(baselineY - y))}
|
|
rx="2"
|
|
fill={fill}
|
|
className="agent-chart-bar"
|
|
style={{ animationDelay: `${seriesIndex * 28 + index * 16}ms` }}
|
|
/>
|
|
);
|
|
})}
|
|
</g>
|
|
);
|
|
}
|
|
|
|
function RegisteredComponentEnvelope({
|
|
envelope
|
|
}: {
|
|
envelope: Extract<UIEnvelope, { kind: "registered_component" }>;
|
|
}) {
|
|
if (envelope.component === "HistoryPanel") {
|
|
return (
|
|
<TrustedPanelShell icon={<History size={15} aria-hidden="true" />} title="历史数据面板">
|
|
<PropRows props={envelope.props} labels={{ feature_id: "资产 ID", featureId: "资产 ID", metric: "指标", range: "时间范围" }} />
|
|
</TrustedPanelShell>
|
|
);
|
|
}
|
|
|
|
if (envelope.component === "ScadaPanel") {
|
|
return (
|
|
<TrustedPanelShell icon={<MonitorCog size={15} aria-hidden="true" />} title="SCADA 面板">
|
|
<PropRows props={envelope.props} labels={{ device_id: "设备 ID", deviceId: "设备 ID", metric: "指标", range: "时间范围" }} />
|
|
</TrustedPanelShell>
|
|
);
|
|
}
|
|
|
|
return <FallbackText text={envelope.fallbackText ?? "当前注册组件未接入渲染器。"} />;
|
|
}
|
|
|
|
function TrustedPanelShell({
|
|
icon,
|
|
title,
|
|
children
|
|
}: {
|
|
icon: ReactNode;
|
|
title: string;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="agent-panel-nested rounded-xl p-3">
|
|
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-950">
|
|
<span className="grid h-7 w-7 place-items-center rounded-lg bg-emerald-100 text-emerald-700">{icon}</span>
|
|
{title}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PropRows({ props, labels }: { props: unknown; labels: Record<string, string> }) {
|
|
const rows = safePropRows(props, labels);
|
|
|
|
if (rows.length === 0) {
|
|
return <p className="text-sm text-slate-600">已打开可信面板,暂无可展示参数。</p>;
|
|
}
|
|
|
|
return (
|
|
<dl className="grid grid-cols-2 gap-2">
|
|
{rows.map((row) => (
|
|
<div key={row.label} className="surface-reading min-w-0 rounded-lg px-2.5 py-2">
|
|
<dt className="truncate text-xs text-slate-500">{row.label}</dt>
|
|
<dd className="mt-1 truncate text-sm font-semibold text-slate-900" title={row.value}>
|
|
{row.value}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
);
|
|
}
|
|
|
|
function FallbackText({ text }: { text: string }) {
|
|
return (
|
|
<div className="agent-panel-nested rounded-xl p-3 text-sm leading-6 text-slate-600">
|
|
{text}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function safePropRows(props: unknown, labels: Record<string, string>) {
|
|
if (!isRecord(props)) {
|
|
return [];
|
|
}
|
|
|
|
return Object.entries(labels).flatMap(([key, label]) => {
|
|
const value = props[key];
|
|
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
|
return [];
|
|
}
|
|
|
|
return [{ label, value: String(value) }];
|
|
});
|
|
}
|
|
|
|
function getEnvelopeIcon(envelope: UIEnvelope) {
|
|
if (envelope.kind === "chart") {
|
|
return envelope.spec && isRecord(envelope.spec) && envelope.spec.chart_type === "bar" ? (
|
|
<BarChart3 size={16} aria-hidden="true" />
|
|
) : (
|
|
<LineChart size={16} aria-hidden="true" />
|
|
);
|
|
}
|
|
|
|
if (envelope.kind === "registered_component") {
|
|
return <Database size={16} aria-hidden="true" />;
|
|
}
|
|
|
|
return <Activity size={16} aria-hidden="true" />;
|
|
}
|
|
|
|
function getEnvelopeTitle(envelope: UIEnvelope) {
|
|
if (envelope.kind === "chart") {
|
|
const spec = parseChartSpec(envelope.spec);
|
|
return spec.title ?? "Agent 图表";
|
|
}
|
|
|
|
if (envelope.kind === "registered_component") {
|
|
return envelope.component;
|
|
}
|
|
|
|
return envelope.action;
|
|
}
|
|
|
|
function getSurfaceLabel(surface: UIEnvelope["surface"]) {
|
|
if (surface === "chat_inline") return "聊天内联展示";
|
|
if (surface === "side_panel") return "侧栏展示";
|
|
if (surface === "canvas") return "画布展示";
|
|
return "地图叠加";
|
|
}
|
|
|
|
function createTicks(min: number, max: number) {
|
|
const range = max - min || 1;
|
|
return [max, min + range * 0.5, min];
|
|
}
|
|
|
|
function chartStroke(index: number) {
|
|
return ["#2563eb", "#0aa6a6", "#ff7a45", "#0477bf"][index % 4] ?? "#2563eb";
|
|
}
|
|
|
|
function chartColorClass(index: number) {
|
|
return ["bg-blue-600", "bg-teal-600", "bg-orange-500", "bg-sky-700"][index % 4] ?? "bg-blue-600";
|
|
}
|
|
|
|
function formatChartNumber(value: number) {
|
|
if (Math.abs(value) >= 100) {
|
|
return value.toFixed(0);
|
|
}
|
|
|
|
if (Math.abs(value) >= 10) {
|
|
return value.toFixed(1);
|
|
}
|
|
|
|
return value.toFixed(2);
|
|
}
|
|
|
|
function shortLabel(value: string) {
|
|
return value.length > 8 ? `${value.slice(0, 7)}...` : value;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|