import { type AgentOptionView, type AgentPlanStep, CAPABILITY_CATALOG, type KnownCapabilityId, type RiskLevel, } from "@umt/contracts"; import { ChevronRight, ListChecks, LoaderCircle, MessageCircleQuestion } from "lucide-react"; import { useId, useState } from "react"; import { toast } from "sonner"; import { useApproval, useChooseOption, useDecideApproval, useIsAdminSession } from "~/api/agent.ts"; import { ApprovalCard, type ApprovalCardStatus } from "~/components/agentui/approval-card.tsx"; import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx"; import { type TodoItem, type TodoItemStatus, TodoList } from "~/components/agentui/todo-list.tsx"; import { TechInfo } from "~/components/console/tech-info.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Textarea } from "~/components/ui/textarea.tsx"; import { errorMessage } from "~/lib/api.ts"; import { cn, formatDateTime } from "~/lib/utils.ts"; import { stepProgress } from "./conversation-model.ts"; import type { ApprovalView, OptionsView } from "./use-agent-stream.ts"; // 自动化优先的会话交互(AG-2):步骤清单(agent.plan)、方案选择(agent.propose_options)、步骤处的审批卡。 // 事件形状见 packages/contracts/src/agent-events.ts;服务端逻辑见 apps/server/src/modules/agent/interaction.ts。 const STEP_STATUS: Record = { pending: "pending", running: "in-progress", done: "completed", skipped: "skipped", failed: "error", }; export function planTodoItems(steps: AgentPlanStep[]): TodoItem[] { return steps.map((s) => ({ id: s.id, title: s.title, status: STEP_STATUS[s.status], // 备注只在失败时显示(失败原因);其余备注是模型的过程说明,不上屏 ...(s.note && s.status === "failed" ? { detail: {s.note} } : {}), })); } /** 步骤清单:会话页右侧栏完整列表(实时勾选);compact 压缩成一行进度 */ export function RunPlan({ plan, active, compact = false, fill = false, }: { plan: { steps: AgentPlanStep[]; revision: number } | null; active: boolean; compact?: boolean; /** 侧栏 / 展开的概况条里:不限高、不自动收起 */ fill?: boolean; }) { if (!plan?.steps.length) return null; const progress = stepProgress(plan.steps); const failed = progress.failed > 0; if (compact) { const pct = progress.pct; return (
步骤 {progress.done}/{progress.total} {progress.status}
); } return ( ); } // ── 方案选择 ── /** 方案排序:推荐项放第一位 */ export function orderOptions(options: T[], recommendedId: string | null): T[] { const rec = options.find((o) => o.id === recommendedId); return rec ? [rec, ...options.filter((o) => o !== rec)] : options; } /** * 方案按钮上的动作要点:「提交下载 · 《沙丘:第二部》· 12.0 GB」;标题已经是动作名时只写对象和大小。 * 没有动作时用模型给的估算代价。 */ export function optionPoint(o: AgentOptionView): string | null { if (o.action) { const label = o.title.includes(o.action.label) ? null : o.action.label; return [label, o.action.detail].filter(Boolean).join(" · ") || null; } return o.estimatedCost ?? null; } /** 超时说明里的时间:当天只写时分 */ function deadlineText(iso: string): string { const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; return d.toDateString() === new Date().toDateString() ? d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }) : formatDateTime(iso); } /** * agent.propose_options 的方案卡(AGUI-3):每个方案一个按钮,点一下就选定;推荐项在第一位、是主按钮。 * 绑定了计划的方案写明动作要点,管理员选定即批准(服务端核对绑定)。风险折叠;「都不要…」点开才出原因框。 */ export function OptionsCard({ runId, view, waiting }: { runId: string; view: OptionsView; waiting: boolean }) { const choose = useChooseOption(); const riskId = useId(); const [riskOpen, setRiskOpen] = useState(false); const [noneOpen, setNoneOpen] = useState(false); const [reason, setReason] = useState(""); const [picking, setPicking] = useState(null); const [sent, setSent] = useState<{ optionId: string | null; reason: string } | null>(null); const resolved = view.resolved; // 已作答但会话还没恢复(事件里没有 options_resolved):按本地提交结果显示 const closed = !!resolved || !!sent || !waiting; const options = orderOptions(view.options, view.recommendedId); const risks = options.flatMap((o) => o.risks.map((r) => ({ key: `${o.id}:${r}`, option: o.title, text: r }))); const submit = (optionId: string | null) => { if (optionId === null && !reason.trim()) { toast.error("写一句不要的原因,智能体按它调整"); return; } setPicking(optionId ?? "__none__"); choose.mutate( { runId, decisionId: view.decisionId, optionId, ...(optionId === null ? { reason: reason.trim() } : {}) }, { onSuccess: () => { setSent({ optionId, reason: reason.trim() }); const o = view.options.find((x) => x.id === optionId); toast.success(optionId ? (o?.action?.approves ? "已选定并批准" : "已选定") : "已退回,智能体调整方案"); }, onError: (e) => toast.error(errorMessage(e)), onSettled: () => setPicking(null), }, ); }; const label = (id: string | null) => { const o = view.options.find((x) => x.id === id); if (!o) return id ?? ""; const point = optionPoint(o); return point && o.action ? `${o.title} · ${point}` : o.title; }; const outcome = resolved ? resolved.outcome === "chosen" ? `${resolved.by === "timeout" ? "超时自动选了" : "已选"}:${label(resolved.choice)}` : resolved.outcome === "stop" ? "超时没选,已停止" : `都不要${resolved.reason ? `:${resolved.reason}` : ""}` : sent ? sent.optionId ? `已选:${label(sent.optionId)}` : `都不要:${sent.reason}` : !waiting ? "已不再等这个选择" : null; if (closed) return (

{view.question} · {outcome}

); return (

{view.question}

    {options.map((o) => { const recommended = o.id === view.recommendedId; const point = optionPoint(o); return (
  • ); })}
{risks.length ? ( ) : null} {deadlineText(view.expiresAt)} 前没选, {view.timeoutAction === "recommended" ? "自动选推荐项" : "智能体停止"}
    {risks.map((r) => (
  • {r.option}:{r.text}
  • ))}
{noneOpen ? (