234 lines
8.6 KiB
TypeScript
234 lines
8.6 KiB
TypeScript
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
|
|
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
|
import { memo, type ReactNode } from "react";
|
|
import { formatChartDisplayText, withChartDisplayTimes } from "@/shared/utils/date-time-text";
|
|
import { StatusIcon } from "@/shared/ui/status";
|
|
import { StandaloneInteractiveChart } from "../answer-ui/interactive-chart";
|
|
import { normalizeSafeChartData, parseChartSpec } from "../chart-data";
|
|
import type { AgentUiResult } from "../types";
|
|
import type { UIEnvelope } from "../ui-envelope";
|
|
import type { parseFrontendChart } from "../frontend-action/chart";
|
|
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 data-agent-ui-result={result.id} className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-xs">
|
|
<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-[var(--action-selection-soft)] text-[var(--action-blue-hover)]">
|
|
{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-[var(--ink-muted)]">{getSurfaceLabel(envelope.surface)}</p>
|
|
</div>
|
|
</div>
|
|
<span className="shrink-0 rounded-full bg-[var(--surface-control)] 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>
|
|
);
|
|
}
|
|
|
|
export const AgentChartView = memo(function AgentChartView({ chart, embedded = false, expanded = false }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>>; embedded?: boolean; expanded?: boolean }) {
|
|
return <SafeChartEnvelope embedded={embedded} expanded={expanded} envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type,
|
|
...(chart.x_axis_name ? { x_axis_name: chart.x_axis_name } : {}),
|
|
...(chart.y_axis_name ? { y_axis_name: chart.y_axis_name } : {}) }, data: { x_data: chart.x_data, series: chart.series } }} />;
|
|
});
|
|
|
|
function SafeChartEnvelope({ envelope, embedded = false, expanded = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean; expanded?: boolean }) {
|
|
const parsed = parseChartSpec(envelope.spec);
|
|
const raw = normalizeSafeChartData(envelope.data, 100);
|
|
const displayed = withChartDisplayTimes({ title: parsed.title ?? "Agent 图表", x_axis_name: parsed.xAxisName,
|
|
x_data: raw?.xData ?? [], series: raw?.series ?? [] });
|
|
const spec = { ...parsed, title: displayed.title, xAxisName: displayed.x_axis_name };
|
|
const data = raw ? { ...raw, xData: displayed.x_data, series: displayed.series } : null;
|
|
|
|
if (!data || data.series.length === 0 || data.xData.length === 0) {
|
|
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
|
|
}
|
|
|
|
return <StandaloneInteractiveChart
|
|
title={spec.title ?? "Agent 图表"}
|
|
chart_type={spec.chartType}
|
|
x_data={data.xData}
|
|
series={data.series.slice(0, 8)}
|
|
source="来源见关联依据。"
|
|
embedded={embedded}
|
|
expanded={expanded}
|
|
xAxisName={spec.xAxisName}
|
|
yAxisName={spec.yAxisName}
|
|
/>;
|
|
}
|
|
|
|
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: "时间范围", start_time: "开始时间", end_time: "结束时间" }} />
|
|
{envelope.data ? <SafeChartEnvelope envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: "监测历史", chart_type: "line" }, data: envelope.data }} /> : null}
|
|
</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: "时间范围", start_time: "开始时间", end_time: "结束时间" }} />
|
|
</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">
|
|
<StatusIcon tone="success" size="md">{icon}</StatusIcon>
|
|
{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-[var(--ink-muted)]">{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 formatChartDisplayText(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 isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|