feat: polish intelligent answer interactions

This commit is contained in:
2026-10-29 18:39:50 +08:00
parent 9474788db6
commit 1ee21d1c7f
26 changed files with 1762 additions and 327 deletions
+130 -236
View File
@@ -1,48 +1,55 @@
import { AskAboutContent } from "./answer-ui-interactions";
import { JSONUIProvider, Renderer, defineRegistry } from "@json-render/react";
import { useContext, useId, useState } from "react";
import { LocateButton, FollowUpForm } from "./answer-action-components";
import { AskAboutContent, SelectForAnalysis } from "./answer-ui-interactions";
import { Renderer, defineRegistry } from "@json-render/react";
import { useContext, useRef, useState, type ReactNode } from "react";
import {
ArrowUpRight,
ChevronDown,
Info,
TriangleAlert,
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 { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } 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 { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions, type AnswerUiMapTarget } from "./answer-ui-interaction-context";
import { AnswerResult } from "./answer-result";
import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions, type AnswerUiMapTarget, type AnswerUiSelection } 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 { AnswerSelectionTray } from "./answer-selection-tray";
import { AnswerViewStateProvider, useAnswerBlockState, useAnswerNodeState, withAnswerNodeIdentity, withAnswerNodeIds } from "./answer-view-state-provider";
import { type AnswerEvidenceState, type AnswerViewState, type AnswerViewStateChange } from "./answer-view-state";
import { ANSWER_SELECTION_LIMIT, getSelectionText } from "./answer-selection";
const { registry } = defineRegistry(answerUiCatalog, {
const { registry: baseRegistry } = defineRegistry(answerUiCatalog, {
actions: {},
components: {
Stack: ({ props, children }) => (
<div
className={cn(
"answer-ui-stack",
props.columns === "two" && "answer-ui-stack-comparison",
(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">
<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}
@@ -53,22 +60,21 @@ const { registry } = defineRegistry(answerUiCatalog, {
<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>
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">
<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">
@@ -80,21 +86,35 @@ const { registry } = defineRegistry(answerUiCatalog, {
{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}` : ""}`} />
<div className="answer-ui-metric-actions flex items-center">
<SelectForAnalysis compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
<AskAboutContent compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
</div>
</CardContent>
</Card>
),
Table: ({ props }) => (
<InteractiveDataTable key={JSON.stringify([props.columns, props.rows])} title={props.title} columns={props.columns} rows={props.rows} />
<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 key={JSON.stringify(props)} {...props} />,
Chart: ({ props }) => <InteractiveChart {...props} />,
Evidence: ({ props }) => <EvidenceCard {...props} />,
MapLocation: ({ props }) => <LocateButton {...props} />,
FollowUpForm: ({ props }) => <FollowUpForm {...props} />
}
});
export function AnswerUiView({
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(props.block.spec) }} />
</AnswerViewStateProvider>;
}
function AnswerUiBody({
block,
disabled = false,
onLocate,
@@ -102,25 +122,80 @@ export function AnswerUiView({
onAsk,
submitMode = "send"
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
const [filter, setFilter] = useState("all");
const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState();
const [selections, setSelections] = useState<AnswerUiSelection[]>([]);
const [selectionError, setSelectionError] = useState<string | null>(null);
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 ask = onAsk ? (selection: AnswerUiSelection) => onAsk({ ...selection, text: `${selection.text}\n引用内容块:${block.block_id}` }) : undefined;
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 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,
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>
onAsk: ask, selections,
onToggleSelection: onAsk ? (selection) => {
setSelectionError(null);
const alreadySelected = selections.some((item) => item.title === selection.title && item.text === selection.text);
if (alreadySelected) {
setSelections((current) => current.filter((item) => item.title !== selection.title || item.text !== selection.text));
return;
}
const next = [...selections, selection];
if (next.length > 8 || getSelectionText(next).length > ANSWER_SELECTION_LIMIT) {
setSelectionError("一次最多选择 8 项、8000 字内容。请减少所选数据行,或移除已有内容后重试。");
return;
}
setSelections(next);
} : undefined }}>
<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="分析内容">
{hasTaskLayout ? <div className="answer-task-heading"><span>分析内容</span><span>直接探索已加载数据</span></div> : null}
{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 enhanced={hasTaskLayout} title="核查依据" description="区分记录、推断与用户资料" icon={<Database size={15} aria-hidden />}>
{evidenceFilters}{renderNodes(taskLayout.evidence)}
</TaskSupport> : null}
{taskLayout.followUp.length ? <TaskSupport enhanced={hasTaskLayout} title="补充资料" description="由你填写,主动提交" icon={<FileCheck2 size={15} aria-hidden />}>
{renderNodes(taskLayout.followUp)}
</TaskSupport> : null}
</div> : null}
</div> : <>{evidenceFilters}<Renderer spec={block.spec} registry={registry} /></>}
</>
</div>
{selectionError ? <p role="status" className="mt-3 text-xs leading-5 text-destructive">{selectionError}</p> : null}
{ask ? <AnswerSelectionTray selections={selections} disabled={disabled}
onRemove={(selection) => { setSelectionError(null); setSelections((current) => current.filter((item) => item !== selection)); }}
onClear={() => { setSelections([]); setSelectionError(null); }} onAsk={ask} /> : null}
</AnswerMotionScope>
</InteractionContext.Provider>
);
}
function TaskSupport({ title, description, icon, children, enhanced }: { title: string; description: string; icon: ReactNode; children: ReactNode; enhanced: boolean }) {
const [open, setOpen] = useState(true);
return <AnswerDisclosure open={open} onOpenChange={setOpen} className="answer-task-support-section"
contentClassName="answer-task-support-content space-y-2.5 pt-2"
trigger={<Button type="button" variant="ghost" className={cn("h-auto min-h-11 w-full justify-start gap-2 px-0 text-left", !enhanced && "hidden")} aria-label={`${open ? "收起" : "展开"}${title}`}>
{icon}<span className="min-w-0 flex-1 text-xs font-medium">{title}<span className="mt-0.5 block text-[11px] font-normal text-muted-foreground">{description}</span></span>
<AnswerDisclosureChevron open={open} />
</Button>}>{children}</AnswerDisclosure>;
}
const evidenceKinds = {
observed: { label: "查询记录", icon: Database },
human_input: { label: "用户提供", icon: MessageSquare },
@@ -136,14 +211,15 @@ function EvidenceCard(props: {
observed_at: string | null;
evidence_id: string | null;
}) {
const [open, setOpen] = useState(false);
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 (
<Collapsible open={open} onOpenChange={setOpen} className="answer-ui-evidence">
<CollapsibleTrigger asChild>
<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"
@@ -161,14 +237,8 @@ function EvidenceCard(props: {
<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">
<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">
@@ -186,186 +256,10 @@ function EvidenceCard(props: {
) : 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 className="mt-2 flex flex-wrap items-center gap-1">
<SelectForAnalysis 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}` : ""}`} />
<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}` : ""}`} />
</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>
</AnswerDisclosure>
);
}