372 lines
14 KiB
TypeScript
372 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|