Files
next-tjwater-frontend/src/features/agent/answer-ui/answer-ui-view.tsx
T

234 lines
12 KiB
TypeScript

import { LocateButton, FollowUpForm } from "./answer-action-components";
import { Renderer, defineRegistry } from "@json-render/react";
import { useContext, useRef, useState, type ReactNode } from "react";
import {
Info,
TriangleAlert,
ClipboardList,
Database,
FileCheck2,
FlaskConical,
Lightbulb,
MessageSquare
} from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Badge } from "@/shared/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert";
import { Card, CardContent } from "@/shared/ui/card";
import { AnswerDisclosure, AnswerDisclosureChevron, AnswerMotionScope } from "./answer-motion";
import { cn } from "@/shared/ui/cn";
import { InteractiveChart } from "./interactive-chart";
import { InteractiveDataTable } from "./interactive-data-table";
import { AnswerResult } from "./answer-result";
import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions } from "./answer-ui-interaction-context";
export type { AnswerUiMapTarget, AnswerUiSelection } from "./answer-ui-interaction-context";
import { answerUiCatalog } from "./catalog";
import type { AnswerUiBlock } from "./contract";
import { AnswerContentNav } from "./answer-content-nav";
import { getAnswerTaskLayout } from "./answer-task-layout";
import { AnswerViewStateProvider, useAnswerBlockState, useAnswerNodeState, withAnswerNodeIdentity, withAnswerNodeIds } from "./answer-view-state-provider";
import { type AnswerEvidenceState, type AnswerViewState, type AnswerViewStateChange } from "./answer-view-state";
import { withAnswerDisplayTimes } from "./answer-display-time";
const { registry: baseRegistry } = defineRegistry(answerUiCatalog, {
actions: {},
components: {
Stack: ({ props, children }) => (
<div
className={cn(
"answer-ui-stack",
(props.columns === "two" || props.intent === "comparison") && "answer-ui-stack-comparison",
props.gap === "compact" && "answer-ui-stack-compact"
)}
data-answer-intent={props.intent}
data-answer-density={props.density}
>
{children}
</div>
),
Section: ({ props, children }) => (
<section className="answer-ui-section" data-answer-role={props.role}>
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<ClipboardList size={15} aria-hidden="true" />
{props.title}
</h3>
{props.description ? (
<p className="mt-1 text-xs leading-5 text-muted-foreground">{props.description}</p>
) : null}
<div className="mt-3 space-y-3">{children}</div>
</section>
),
Text: ({ props }) => props.tone === "neutral" ? (
<div className="space-y-1">
{props.title ? <h4 className="text-sm font-medium leading-6 text-foreground">{props.title}</h4> : null}
<p className="whitespace-pre-wrap text-sm leading-6 text-foreground">{props.text}</p>
</div>
) : (
<Alert role="note" className="border-border bg-card px-3.5 py-3 text-card-foreground has-[>svg]:gap-x-2.5">
{props.tone === "warning" ? <TriangleAlert className="text-[var(--status-warning-foreground)]" aria-hidden /> : <Info className="text-muted-foreground" aria-hidden />}
<AlertTitle className="text-[13px]">{props.title ?? (props.tone === "warning" ? "注意事项" : "补充说明")}</AlertTitle>
<AlertDescription className="whitespace-pre-wrap text-muted-foreground">{props.text}</AlertDescription>
</Alert>
),
Result: ({ props }) => <AnswerResult {...props} />,
Metric: ({ props }) => (
<Card className="answer-ui-metric" role="group" aria-label={props.label}>
<CardContent className="answer-ui-metric-content p-3">
<p className="answer-ui-metric-label text-xs leading-5 text-muted-foreground">{props.label}</p>
<p className="answer-ui-metric-value break-words text-2xl font-semibold tabular-nums text-foreground">
{props.value}
{props.unit ? (
<span className="ml-1.5 text-xs font-normal text-muted-foreground">{props.unit}</span>
) : null}
</p>
{props.source ? (
<p className="answer-ui-metric-source text-[11px] leading-5 text-muted-foreground">来源:{props.source}</p>
) : null}
</CardContent>
</Card>
),
Table: ({ props }) => (
<section data-answer-table aria-label={props.title} className="min-w-0">
<InteractiveDataTable title={props.title} columns={props.columns} rows={props.rows} />
</section>
),
Chart: ({ props }) => <InteractiveChart {...props} />,
Evidence: ({ props }) => <EvidenceCard {...props} />,
MapLocation: ({ props }) => <LocateButton {...props} />,
FollowUpForm: ({ props }) => <FollowUpForm {...props} />
}
});
const registry = withAnswerNodeIdentity(baseRegistry);
type AnswerUiViewProps = { block: AnswerUiBlock; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange } & Partial<AnswerUiInteractions>;
export function AnswerUiView({ viewState, onViewStateChange, ...props }: AnswerUiViewProps) {
return <AnswerViewStateProvider key={props.block.block_id} spec={props.block.spec} registry={registry} viewState={viewState} onViewStateChange={onViewStateChange}>
<AnswerUiBody {...props} block={{ ...props.block, spec: withAnswerNodeIds(withAnswerDisplayTimes(props.block.spec)) }} />
</AnswerViewStateProvider>;
}
function AnswerUiBody({
block,
disabled = false,
onLocate,
onSubmit,
chartWindowKey,
submitMode = "send"
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState();
const contentRef = useRef<HTMLDivElement>(null);
const taskLayout = getAnswerTaskLayout(block.spec);
const root = block.spec.elements[block.spec.root];
const density = root.type === "Stack" ? root.props.density : undefined;
const intent = root.type === "Stack" ? root.props.intent : undefined;
const hasTaskLayout = intent !== "response" && Boolean(taskLayout?.analysis.length && (taskLayout.evidence.length || taskLayout.followUp.length));
const kinds = [...new Set(Object.values(block.spec.elements).filter((node) => node.type === "Evidence").map((node) => String(node.props.kind)))];
const evidenceFilters = kinds.length > 1 ? <div className="mb-3 flex flex-wrap gap-1.5" aria-label="筛选证据类别">
{["all", ...kinds].map((kind) => <Button key={kind} variant={filter === kind ? "secondary" : "outline"} size="sm" className="h-8 text-xs" aria-pressed={filter === kind} onClick={() => setFilter(kind)}>
{kind === "all" ? "全部依据" : evidenceKinds[kind as keyof typeof evidenceKinds].label}
</Button>)}
</div> : null;
const isGroup = (ids: string[]) => hasTaskLayout && ids.length > 1 && !ids.every((id) => block.spec.elements[id].type === "Section");
const renderNodes = (ids: string[]) => ids.map((id) => <Renderer key={id} spec={{ ...block.spec, root: id }} registry={registry} />);
return (
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter, chartWindowKey }}>
<AnswerMotionScope className="answer-ui-root" data-answer-ui={block.block_id} data-answer-phase={block.phase} data-answer-intent={intent} data-answer-density={density}>
<AnswerContentNav block={block} contentRef={contentRef} />
<div ref={contentRef}>
<>
{taskLayout ? <div className={hasTaskLayout ? "answer-task-layout" : "answer-task-layout-simple"} data-answer-task-layout={hasTaskLayout || undefined}>
{taskLayout.overview.length ? <div className="answer-task-overview grid gap-3" aria-label="回答概览">{renderNodes(taskLayout.overview)}</div> : null}
{taskLayout.analysis.length ? <section className="answer-task-analysis grid min-w-0 gap-3" aria-label="分析内容">
{renderNodes(taskLayout.analysis)}
</section> : null}
{taskLayout.evidence.length || taskLayout.followUp.length ? <div className="answer-task-support grid min-w-0 gap-4">
{taskLayout.evidence.length ? <TaskSupport grouped={isGroup(taskLayout.evidence)} title="核查依据" icon={<Database size={15} aria-hidden />}>
{evidenceFilters}{renderNodes(taskLayout.evidence)}
</TaskSupport> : null}
{taskLayout.followUp.length ? <TaskSupport grouped={isGroup(taskLayout.followUp)} title="补充资料" icon={<FileCheck2 size={15} aria-hidden />}>
{renderNodes(taskLayout.followUp)}
</TaskSupport> : null}
</div> : null}
</div> : <>{evidenceFilters}<Renderer spec={block.spec} registry={registry} /></>}
</>
</div>
</AnswerMotionScope>
</InteractionContext.Provider>
);
}
function TaskSupport({ title, icon, children, grouped }: { title: string; icon: ReactNode; children: ReactNode; grouped: boolean }) {
const [open, setOpen] = useState(true);
return <AnswerDisclosure open={grouped ? open : true} onOpenChange={setOpen} className="answer-task-support-section" data-grouped={grouped || undefined}
contentClassName={cn("answer-task-support-content space-y-2.5", grouped && "pt-2")}
trigger={<Button type="button" variant="ghost" hidden={!grouped} className={cn("h-auto min-h-11 w-full justify-start gap-2 px-0 text-left", !grouped && "hidden")} aria-label={`${open ? "收起" : "展开"}${title}`}>
{icon}<span className="min-w-0 flex-1 text-xs font-medium">{title}</span>
<AnswerDisclosureChevron open={open} />
</Button>}>{children}</AnswerDisclosure>;
}
const evidenceKinds = {
observed: { label: "查询记录", icon: Database },
human_input: { label: "用户提供", icon: MessageSquare },
inference: { label: "分析推断", icon: Lightbulb },
demo: { label: "演练数据", icon: FlaskConical }
};
function EvidenceCard(props: {
title: string;
summary: string;
source: string;
kind: keyof typeof evidenceKinds;
observed_at: string | null;
evidence_id: string | null;
}) {
const [{ open }, patch] = useAnswerNodeState<AnswerEvidenceState>("Evidence", props);
const setOpen = (open: boolean) => patch({ open });
const meta = evidenceKinds[props.kind];
const Icon = meta.icon;
const { evidenceFilter } = useContext(InteractionContext);
if (evidenceFilter && evidenceFilter !== "all" && evidenceFilter !== props.kind) return null;
return (
<AnswerDisclosure open={open} onOpenChange={setOpen} className="answer-ui-evidence" role="group" aria-label={props.title}
contentClassName="px-3 pb-3 text-xs leading-6" trigger={
<Button
variant="ghost"
type="button"
className="h-auto min-h-12 w-full justify-start gap-2.5 whitespace-normal rounded-xl px-3 py-3 text-left shadow-none"
>
<span className="answer-ui-evidence-icon">
<Icon size={16} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium">{props.title}</span>
<span className="mt-0.5 block break-words text-[11px] font-normal text-muted-foreground">
{props.source}
</span>
</span>
<Badge variant="outline" className="shrink-0 px-1.5 text-[10px] font-normal">
{meta.label}
</Badge>
<AnswerDisclosureChevron open={open} />
</Button>}>
<p className="whitespace-pre-wrap border-t border-border pt-3">{props.summary}</p>
{props.observed_at || props.evidence_id ? (
<dl className="mt-3 grid gap-1 text-[11px] text-muted-foreground">
{props.observed_at ? (
<div>
<dt className="inline">记录时间:</dt>
<dd className="inline tabular-nums">{props.observed_at}</dd>
</div>
) : null}
{props.evidence_id ? (
<div>
<dt className="inline">资料编号:</dt>
<dd className="inline break-all font-mono">{props.evidence_id}</dd>
</div>
) : null}
</dl>
) : null}
</AnswerDisclosure>
);
}