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
@@ -0,0 +1,234 @@
import { useContext, useId, useRef, useState } from "react";
import { ArrowUpRight, CheckCircle2, FileCheck2, Info, LoaderCircle, MapPin, TriangleAlert } from "lucide-react";
import { useAnswerMotion } from "./answer-motion-context";
import { motion } from "motion/react";
import { agentEase } from "../components/agent-motion";
import { AnswerDisclosure, AnswerDisclosureChevron } from "./answer-motion";
import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { AnswerInteractionContext as InteractionContext, type AnswerUiMapTarget } from "./answer-ui-interaction-context";
import { SelectForAnalysis } from "./answer-ui-interactions";
import { useAnswerNodeState } from "./answer-view-state-provider";
import { answerStatePointer, type AnswerFormState } from "./answer-view-state";
type ActionState = "idle" | "pending" | "success" | "partial" | "unconfirmed" | "error";
function ActionFeedback({ state, children }: { state: ActionState; children: string }) {
const shouldAnimate = useAnswerMotion();
const animate = shouldAnimate();
const Icon = state === "pending" ? LoaderCircle : state === "success" ? CheckCircle2 : state === "error" || state === "partial" ? TriangleAlert : Info;
return <motion.p key={state} initial={animate ? { opacity: 0, y: 3 } : false} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.18, ease: agentEase }} role={state === "idle" ? undefined : "status"} className="mt-1 flex items-start gap-1.5 text-xs leading-5 text-muted-foreground"><Icon size={14} className={`mt-0.5 shrink-0 ${state === "pending" && animate ? "motion-safe:animate-spin" : ""}`} aria-hidden />{children}</motion.p>;
}
export function LocateButton({
label,
source,
...target
}: AnswerUiMapTarget & { label: string; source: string }) {
const { disabled, onLocate } = useContext(InteractionContext);
const runningRef = useRef(false);
const [showIds, setShowIds] = useState(false);
const [state, setState] = useState<ActionState>("idle");
const running = state === "pending";
const [feedback, setFeedback] = useState<string | null>(null);
return (
<Card className="answer-ui-location" role="group" aria-label={label} data-state={state} aria-busy={running}>
<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 () => {
if (!onLocate || disabled || runningRef.current) return;
runningRef.current = true;
setState("pending");
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;
if (!output || !Array.isArray(output.located_ids)) {
setState("unconfirmed");
setFeedback("定位请求已处理,未返回定位结果,请查看地图确认。");
} else {
const located = output.located_ids.length;
const missing = output.missing_ids?.length ?? 0;
setState(!located ? "error" : missing ? "partial" : "success");
setFeedback(!located ? "未找到可定位的关联要素,请检查要素编号后重试。" : missing ? `已定位 ${located} 个,另有 ${missing} 个未找到` : `已定位 ${located} 个关联要素到地图`);
}
} catch (error) {
setState("error");
setFeedback(error instanceof Error ? error.message : "地图定位失败,请重试。");
} finally {
runningRef.current = false;
}
}}
>
{running ? "正在定位…" : state === "error" ? `重试${label}` : label}
<ArrowUpRight size={14} aria-hidden="true" />
</Button>
<p className="mt-2 break-words text-xs leading-5 text-muted-foreground">{target.feature_ids.length} 个关联要素 · 依据:{source}</p>
<AnswerDisclosure open={showIds} onOpenChange={setShowIds} className="mt-1 text-[11px] leading-5 text-muted-foreground"
trigger={<Button type="button" variant="ghost" size="sm" className="h-9 gap-1 px-0 text-[11px] font-normal">查看关联要素<AnswerDisclosureChevron open={showIds} /></Button>}>
<p className="mt-1 break-words font-mono">{target.feature_ids.join("、")}</p>
</AnswerDisclosure>
<SelectForAnalysis title={label} text={`关联图层:${target.layer}\n要素编号:${target.feature_ids.join("、")}\n依据:${source}`} />
{feedback || running || !onLocate ? <ActionFeedback state={state}>{running ? "正在查找关联要素…" : feedback ?? "当前视图不支持地图定位,可查看关联要素编号。"}</ActionFeedback> : null}
</div>
</Card>
);
}
type FormField = {
name: string;
label: string;
kind: "text" | "datetime" | "number";
required: boolean;
};
export function FollowUpForm({
title,
description,
fields
}: {
title: string;
description: string;
fields: FormField[];
}) {
const { disabled, onSubmit, submitMode } = useContext(InteractionContext);
const prefix = useId();
const [{ values }, patch] = useAnswerNodeState<AnswerFormState>("FollowUpForm", { title, description, fields });
const submittingRef = useRef(false);
const [state, setState] = useState<ActionState>("idle");
const submitting = state === "pending";
const [invalidFields, setInvalidFields] = useState<string[]>([]);
const setValue = (name: string, value: string) => {
patch({ values: { ...values, [name]: value } }, [`values/${answerStatePointer(name)}`]);
setState("idle");
setFeedback(null);
setInvalidFields((current) => current.filter((field) => field !== name));
};
const [feedback, setFeedback] = useState<string | null>(null);
return (
<Card className="answer-ui-form" role="group" aria-label={title} data-state={state} aria-busy={submitting}>
<CardHeader className="gap-2 border-b border-border p-3.5">
<CardTitle className="flex items-center gap-2 text-sm leading-6 tracking-normal"><FileCheck2 size={14} className="shrink-0 text-muted-foreground" aria-hidden />{title}</CardTitle>
<CardDescription className="text-xs leading-5">{description}</CardDescription>
</CardHeader>
<CardContent className="p-3.5">
<form
className="space-y-3"
aria-label={title}
aria-busy={submitting}
noValidate
onSubmit={async (event) => {
event.preventDefault();
if (!onSubmit || disabled || submittingRef.current) return;
const missing = fields.filter((field) => field.required && !values[field.name]?.trim()).map((field) => field.name);
setInvalidFields(missing);
if (missing.length) {
setState("error");
setFeedback("请填写必填资料。");
return;
}
const entries = fields
.filter((field) => values[field.name]?.trim())
.map(
(field) =>
`${field.label}:${values[field.name].trim()}${field.kind === "datetime" ? "(北京时间)" : ""}`
);
if (!entries.length) {
setState("error");
setFeedback("请至少填写一项资料。");
return;
}
if (entries.join("\n").length > 9000) {
setState("error");
setFeedback("补充资料过长,请分次发送。");
return;
}
submittingRef.current = true;
setState("pending");
setFeedback(null);
try {
await onSubmit(
`我补充以下资料,请按用户提供的信息处理,说明保存或调查的实际结果:\n${entries.join("\n")}`
);
setState("success");
setFeedback(submitMode === "draft" ? "资料已带入对话,请确认后发送。" : "资料已发送,请查看 Agent 回复。");
} catch (error) {
setState("error");
setFeedback(error instanceof Error ? error.message : "资料发送失败,请重试。");
} finally {
submittingRef.current = false;
}
}}
>
{fields.map((field) => (
<div key={field.name} role={field.kind === "datetime" ? "group" : undefined} aria-label={field.kind === "datetime" ? `${field.label}${field.required ? "(必填)" : ""}` : undefined} aria-invalid={invalidFields.includes(field.name) || undefined} aria-describedby={invalidFields.includes(field.name) ? `${prefix}-${field.name}-error` : undefined}>
{field.kind === "datetime" ? (
<DateTimeField
key={field.name}
label={`${field.label}${field.required ? "(必填)" : ""}`}
value={values[field.name] ?? ""}
disabled={disabled || submitting || !onSubmit}
onChange={(value) => setValue(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}`}
aria-required={field.required}
aria-invalid={invalidFields.includes(field.name) || undefined}
aria-describedby={invalidFields.includes(field.name) ? `${prefix}-${field.name}-error` : undefined}
value={values[field.name] ?? ""}
maxLength={500}
disabled={disabled || submitting || !onSubmit}
onChange={(event) => {
const value = event.currentTarget.value;
setValue(field.name, value);
}}
/>
) : (
<Input
className="h-10"
id={`${prefix}-${field.name}`}
aria-required={field.required}
aria-invalid={invalidFields.includes(field.name) || undefined}
aria-describedby={invalidFields.includes(field.name) ? `${prefix}-${field.name}-error` : undefined}
type="number"
value={values[field.name] ?? ""}
maxLength={500}
disabled={disabled || submitting || !onSubmit}
onChange={(event) => {
const value = event.currentTarget.value;
setValue(field.name, value);
}}
/>
)}
</div>
)}
{invalidFields.includes(field.name) ? <p id={`${prefix}-${field.name}-error`} className="mt-1 text-xs leading-5 text-destructive">请填写{field.label}。</p> : null}
</div>
))}
<Button
type="submit"
size="sm"
variant="default"
disabled={disabled || submitting || !onSubmit}
>
{submitting ? submitMode === "draft" ? "正在处理…" : "正在发送…" : submitMode === "draft" ? "带入对话" : "发送补充资料"}
</Button>
{feedback || submitting || !onSubmit ? <ActionFeedback state={state}>{submitting ? submitMode === "draft" ? "正在带入对话…" : "正在发送补充资料…" : feedback ?? "当前视图无法提交资料。"}</ActionFeedback> : null}
</form>
</CardContent>
</Card>
);
}