Files
next-tjwater-frontend/src/features/agent/components/agent-ui-envelope-renderer.tsx
T
jiang 8a63926284
Generic Container CI/CD / test-build-publish (push) Successful in 1m25s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m26s
fix(charts): simplify time labels in investigation views
2026-11-07 21:03:43 +08:00

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;
}