Files
next-tjwater-drainage-frontend/features/agent/components/agent-message-details.tsx
T

704 lines
25 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.
"use client";
import {
CheckCircle2,
ChevronDown,
HelpCircle,
ListChecks,
ListTree,
LockKeyhole,
ShieldCheck,
XCircle,
type LucideIcon
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/shared/ui/button";
import {
agentEase,
agentLayoutTransition,
agentPanelItemVariants,
agentPanelListVariants,
agentPanelSectionVariants
} from "./agent-motion";
import type {
AgentChatMessage,
AgentChatProgress,
AgentPermissionReply,
AgentPermissionRequest,
AgentQuestionInfo,
AgentQuestionRequest,
AgentTodoUpdate
} from "../types";
type ProgressStatus = AgentChatProgress["status"];
type TodoStatus = AgentTodoUpdate["todos"][number]["status"];
type PermissionStatus = AgentPermissionRequest["status"];
type QuestionStatus = AgentQuestionRequest["status"];
const STREAMING_PROGRESS_LIMIT = 3;
const progressStatusMeta: Record<ProgressStatus, { label: string; className: string; dotClassName: string }> = {
running: {
label: "进行中",
className: "bg-blue-100 text-blue-700",
dotClassName: "border-blue-500 bg-blue-100"
},
completed: {
label: "完成",
className: "bg-emerald-100 text-emerald-700",
dotClassName: "border-emerald-500 bg-emerald-100"
},
error: {
label: "失败",
className: "bg-red-100 text-red-700",
dotClassName: "border-red-500 bg-red-100"
}
};
const todoStatusMeta: Record<TodoStatus, { label: string; className: string; dotClassName: string }> = {
completed: {
label: "完成",
className: "bg-emerald-100 text-emerald-700",
dotClassName: "border-emerald-500 bg-emerald-500"
},
in_progress: {
label: "进行中",
className: "bg-blue-100 text-blue-700",
dotClassName: "border-blue-500 bg-blue-100"
},
pending: {
label: "待办",
className: "bg-slate-100 text-slate-600",
dotClassName: "border-slate-300 bg-white"
},
cancelled: {
label: "取消",
className: "bg-slate-200 text-slate-500",
dotClassName: "border-slate-300 bg-slate-200"
}
};
const permissionStatusMeta: Record<PermissionStatus, { label: string; className: string }> = {
approved_always: { label: "已始终允许", className: "bg-emerald-100 text-emerald-700" },
approved_once: { label: "已允许一次", className: "bg-emerald-100 text-emerald-700" },
rejected: { label: "已拒绝", className: "bg-red-100 text-red-700" },
aborted: { label: "已中止", className: "bg-slate-200 text-slate-500" },
error: { label: "失败", className: "bg-red-100 text-red-700" },
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
pending: { label: "待确认", className: "bg-orange-100 text-orange-700" }
};
const questionStatusMeta: Record<QuestionStatus, { label: string; className: string }> = {
answered: { label: "已回答", className: "bg-emerald-100 text-emerald-700" },
rejected: { label: "已跳过", className: "bg-slate-200 text-slate-500" },
error: { label: "失败", className: "bg-red-100 text-red-700" },
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
pending: { label: "待回答", className: "bg-blue-100 text-blue-700" }
};
export function AgentMessageDetails({
message,
onReplyPermission,
onReplyQuestion,
onRejectQuestion
}: {
message: AgentChatMessage;
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) {
const hasDetails = Boolean(
message.todos?.todos.length ||
message.permissions?.length ||
message.questions?.length
);
if (!hasDetails) {
return null;
}
return (
<motion.div className="space-y-2" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{message.todos ? (
<motion.div key="todos" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<TodoCard todoUpdate={message.todos} />
</motion.div>
) : null}
{message.permissions?.length ? (
<motion.div key="permissions" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<PermissionList permissions={message.permissions} onReplyPermission={onReplyPermission} />
</motion.div>
) : null}
{message.questions?.length ? (
<motion.div key="questions" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<QuestionList
questions={message.questions}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
</motion.div>
) : null}
</AnimatePresence>
</motion.div>
);
}
export function AgentMessageProgress({
progress,
running
}: {
progress?: AgentChatProgress[];
running: boolean;
}) {
if (!progress?.length) {
return null;
}
return <ProgressList key={running ? "running" : "settled"} progress={progress} running={running} />;
}
function AgentNestedBlock({
icon: Icon,
iconClassName,
title,
children,
floating = false
}: {
icon: LucideIcon;
iconClassName: string;
title: string;
children: ReactNode;
floating?: boolean;
}) {
return (
<div className={cn(floating ? "px-0 py-1" : "agent-panel-nested rounded-xl p-2.5")}>
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-600">
<Icon size={14} className={iconClassName} aria-hidden="true" />
{title}
</div>
{children}
</div>
);
}
function AnimatedDetailItem({ children }: { children: ReactNode }) {
return (
<motion.div
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
{children}
</motion.div>
);
}
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
const [expanded, setExpanded] = useState(false);
const visibleProgress = running
? progress.slice(-STREAMING_PROGRESS_LIMIT)
: expanded
? progress
: [];
const listMode = running ? "streaming" : expanded ? "expanded" : "collapsed";
return (
<div className="agent-panel-nested rounded-xl p-2.5">
<button
type="button"
className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600 disabled:cursor-default"
aria-expanded={running || expanded}
disabled={running}
onClick={() => setExpanded((current) => !current)}
>
<ListTree size={16} className="shrink-0 text-blue-700" aria-hidden="true" />
<span>执行进度</span>
<span className="flex-1" />
<span className="shrink-0 font-normal text-slate-400">
{running
? `最近 ${Math.min(progress.length, STREAMING_PROGRESS_LIMIT)} 条`
: expanded
? `全部 ${progress.length} 条`
: `共 ${progress.length} 条`}
</span>
<ChevronDown
size={14}
className={cn("shrink-0 text-slate-400 transition-transform", !running && expanded && "rotate-180")}
aria-hidden="true"
/>
</button>
<AnimatePresence initial={false} mode="wait">
{visibleProgress.length ? (
<motion.ol
key={listMode}
aria-label={!running && expanded ? "全部执行进度" : "最近执行进度"}
className="mt-2 space-y-1.5 overflow-hidden"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={agentLayoutTransition}
>
<AnimatePresence initial={false} mode={expanded ? "sync" : "wait"}>
{visibleProgress.map((item) => (
<motion.li
key={item.id}
className="grid h-12 grid-cols-[16px_minmax(0,1fr)_auto] items-start gap-2 overflow-hidden text-xs"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -2 }}
transition={{ duration: 0.16, ease: agentEase }}
>
<motion.span
aria-hidden="true"
className={cn(
"mt-0.5 h-3.5 w-3.5 rounded-full border transition-colors duration-150",
progressStatusMeta[item.status].dotClassName
)}
animate={
item.status === "running"
? { opacity: [0.45, 1, 0.45], scale: 1 }
: item.status === "error"
? { opacity: [0.55, 1, 1], scale: [1, 1.28, 1] }
: { opacity: 1, scale: 1 }
}
transition={
item.status === "running"
? { duration: 1.2, ease: "easeInOut", repeat: Infinity }
: item.status === "error"
? { duration: 0.28, ease: agentEase }
: { duration: 0.14 }
}
/>
<span className="min-w-0">
<span className="block truncate font-semibold text-slate-800" title={item.title}>
{item.title}
</span>
{item.detail ? (
<span className="block truncate leading-5 text-slate-500" title={item.detail}>
{item.detail}
</span>
) : null}
</span>
<AnimatePresence initial={false} mode="wait">
<motion.span
key={item.status}
data-progress-status={item.status}
className={cn(
"min-w-12 rounded-full px-2 py-0.5 text-center font-semibold",
progressStatusMeta[item.status].className
)}
initial={{ opacity: 0 }}
animate={
item.status === "error"
? { opacity: [0, 1, 1], x: [0, -2, 2, -1, 0] }
: { opacity: 1, x: 0 }
}
exit={{ opacity: 0 }}
transition={
item.status === "error"
? { duration: 0.24, ease: agentEase }
: { duration: 0.14 }
}
>
{progressStatusMeta[item.status].label}
</motion.span>
</AnimatePresence>
</motion.li>
))}
</AnimatePresence>
</motion.ol>
) : null}
</AnimatePresence>
</div>
);
}
function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
return (
<AgentNestedBlock icon={ListChecks} iconClassName="text-emerald-600" title="计划">
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{todoUpdate.todos.slice(0, 6).map((todo) => (
<motion.li
key={todo.id}
className="grid grid-cols-[16px_1fr_auto] items-start gap-2 text-xs"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<span
className={cn(
"mt-0.5 grid h-3.5 w-3.5 place-items-center rounded-full border",
todoStatusMeta[todo.status].dotClassName
)}
>
{todo.status === "completed" ? <CheckCircle2 size={10} className="text-white" aria-hidden="true" /> : null}
</span>
<span className="min-w-0 truncate text-slate-700" title={todo.content}>
{todo.content}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-semibold", todoStatusMeta[todo.status].className)}>
{todoStatusMeta[todo.status].label}
</span>
</motion.li>
))}
</AnimatePresence>
</motion.ol>
</AgentNestedBlock>
);
}
function PermissionList({
permissions,
onReplyPermission
}: {
permissions: AgentPermissionRequest[];
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
}) {
return (
<AgentNestedBlock icon={LockKeyhole} iconClassName="text-orange-600" title="权限请求">
<motion.div className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{permissions.slice(-4).map((permission) => (
<AnimatedDetailItem key={permission.requestId}>
<PermissionRequestCard
permission={permission}
onReplyPermission={onReplyPermission}
/>
</AnimatedDetailItem>
))}
</AnimatePresence>
</motion.div>
</AgentNestedBlock>
);
}
function PermissionRequestCard({
permission,
onReplyPermission
}: {
permission: AgentPermissionRequest;
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
}) {
const actionable = permission.status === "pending" || permission.status === "error";
const submitting = permission.status === "submitting";
const canApproveAlways = permission.always.length > 0;
const disabled = !onReplyPermission || submitting;
const target = permission.target ?? (permission.patterns.join(" ") || permission.permission);
const reply = (nextReply: AgentPermissionReply) => {
if (!onReplyPermission || submitting) {
return;
}
void Promise.resolve(onReplyPermission(permission, nextReply));
};
return (
<div className="surface-reading rounded-lg px-2.5 py-2 text-xs">
<div className="grid grid-cols-[1fr_auto] gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-slate-800" title={permission.target ?? permission.permission}>
{getPermissionTitle(permission)}
</p>
<p className="mt-0.5 truncate font-mono text-xs text-slate-500" title={target}>
{target}
</p>
{permission.error ? (
<p className="mt-1 line-clamp-2 text-xs leading-4 text-red-600">{permission.error}</p>
) : null}
</div>
<span className={cn("self-start rounded-full px-2 py-0.5 font-semibold", permissionStatusMeta[permission.status].className)}>
{permissionStatusMeta[permission.status].label}
</span>
</div>
<AnimatePresence initial={false}>
{actionable ? (
<motion.div
key="permission-actions"
className="mt-2 flex flex-wrap items-center gap-1.5"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<Button
type="button"
size="sm"
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
disabled={disabled}
onClick={() => reply("once")}
>
<CheckCircle2 size={13} aria-hidden="true" />
允许一次
</Button>
{canApproveAlways ? (
<Button
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
disabled={disabled}
onClick={() => reply("always")}
>
<ShieldCheck size={13} aria-hidden="true" />
始终允许
</Button>
) : null}
<Button
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-red-200 bg-red-50 px-2 text-xs text-red-700 hover:bg-red-100"
disabled={disabled}
onClick={() => reply("reject")}
>
<XCircle size={13} aria-hidden="true" />
拒绝
</Button>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
function QuestionList({
questions,
onReplyQuestion,
onRejectQuestion
}: {
questions: AgentQuestionRequest[];
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) {
return (
<AgentNestedBlock icon={HelpCircle} iconClassName="text-blue-600" title="补充信息" floating>
<motion.div className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{questions.slice(-3).map((request) => (
<AnimatedDetailItem key={request.requestId}>
<QuestionRequestCard
request={request}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
</AnimatedDetailItem>
))}
</AnimatePresence>
</motion.div>
</AgentNestedBlock>
);
}
function QuestionRequestCard({
request,
onReplyQuestion,
onRejectQuestion
}: {
request: AgentQuestionRequest;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) {
const [draftAnswers, setDraftAnswers] = useState(() => createInitialQuestionAnswers(request));
const actionable = request.status === "pending" || request.status === "error";
const submitting = request.status === "submitting";
const disabled = submitting || !onReplyQuestion;
const answers = normalizeDraftAnswers(draftAnswers);
const canSubmit = actionable && answers.length === request.questions.length && answers.every((answer) => answer.length > 0);
const submit = () => {
if (!onReplyQuestion || !canSubmit) {
return;
}
void Promise.resolve(onReplyQuestion(request, answers));
};
const reject = () => {
if (!onRejectQuestion || submitting) {
return;
}
void Promise.resolve(onRejectQuestion(request));
};
return (
<div className="px-0 py-1 text-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="min-w-0 truncate font-semibold text-slate-800">
{request.questions[0]?.header || "问题"}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-semibold", questionStatusMeta[request.status].className)}>
{questionStatusMeta[request.status].label}
</span>
</div>
<div className="space-y-3">
{request.questions.map((question, questionIndex) => (
<QuestionInput
key={`${request.requestId}-${questionIndex}`}
question={question}
questionIndex={questionIndex}
value={draftAnswers[questionIndex] ?? { selected: [], custom: "" }}
disabled={!actionable || submitting}
onChange={(nextValue) =>
setDraftAnswers((current) => current.map((item, index) => (index === questionIndex ? nextValue : item)))
}
/>
))}
</div>
{request.error ? (
<p className="mt-2 line-clamp-2 text-xs leading-4 text-red-600">{request.error}</p>
) : null}
{request.answers?.length ? (
<p className="mt-2 truncate text-slate-500" title={request.answers.map((answer) => answer.join("、")).join("")}>
已答:{request.answers.map((answer) => answer.join("、")).join("")}
</p>
) : null}
<AnimatePresence initial={false}>
{actionable ? (
<motion.div
key="question-actions"
className="mt-2 flex flex-wrap items-center gap-1.5"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<Button
type="button"
size="sm"
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
disabled={disabled || !canSubmit}
onClick={submit}
>
<CheckCircle2 size={13} aria-hidden="true" />
提交回答
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
disabled={submitting || !onRejectQuestion}
onClick={reject}
>
<XCircle size={13} aria-hidden="true" />
跳过
</Button>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
type QuestionDraftAnswer = {
selected: string[];
custom: string;
};
function QuestionInput({
question,
questionIndex,
value,
disabled,
onChange
}: {
question: AgentQuestionInfo;
questionIndex: number;
value: QuestionDraftAnswer;
disabled: boolean;
onChange: (value: QuestionDraftAnswer) => void;
}) {
const name = `agent-question-${questionIndex}-${question.question}`;
const showCustomInput = question.custom || question.options.length === 0;
const toggleOption = (label: string, checked: boolean) => {
if (question.multiple) {
onChange({
...value,
selected: checked ? [...value.selected, label] : value.selected.filter((item) => item !== label)
});
return;
}
onChange({
...value,
selected: checked ? [label] : []
});
};
return (
<div className="space-y-1.5">
<p className="leading-5 text-slate-700">{question.question}</p>
{question.options.length ? (
<div className="space-y-1">
{question.options.map((option) => {
const checked = value.selected.includes(option.label);
return (
<label
key={option.label}
className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200/80 px-2 py-1.5 text-slate-600"
>
<input
type={question.multiple ? "checkbox" : "radio"}
name={name}
className="mt-0.5 h-3.5 w-3.5 accent-blue-600"
checked={checked}
disabled={disabled}
onChange={(event) => toggleOption(option.label, event.currentTarget.checked)}
/>
<span className="min-w-0">
<span className="block font-semibold text-slate-700">{option.label}</span>
{option.description ? (
<span className="block leading-4 text-slate-500">{option.description}</span>
) : null}
</span>
</label>
);
})}
</div>
) : null}
{showCustomInput ? (
<textarea
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-none transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
placeholder="输入回答"
value={value.custom}
disabled={disabled}
onChange={(event) => onChange({ ...value, custom: event.currentTarget.value })}
/>
) : null}
</div>
);
}
function createInitialQuestionAnswers(request: AgentQuestionRequest): QuestionDraftAnswer[] {
return request.questions.map((question, index) => ({
selected: request.answers?.[index] ?? [],
custom: ""
}));
}
function normalizeDraftAnswers(draftAnswers: QuestionDraftAnswer[]) {
return draftAnswers.map((answer) => {
const custom = answer.custom.trim();
return custom ? [...answer.selected, custom] : answer.selected;
});
}
function getPermissionTitle(permission: AgentPermissionRequest) {
if (permission.permission === "bash") return "执行终端命令";
if (permission.permission === "edit") return "修改文件内容";
if (permission.permission === "external_directory") return "访问工作区外目录";
return permission.permission || "工具权限请求";
}