fix: unify chat process and reply presentation

This commit is contained in:
2026-10-29 17:54:34 +08:00
parent 7621bf0f86
commit 9474788db6
46 changed files with 3160 additions and 500 deletions
@@ -0,0 +1,146 @@
import { CircleCheck, CircleHelp, LoaderCircle, RotateCw, X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { cn } from "@/shared/ui/cn";
import { Radio } from "@/shared/ui/radio";
import { Textarea } from "@/shared/ui/textarea";
import type { InteractionAnswer } from "../api/interaction-requests";
import type { InteractionRequestEntry, InteractionRequestsController } from "../hooks/use-interaction-requests";
import { AgentUserMessageText } from "./agent-user-message-text";
const statuses = { pending: "等待回答", answered: "答案已保存", skipped: "已跳过", cancelled: "已取消", invalidated: "已失效" };
const deliveries = { queued: "等待继续", dispatching: "正在继续处理", delivered: "已继续处理", blocked: "继续失败,可以重试", cancelled: "已停止继续" };
export function AgentInteractionRequests({ controller, representedMessageIds }: {
controller: InteractionRequestsController;
representedMessageIds?: ReadonlySet<string>;
}) {
return <section aria-label="Agent 提问" className="space-y-3">
{controller.checking ? <p role="status" className="text-xs text-muted-foreground">正在核对提问状态,核对后可回答。</p> : null}
{controller.error ? <div role="alert" className="text-sm">{controller.error}<Button type="button" variant="ghost" size="sm" onClick={() => void controller.refresh()} aria-label="重新核对提问"><RotateCw size={14} aria-hidden />重新核对</Button></div> : null}
{controller.entries.filter(({ record }) => !(record.delivery && ["answered", "skipped"].includes(record.status)
&& representedMessageIds?.has(record.delivery.native_message_id))).map((entry) =>
<AgentInteractionRequestCard key={entry.record.interaction_id} entry={entry} checking={controller.checking}
onChange={(draft) => controller.update(entry.record.interaction_id, draft)} onAction={(action) => void controller.act(entry.record.interaction_id, action)} />)}
</section>;
}
/** Render the stored human answer, independent of the model-facing transcript. */
export function AgentInteractionReply({ entry, checking, onResume }: {
entry: InteractionRequestEntry;
checking: boolean;
onResume: () => void;
}) {
const { record } = entry;
const retryable = record.delivery && ["queued", "dispatching", "blocked"].includes(record.delivery.status);
return <div role="group" aria-label="已提交的回答" className="min-w-0 space-y-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<CircleCheck size={14} className="shrink-0" aria-hidden />
<span>{record.status === "skipped" ? "已跳过" : "已回答"}</span>
</div>
{record.status === "skipped" ? <p className="text-sm leading-6">已跳过本组可选问题。</p> : <dl className="space-y-3">
{record.questions.map((question) => {
const answer = record.answers?.find((item) => item.question_id === question.question_id);
return <div key={question.question_id} className="min-w-0">
<dt className="mb-1 break-words text-xs leading-5 text-muted-foreground">{question.title}</dt>
<dd className="space-y-1.5 whitespace-pre-wrap break-words text-sm leading-6 text-foreground">
{answer?.selected_options.map((option) => <div key={option.id} className="font-medium"><AgentUserMessageText content={option.label} /></div>)}
{answer?.custom_text ? <div>{answer.selected_options.length ? <span className="block text-xs text-muted-foreground">补充说明</span> : null}<AgentUserMessageText content={answer.custom_text} /></div> : null}
{!answer?.selected_options.length && !answer?.custom_text ? <p className="text-muted-foreground">未填写</p> : null}
</dd>
</div>;
})}
</dl>}
{entry.error ? <p role="alert" className="text-xs leading-5 text-destructive">{entry.error}</p> : null}
{entry.conflict ? <details className="text-xs"><summary className="cursor-pointer">查看本窗口未提交的输入,供复制</summary><pre className="mt-2 whitespace-pre-wrap break-words">{JSON.stringify(entry.conflict, null, 2)}</pre></details> : null}
{record.delivery ? <div className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-2">
<span role="status" className="text-xs leading-5 text-muted-foreground">{checking ? "正在核对提问" : deliveries[record.delivery.status]}</span>
{retryable ? <Button type="button" variant="ghost" size="sm" disabled={checking || entry.busy} onClick={onResume}>
<RotateCw size={13} aria-hidden />重试继续
</Button> : null}
</div> : null}
</div>;
}
export function AgentInteractionRequestCard({ entry, checking, onChange, onAction }: {
entry: InteractionRequestEntry; checking: boolean; onChange: (draft: InteractionAnswer[]) => void;
onAction: (action: "reply" | "skip" | "cancel" | "resume") => void;
}) {
const { record } = entry;
if (["answered", "skipped"].includes(record.status)) return <div className="flex min-w-0 justify-end">
<div className="agent-panel-user-message min-w-0 max-w-[92%] rounded-2xl px-3 py-3">
<AgentInteractionReply entry={entry} checking={checking} onResume={() => onAction("resume")} />
</div>
</div>;
const pending = record.status === "pending";
const disabled = checking || entry.busy || !pending;
const values = entry.draft;
const answerFor = (id: string) => values.find((answer) => answer.question_id === id) ?? { question_id: id, selected_option_ids: [], custom_text: "" };
const change = (answer: InteractionAnswer) => onChange(record.questions.map((question) => question.question_id === answer.question_id ? answer : answerFor(question.question_id)));
const valid = record.questions.every((question) => !question.required || answerFor(question.question_id).selected_option_ids.length > 0 || (question.allow_custom && answerFor(question.question_id).custom_text.trim().length > 0));
const draftStatus = checking ? "正在核对提问" : entry.saving ? "正在保存草稿" : entry.dirty ? "草稿尚未保存"
: entry.draft.some((answer) => answer.selected_option_ids.length || answer.custom_text.trim()) ? "草稿已保存" : "提交后继续处理";
return <article aria-label="需要补充的信息" aria-busy={entry.busy || checking}
className="min-w-0 overflow-hidden rounded-xl border border-border bg-card text-card-foreground">
<header className="flex min-h-10 items-center justify-between gap-3 px-4 pt-3 sm:px-5">
<div role="status" className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<CircleHelp size={15} className="shrink-0" aria-hidden />
<span>{checking ? "核对中" : statuses[record.status]}</span>
</div>
{pending ? <Button type="button" size="icon-sm" variant="ghost" aria-label="取消提问" title="取消提问"
disabled={disabled} onClick={() => onAction("cancel")}><X size={15} aria-hidden /></Button> : null}
</header>
<div className="space-y-5 px-4 pb-4 pt-2 sm:px-5 sm:pb-5">
{record.questions.map((question) => {
const answer = answerFor(question.question_id);
const selected = new Set(answer.selected_option_ids);
return <fieldset key={question.question_id} disabled={disabled} className="min-w-0">
<legend className="mb-2 w-full text-sm font-semibold leading-6 text-foreground">
{question.title}
{pending ? <span className="ml-2 text-[11px] font-normal text-muted-foreground">
{question.required ? "必填" : "可选"}{question.options.length ? question.mode === "multiple" ? " · 可多选" : " · 单选" : ""}
</span> : null}
</legend>
{pending ? <>
<p className="mb-3 whitespace-pre-wrap break-words text-sm leading-6 text-muted-foreground">{question.body}</p>
<div className="space-y-2">
{question.options.map((option) => <label key={option.id}
className={cn("flex min-h-11 min-w-0 cursor-pointer items-start gap-2.5 rounded-lg border px-3 py-2.5 text-sm leading-6 has-[:disabled]:cursor-default",
selected.has(option.id) ? "border-primary/50 bg-primary/5" : "border-border bg-background has-[:enabled]:hover:bg-muted/40")}>
<span className="mt-0.5 inline-flex shrink-0">
{question.mode === "single" ? <Radio name={`${record.interaction_id}-${question.question_id}`}
checked={selected.has(option.id)} onChange={() => change({ ...answer, selected_option_ids: [option.id] })} />
: <Checkbox checked={selected.has(option.id)} onChange={(event) => change({ ...answer,
selected_option_ids: event.target.checked ? [...answer.selected_option_ids, option.id] : answer.selected_option_ids.filter((id) => id !== option.id) })} />}
</span>
<span className="min-w-0 flex-1 break-words">{option.label}</span>
</label>)}
</div>
{question.allow_custom ? <label className="mt-3 block text-xs font-medium leading-5 text-foreground">
{question.options.length ? "自定义回答 / 补充说明" : "你的回答"}
<Textarea aria-label={`${question.title}:补充说明`} placeholder={question.options.length ? "可以直接填写回答,也可以补充已选内容" : "请输入你的回答"}
maxLength={2000} value={answer.custom_text} onChange={(event) => change({ ...answer, custom_text: event.target.value })}
className="mt-1.5 min-h-20 resize-y bg-background text-sm font-normal leading-6" />
</label> : null}
</> : <p className="text-sm leading-6 text-muted-foreground">未提交回答。</p>}
</fieldset>;
})}
{entry.error ? <p role="alert" className="text-sm leading-5 text-destructive">{entry.error}</p> : null}
{entry.conflict ? <details className="text-xs"><summary className="cursor-pointer">查看本窗口未提交的输入,供复制</summary><pre className="mt-2 whitespace-pre-wrap break-words">{JSON.stringify(entry.conflict, null, 2)}</pre></details> : null}
</div>
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3 sm:px-5">
<p role="status" className="text-xs leading-5 text-muted-foreground">{pending ? draftStatus : "本次提问已结束"}</p>
<div className="ml-auto flex flex-wrap gap-2">
{pending ? <>
{record.questions.every((question) => !question.required) ? <Button type="button" size="sm" variant="outline" disabled={disabled} onClick={() => onAction("skip")}>跳过</Button> : null}
<Button type="button" size="sm" disabled={disabled || !valid} onClick={() => onAction("reply")}>
{entry.busy ? <LoaderCircle size={14} className="animate-spin motion-reduce:animate-none" aria-hidden /> : null}提交回答
</Button>
</> : null}
</div>
</footer>
</article>;
}