Derive the gem-blue palette with OKLCH tonal and complementary-color rules while preserving acrylic opacity, blur and surface hierarchy. Darker focus tokens exposed stacked field rings, fallback outlines and persistent basemap selection rings. Keep fallback focus styles in the base layer and use one themed field boundary or inset card stroke. Preserve keyboard focus, invalid states and native input semantics. Add browser regression coverage for pointer and keyboard focus, all basemap presentations, narrow layouts and rendered contrast. Validation in an isolated checkout: pnpm lint, pnpm typecheck, pnpm test (393 tests), focused browser suites and pnpm build.
782 lines
27 KiB
TypeScript
782 lines
27 KiB
TypeScript
import {
|
||
CheckCircle2,
|
||
ChevronDown,
|
||
HelpCircle,
|
||
ListChecks,
|
||
ListTree,
|
||
LockKeyhole,
|
||
ShieldCheck,
|
||
XCircle,
|
||
type LucideIcon
|
||
} from "lucide-react";
|
||
import { AnimatePresence, motion } from "motion/react";
|
||
import { useId, useState, type ReactNode } from "react";
|
||
import { cn } from "@/shared/ui/cn";
|
||
import { Button } from "@/shared/ui/button";
|
||
import { Checkbox } from "@/shared/ui/checkbox";
|
||
import { Radio } from "@/shared/ui/radio";
|
||
import { Textarea } from "@/shared/ui/textarea";
|
||
import {
|
||
StatusBadge,
|
||
StatusDot,
|
||
StatusIcon as SemanticStatusIcon,
|
||
type StatusActivity,
|
||
type StatusTone
|
||
} from "@/shared/ui/status";
|
||
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;
|
||
|
||
type AgentStatusMeta = {
|
||
label: string;
|
||
tone: StatusTone;
|
||
activity?: StatusActivity;
|
||
};
|
||
|
||
const progressStatusMeta: Record<ProgressStatus, AgentStatusMeta> = {
|
||
running: {
|
||
label: "进行中",
|
||
tone: "info",
|
||
activity: "live"
|
||
},
|
||
completed: {
|
||
label: "已完成",
|
||
tone: "success"
|
||
},
|
||
error: {
|
||
label: "失败",
|
||
tone: "danger"
|
||
}
|
||
};
|
||
|
||
const todoStatusMeta: Record<TodoStatus, AgentStatusMeta> = {
|
||
completed: {
|
||
label: "完成",
|
||
tone: "success"
|
||
},
|
||
in_progress: {
|
||
label: "进行中",
|
||
tone: "info",
|
||
activity: "live"
|
||
},
|
||
pending: {
|
||
label: "待办",
|
||
tone: "neutral"
|
||
},
|
||
cancelled: {
|
||
label: "取消",
|
||
tone: "neutral"
|
||
}
|
||
};
|
||
|
||
const permissionStatusMeta: Record<PermissionStatus, AgentStatusMeta> = {
|
||
approved_always: { label: "已始终允许", tone: "success" },
|
||
approved_once: { label: "已允许一次", tone: "success" },
|
||
rejected: { label: "已拒绝", tone: "danger" },
|
||
aborted: { label: "已中止", tone: "neutral" },
|
||
error: { label: "失败", tone: "danger" },
|
||
submitting: { label: "提交中", tone: "info", activity: "live" },
|
||
pending: { label: "待确认", tone: "warning" }
|
||
};
|
||
|
||
const questionStatusMeta: Record<QuestionStatus, AgentStatusMeta> = {
|
||
answered: { label: "已回答", tone: "success" },
|
||
rejected: { label: "已跳过", tone: "neutral" },
|
||
error: { label: "失败", tone: "danger" },
|
||
submitting: { label: "提交中", tone: "info", activity: "live" },
|
||
pending: { label: "待回答", tone: "info" }
|
||
};
|
||
|
||
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 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 listId = useId();
|
||
const compact = running && !expanded;
|
||
const visibleProgress = expanded
|
||
? progress
|
||
: running
|
||
? progress.slice(-STREAMING_PROGRESS_LIMIT)
|
||
: [];
|
||
const listMode = expanded ? "expanded" : running ? "streaming" : "collapsed";
|
||
|
||
return (
|
||
<div className="agent-panel-nested rounded-xl p-2.5" data-agent-progress>
|
||
<div className="flex min-h-8 items-center gap-2 px-0.5">
|
||
<ListTree size={15} className="shrink-0 text-[var(--action-blue-hover)]" aria-hidden="true" />
|
||
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-slate-700">执行进度</span>
|
||
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-[var(--ink-muted)]">
|
||
{running ? (
|
||
<StatusDot tone="info" activity="live" aria-hidden="true" />
|
||
) : null}
|
||
<span>{running ? (expanded ? "全部" : "最近") : "步骤"}</span>
|
||
<span className="font-mono tabular-nums text-slate-600">
|
||
{expanded || !running
|
||
? progress.length
|
||
: Math.min(progress.length, STREAMING_PROGRESS_LIMIT)}
|
||
<span className="px-0.5 text-slate-300">/</span>
|
||
{progress.length}
|
||
</span>
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
"-my-1 -mr-1.5 grid h-10 w-10 shrink-0 place-items-center rounded-md text-[var(--ink-muted)] transition-colors [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]",
|
||
expanded && "text-[var(--action-blue-hover)]"
|
||
)}
|
||
aria-controls={listId}
|
||
aria-expanded={expanded}
|
||
aria-label={expanded ? "收起执行进度" : "展开执行进度"}
|
||
data-state={expanded ? "open" : "closed"}
|
||
onClick={() => setExpanded((current) => !current)}
|
||
>
|
||
<ChevronDown
|
||
size={14}
|
||
className={cn("transition-transform duration-150", expanded && "rotate-180")}
|
||
aria-hidden="true"
|
||
/>
|
||
</button>
|
||
</div>
|
||
<AnimatePresence initial={false} mode="wait">
|
||
{visibleProgress.length ? (
|
||
<motion.ol
|
||
key={listMode}
|
||
id={listId}
|
||
aria-label={expanded ? "全部执行进度" : "最近执行进度"}
|
||
className="mt-2 flex flex-col gap-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="popLayout">
|
||
{visibleProgress.map((item) => (
|
||
<motion.li
|
||
key={item.id}
|
||
className={cn(
|
||
"grid grid-cols-[16px_minmax(0,1fr)_auto] items-start gap-2 text-xs",
|
||
compact && "h-12 overflow-hidden"
|
||
)}
|
||
initial={{ opacity: 0, y: 4 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -2 }}
|
||
transition={{ duration: 0.16, ease: agentEase }}
|
||
>
|
||
<StatusDot
|
||
tone={progressStatusMeta[item.status].tone}
|
||
activity={progressStatusMeta[item.status].activity}
|
||
size="md"
|
||
className="mt-1"
|
||
/>
|
||
<span className="min-w-0">
|
||
<span
|
||
className={cn(
|
||
"block font-semibold text-slate-800",
|
||
compact ? "truncate" : "break-words"
|
||
)}
|
||
title={item.title}
|
||
>
|
||
{item.title}
|
||
</span>
|
||
{item.detail ? (
|
||
<span
|
||
className={cn(
|
||
"block leading-5 text-[var(--ink-muted)]",
|
||
compact ? "truncate" : "break-words"
|
||
)}
|
||
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 pt-0.5 text-right text-xs font-semibold",
|
||
item.status === "running"
|
||
? "text-[var(--action-blue-hover)]"
|
||
: [
|
||
`status-tone-${progressStatusMeta[item.status].tone}`,
|
||
"text-[var(--status-foreground)]"
|
||
]
|
||
)}
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ 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="status-tone-success text-[var(--status-foreground)]" 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}
|
||
>
|
||
<SemanticStatusIcon
|
||
tone={todoStatusMeta[todo.status].tone}
|
||
activity={todoStatusMeta[todo.status].activity}
|
||
shape="circle"
|
||
size="xs"
|
||
className="mt-0.5"
|
||
>
|
||
{todo.status === "completed" ? <CheckCircle2 size={10} /> : null}
|
||
</SemanticStatusIcon>
|
||
<span className="min-w-0 truncate text-slate-700" title={todo.content}>
|
||
{todo.content}
|
||
</span>
|
||
<StatusBadge
|
||
tone={todoStatusMeta[todo.status].tone}
|
||
activity={todoStatusMeta[todo.status].activity}
|
||
>
|
||
{todoStatusMeta[todo.status].label}
|
||
</StatusBadge>
|
||
</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="status-tone-warning text-[var(--status-foreground)]" title="权限请求">
|
||
<motion.div className="flex flex-col gap-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-[var(--ink-muted)]" title={target}>
|
||
{target}
|
||
</p>
|
||
{permission.error ? (
|
||
<p className="status-tone-danger mt-1 line-clamp-2 text-xs leading-4 text-[var(--status-foreground)]">{permission.error}</p>
|
||
) : null}
|
||
</div>
|
||
<StatusBadge
|
||
tone={permissionStatusMeta[permission.status].tone}
|
||
activity={permissionStatusMeta[permission.status].activity}
|
||
className="self-start"
|
||
>
|
||
{permissionStatusMeta[permission.status].label}
|
||
</StatusBadge>
|
||
</div>
|
||
<AnimatePresence initial={false}>
|
||
{actionable ? (
|
||
<motion.div
|
||
key="permission-actions"
|
||
className="mt-2 flex flex-wrap items-center gap-2"
|
||
initial="initial"
|
||
animate="animate"
|
||
exit="exit"
|
||
variants={agentPanelSectionVariants}
|
||
style={{ overflow: "hidden" }}
|
||
>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
className="h-8 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-8 border-[var(--surface-border)] bg-[var(--action-soft)] px-2 text-xs text-[var(--action-blue-hover)] hover:bg-[var(--action-selection-soft)]"
|
||
disabled={disabled}
|
||
onClick={() => reply("always")}
|
||
>
|
||
<ShieldCheck size={13} aria-hidden="true" />
|
||
始终允许
|
||
</Button>
|
||
) : null}
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
className="status-tone-danger h-8 border-[var(--status-border)] bg-[var(--status-soft)] px-2 text-xs text-[var(--status-foreground)] [@media(hover:hover)]:hover:brightness-95"
|
||
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-[var(--action-blue-hover)]" title="补充信息" floating>
|
||
<motion.div className="flex flex-col gap-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>
|
||
<StatusBadge
|
||
tone={questionStatusMeta[request.status].tone}
|
||
activity={questionStatusMeta[request.status].activity}
|
||
>
|
||
{questionStatusMeta[request.status].label}
|
||
</StatusBadge>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{request.questions.map((question, questionIndex) => (
|
||
<QuestionInput
|
||
key={`${request.requestId}-${questionIndex}`}
|
||
question={question}
|
||
questionIndex={questionIndex}
|
||
value={draftAnswers[questionIndex] ?? { selected: [], customSelected: false, custom: "" }}
|
||
disabled={!actionable || submitting}
|
||
onChange={(nextValue) =>
|
||
setDraftAnswers((current) => current.map((item, index) => (index === questionIndex ? nextValue : item)))
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{request.error ? (
|
||
<p className="status-tone-danger mt-2 line-clamp-2 text-xs leading-4 text-[var(--status-foreground)]">{request.error}</p>
|
||
) : null}
|
||
{request.answers?.length ? (
|
||
<p className="mt-2 truncate text-[var(--ink-muted)]" 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-2"
|
||
initial="initial"
|
||
animate="animate"
|
||
exit="exit"
|
||
variants={agentPanelSectionVariants}
|
||
style={{ overflow: "hidden" }}
|
||
>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
className="h-8 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-8 border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2 text-xs text-slate-600 hover:bg-[var(--surface-reading)]"
|
||
disabled={submitting || !onRejectQuestion}
|
||
onClick={reject}
|
||
>
|
||
<XCircle size={13} aria-hidden="true" />
|
||
跳过
|
||
</Button>
|
||
</motion.div>
|
||
) : null}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type QuestionDraftAnswer = {
|
||
selected: string[];
|
||
customSelected: boolean;
|
||
custom: string;
|
||
};
|
||
|
||
function QuestionInput({
|
||
question,
|
||
questionIndex,
|
||
value,
|
||
disabled,
|
||
onChange
|
||
}: {
|
||
question: AgentQuestionInfo;
|
||
questionIndex: number;
|
||
value: QuestionDraftAnswer;
|
||
disabled: boolean;
|
||
onChange: (value: QuestionDraftAnswer) => void;
|
||
}) {
|
||
const inputId = useId();
|
||
const name = `agent-question-${questionIndex}-${inputId}`;
|
||
const customEnabled = question.custom !== false;
|
||
const showCustomChoice = customEnabled && question.options.length > 0;
|
||
const showCustomInput = customEnabled && value.customSelected;
|
||
|
||
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] : [],
|
||
customSelected: false
|
||
});
|
||
};
|
||
|
||
const toggleCustom = (checked: boolean) => {
|
||
onChange({
|
||
...value,
|
||
selected: question.multiple ? value.selected : [],
|
||
customSelected: checked
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-1.5">
|
||
<p className="leading-5 text-slate-700">{question.question}</p>
|
||
{question.options.length || showCustomChoice ? (
|
||
<div className="space-y-1">
|
||
{question.options.map((option) => {
|
||
const checked = value.selected.includes(option.label);
|
||
const Choice = question.multiple ? Checkbox : Radio;
|
||
return (
|
||
<label
|
||
key={option.label}
|
||
className={cn(
|
||
"flex cursor-pointer items-start gap-2 rounded-md border px-2 py-1.5 transition-colors",
|
||
checked
|
||
? "border-[var(--palette-brand)] bg-[var(--action-soft)]/70 text-[var(--action-blue-hover)]"
|
||
: "border-[var(--surface-divider)]/80 text-slate-600 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)]/70",
|
||
disabled && "cursor-default opacity-60"
|
||
)}
|
||
>
|
||
<Choice
|
||
name={name}
|
||
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-[var(--ink-muted)]">{option.description}</span>
|
||
) : null}
|
||
</span>
|
||
</label>
|
||
);
|
||
})}
|
||
{showCustomChoice ? (
|
||
<label
|
||
className={cn(
|
||
"flex cursor-pointer items-center gap-2 rounded-md border px-2 py-2 transition-colors",
|
||
value.customSelected
|
||
? "border-[var(--palette-brand)] bg-[var(--action-soft)]/70 text-[var(--action-blue-hover)]"
|
||
: "border-[var(--surface-divider)]/80 text-slate-600 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)]/70",
|
||
disabled && "cursor-default opacity-60"
|
||
)}
|
||
>
|
||
{question.multiple ? (
|
||
<Checkbox name={name} checked={value.customSelected} disabled={disabled} onChange={(event) => toggleCustom(event.currentTarget.checked)} />
|
||
) : (
|
||
<Radio name={name} checked={value.customSelected} disabled={disabled} onChange={(event) => toggleCustom(event.currentTarget.checked)} />
|
||
)}
|
||
<span className="font-semibold text-slate-700">自定义回答</span>
|
||
</label>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
{showCustomInput ? (
|
||
<Textarea
|
||
className="min-h-16 resize-none border-[var(--surface-divider)]/80 px-2 py-1.5 text-xs leading-5 text-slate-700 shadow-none md:text-xs disabled:opacity-60"
|
||
aria-label="自定义回答内容"
|
||
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) => {
|
||
const optionLabels = new Set(question.options.map((option) => option.label));
|
||
const savedAnswers = request.answers?.[index] ?? [];
|
||
const selected = savedAnswers.filter((answer) => optionLabels.has(answer));
|
||
const customAnswers = savedAnswers.filter((answer) => !optionLabels.has(answer));
|
||
const customEnabled = question.custom !== false;
|
||
|
||
return {
|
||
selected,
|
||
customSelected: customEnabled && (question.options.length === 0 || customAnswers.length > 0),
|
||
custom: customAnswers.join(";")
|
||
};
|
||
});
|
||
}
|
||
|
||
function normalizeDraftAnswers(draftAnswers: QuestionDraftAnswer[]) {
|
||
return draftAnswers.map((answer) => {
|
||
const custom = answer.custom.trim();
|
||
return answer.customSelected && 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 || "工具权限请求";
|
||
}
|