Files
next-tjwater-frontend/src/features/agent/answer-ui/answer-ui-view.tsx
T
jiang 2d8506fea0
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s
feat: add project-aware agent workspace
2026-10-22 18:52:18 +08:00

372 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { AskAboutContent } from "./answer-ui-interactions";
import { JSONUIProvider, Renderer, defineRegistry } from "@json-render/react";
import { useContext, useId, useState } from "react";
import {
ArrowUpRight,
ChevronDown,
ClipboardList,
Database,
FileCheck2,
FlaskConical,
Lightbulb,
MapPin,
MessageSquare
} from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Badge } from "@/shared/ui/badge";
import { Card, CardContent } from "@/shared/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { cn } from "@/shared/ui/cn";
import { InteractiveChart } from "./interactive-chart";
import { InteractiveDataTable } from "./interactive-data-table";
import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions, type AnswerUiMapTarget } from "./answer-ui-interaction-context";
export type { AnswerUiMapTarget, AnswerUiSelection } from "./answer-ui-interaction-context";
import { answerUiCatalog } from "./catalog";
import type { AnswerUiBlock } from "./contract";
const { registry } = defineRegistry(answerUiCatalog, {
actions: {},
components: {
Stack: ({ props, children }) => (
<div
className={cn(
"answer-ui-stack",
props.columns === "two" && "answer-ui-stack-comparison",
props.gap === "compact" && "answer-ui-stack-compact"
)}
>
{children}
</div>
),
Section: ({ props, children }) => (
<section className="answer-ui-section">
<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 }) => (
<p
className={cn(
"whitespace-pre-wrap text-sm leading-6",
props.tone === "warning"
? "text-[var(--status-warning-foreground)]"
: props.tone === "info"
? "text-[var(--action-blue-hover)]"
: "text-[var(--ink)]"
)}
>
{props.text}
</p>
),
Metric: ({ props }) => (
<Card className="answer-ui-metric">
<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}
<AskAboutContent compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
</CardContent>
</Card>
),
Table: ({ props }) => (
<InteractiveDataTable key={JSON.stringify([props.columns, props.rows])} title={props.title} columns={props.columns} rows={props.rows} />
),
Chart: ({ props }) => <InteractiveChart key={JSON.stringify(props)} {...props} />,
Evidence: ({ props }) => <EvidenceCard {...props} />,
MapLocation: ({ props }) => <LocateButton {...props} />,
FollowUpForm: ({ props }) => <FollowUpForm {...props} />
}
});
export function AnswerUiView({
block,
disabled = false,
onLocate,
onSubmit,
onAsk,
submitMode = "send"
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
const [filter, setFilter] = useState("all");
const kinds = [...new Set(Object.values(block.spec.elements).filter((node) => node.type === "Evidence").map((node) => String(node.props.kind)))];
return (
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter,
onAsk: onAsk ? (selection) => onAsk({ ...selection, text: `${selection.text}\n引用内容块:${block.block_id}` }) : undefined }}>
<div className="answer-ui-root" data-answer-ui={block.block_id}>
{kinds.length > 1 ? <div className="mb-3 flex flex-wrap gap-1.5" aria-label="筛选证据类别">
{["all", ...kinds].map((kind) => <Button key={kind} variant="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}
<JSONUIProvider registry={registry}>
<Renderer spec={block.spec} registry={registry} />
</JSONUIProvider>
</div>
</InteractionContext.Provider>
);
}
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, setOpen] = useState(false);
const meta = evidenceKinds[props.kind];
const Icon = meta.icon;
const { evidenceFilter } = useContext(InteractionContext);
if (evidenceFilter && evidenceFilter !== "all" && evidenceFilter !== props.kind) return null;
return (
<Collapsible open={open} onOpenChange={setOpen} className="answer-ui-evidence">
<CollapsibleTrigger asChild>
<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>
<ChevronDown
size={14}
className={cn("shrink-0", open && "rotate-180")}
aria-hidden="true"
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="px-3 pb-3 text-xs leading-6">
<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}
<AskAboutContent title={props.title} text={`${props.summary}\n来源:${props.source}\n类别:${meta.label}${props.evidence_id ? `\n资料编号:${props.evidence_id}` : ""}${props.observed_at ? `\n记录时间:${props.observed_at}` : ""}`} />
</CollapsibleContent>
</Collapsible>
);
}
function LocateButton({
label,
source,
...target
}: AnswerUiMapTarget & { label: string; source: string }) {
const { disabled, onLocate } = useContext(InteractionContext);
const [running, setRunning] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
return (
<div className="answer-ui-location">
<MapPin size={17} className="mt-1 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<Button
size="sm"
variant="outline"
disabled={disabled || running || !onLocate}
onClick={async () => {
setRunning(true);
setFeedback(null);
try {
const result = await onLocate?.(target);
if (result === false) throw new Error("关联要素暂不可定位,请检查地图提示。");
const output = result as
{ located_ids?: string[]; missing_ids?: string[] } | undefined;
setFeedback(
output?.missing_ids?.length
? `已定位 ${output.located_ids?.length ?? 0} 个,另有 ${output.missing_ids.length} 个未找到`
: "已定位到地图"
);
} catch (error) {
setFeedback(error instanceof Error ? error.message : "地图定位失败");
} finally {
setRunning(false);
}
}}
>
{running ? "正在定位…" : label}
<ArrowUpRight size={14} aria-hidden="true" />
</Button>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">依据:{source}</p>
{feedback ? (
<p role="status" className="mt-1 text-xs leading-5">
{feedback}
</p>
) : null}
</div>
</div>
);
}
type FormField = {
name: string;
label: string;
kind: "text" | "datetime" | "number";
required: boolean;
};
function FollowUpForm({
title,
description,
fields
}: {
title: string;
description: string;
fields: FormField[];
}) {
const { disabled, onSubmit, submitMode } = useContext(InteractionContext);
const prefix = useId();
const [values, setValues] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
return (
<form
className="answer-ui-form space-y-3"
aria-label={title}
onSubmit={async (event) => {
event.preventDefault();
if (fields.some((field) => field.required && !values[field.name]?.trim())) {
setFeedback("请填写必填资料。");
return;
}
if (!onSubmit || disabled || submitting) return;
const entries = fields
.filter((field) => values[field.name]?.trim())
.map(
(field) =>
`${field.label}:${values[field.name].trim()}${field.kind === "datetime" ? "(北京时间)" : ""}`
);
if (!entries.length) {
setFeedback("请至少填写一项资料。");
return;
}
if (entries.join("\n").length > 9000) {
setFeedback("补充资料过长,请分次发送。");
return;
}
setSubmitting(true);
setFeedback(null);
try {
await onSubmit(
`我补充以下资料,请按用户提供的信息处理,说明保存或调查的实际结果:\n${entries.join("\n")}`
);
setFeedback(submitMode === "draft" ? "资料已带入对话,请确认后发送。" : "资料已发送,请查看 Agent 回复。");
} catch (error) {
setFeedback(error instanceof Error ? error.message : "资料发送失败,请重试。");
} finally {
setSubmitting(false);
}
}}
>
<div className="flex items-start gap-2.5">
<span className="answer-ui-evidence-icon">
<FileCheck2 size={16} aria-hidden="true" />
</span>
<div className="min-w-0">
<h4 className="text-sm font-semibold">{title}</h4>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{description}</p>
</div>
</div>
{fields.map((field) =>
field.kind === "datetime" ? (
<DateTimeField
key={field.name}
label={`${field.label}${field.required ? "(必填)" : ""}`}
value={values[field.name] ?? ""}
disabled={disabled || submitting || !onSubmit}
onChange={(value) => setValues((current) => ({ ...current, [field.name]: value }))}
/>
) : (
<div key={field.name} className="space-y-1.5">
<label htmlFor={`${prefix}-${field.name}`} className="block text-xs font-medium">
{field.label}
{field.required ? "(必填)" : ""}
</label>
{field.kind === "text" ? (
<Textarea
id={`${prefix}-${field.name}`}
value={values[field.name] ?? ""}
maxLength={500}
disabled={disabled || submitting || !onSubmit}
onChange={(event) => {
const value = event.currentTarget.value;
setValues((current) => ({ ...current, [field.name]: value }));
}}
/>
) : (
<Input
className="h-10"
id={`${prefix}-${field.name}`}
type="number"
value={values[field.name] ?? ""}
maxLength={500}
disabled={disabled || submitting || !onSubmit}
onChange={(event) => {
const value = event.currentTarget.value;
setValues((current) => ({ ...current, [field.name]: value }));
}}
/>
)}
</div>
)
)}
<Button
type="submit"
size="sm"
variant="outline"
disabled={disabled || submitting || !onSubmit}
>
{submitting ? submitMode === "draft" ? "正在处理…" : "正在发送…" : submitMode === "draft" ? "带入对话" : "发送补充资料"}
</Button>
{feedback ? (
<p role="status" className="text-xs leading-5">
{feedback}
</p>
) : null}
</form>
);
}