feat(web): live step list, option choice card and inline approval card; drop run mode switch

- session page shows the agent.plan TodoList (drawer: one-line progress); options and approval
  cards sit at their step; composer has a "先看方案再执行" toggle instead of the mode switch
- settings expose option timeout, timeout action and approval validity; quick goals follow glossary

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 21:07:01 +08:00
1 parent cf2c4ff0e6
commit 9649854ab3
15 files changed
+731 -205

No files matched your search

@@ -0,0 +1,338 @@
import { type AgentPlanStep, CAPABILITY_CATALOG, type KnownCapabilityId, type RiskLevel } from "@umt/contracts";
import { ListChecks, MessageCircleQuestion, Star } 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 { type TodoItem, type TodoItemStatus, TodoList } from "~/components/agentui/todo-list.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.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 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<AgentPlanStep["status"], TodoItemStatus> = {
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 ? { detail: <span className="block max-w-48 truncate">{s.note}</span> } : {}),
}));
}
/** 步骤清单:会话页顶部完整列表(实时勾选);抽屉里压缩成一行进度 */
export function RunPlan({
plan,
active,
compact = false,
}: {
plan: { steps: AgentPlanStep[]; revision: number } | null;
active: boolean;
compact?: boolean;
}) {
if (!plan?.steps.length) return null;
const done = plan.steps.filter((s) => s.status === "done" || s.status === "skipped").length;
const current = plan.steps.find((s) => s.status === "running") ?? plan.steps.find((s) => s.status === "pending");
const failed = plan.steps.some((s) => s.status === "failed");
if (compact) {
const pct = Math.round((done / plan.steps.length) * 100);
return (
<section aria-label="步骤进度" className="rounded-xl border border-border/70 px-3 py-2">
<div className="flex min-w-0 items-center gap-2 text-meta">
<ListChecks
aria-hidden
className={cn("size-3.5 shrink-0 text-muted-foreground", failed && "text-destructive")}
/>
<span className="shrink-0 text-foreground tabular-nums">
步骤 {done}/{plan.steps.length}
</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{current ? `当前:${current.title}` : failed ? "有步骤失败" : "全部完成"}
</span>
</div>
<div className="mt-1.5 h-1 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
<div
className={cn(
"h-full rounded-full transition-[width] duration-layout ease-standard motion-reduce:transition-none",
failed ? "bg-destructive" : "bg-foreground/60",
)}
style={{ width: `${pct}%` }}
/>
</div>
</section>
);
}
return (
<TodoList
title="步骤"
items={planTodoItems(plan.steps)}
defaultOpen
collapseOnComplete={!active}
maxHeightClassName="max-h-56"
/>
);
}
// ── 方案选择 ──
const NONE = "__none__";
/** agent.propose_options 的选择卡:单选一个方案,或"都不要"并写原因;选定后会话继续 */
export function OptionsCard({ runId, view, waiting }: { runId: string; view: OptionsView; waiting: boolean }) {
const name = useId();
const choose = useChooseOption();
const [picked, setPicked] = useState<string>(view.recommendedId ?? "");
const [reason, setReason] = useState("");
const [sent, setSent] = useState<{ optionId: string | null; reason: string } | null>(null);
const resolved = view.resolved;
// 已作答但会话还没恢复(事件里没有 options_resolved):按本地提交结果显示
const closed = !!resolved || !!sent || !waiting;
const chosenId = resolved ? resolved.choice : sent ? sent.optionId : null;
const submit = () => {
const optionId = picked === NONE ? null : picked;
if (!picked) return;
if (optionId === null && !reason.trim()) {
toast.error("写一句不要的原因,Agent 按它调整");
return;
}
choose.mutate(
{ runId, decisionId: view.decisionId, optionId, ...(optionId === null ? { reason: reason.trim() } : {}) },
{
onSuccess: () => {
setSent({ optionId, reason: reason.trim() });
toast.success(optionId ? "已选定,Agent 继续执行" : "已退回,Agent 调整方案");
},
onError: (e) => toast.error(errorMessage(e)),
},
);
};
const outcome = resolved
? resolved.outcome === "chosen"
? `${resolved.by === "timeout" ? "超时自动选择" : "已选择"}:${view.options.find((o) => o.id === resolved.choice)?.title ?? resolved.choice}`
: resolved.outcome === "stop"
? "超时未选,按设置停止"
: `都不要${resolved.reason ? `:${resolved.reason}` : ""}`
: sent
? sent.optionId
? `已选择:${view.options.find((o) => o.id === sent.optionId)?.title ?? sent.optionId},等待 Agent 继续`
: `都不要:${sent.reason},等待 Agent 调整`
: !waiting
? "会话已不在等待这个选择"
: null;
return (
<section
aria-label="选择方案"
className={cn(
"w-full min-w-0 rounded-xl border p-3",
closed ? "border-border/70" : "border-warning/40 bg-warning/[0.06]",
)}
>
<p className="flex items-start gap-2 text-caption font-medium text-foreground">
<MessageCircleQuestion aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 break-words">{view.question}</span>
</p>
<fieldset className="mt-2.5 space-y-1.5" disabled={closed || choose.isPending}>
<legend className="sr-only">{view.question}</legend>
{view.options.map((o) => {
const selected = closed ? chosenId === o.id : picked === o.id;
return (
<label
key={o.id}
className={cn(
"flex min-w-0 cursor-pointer gap-2.5 rounded-lg border border-border/70 px-3 py-2 transition-colors duration-micro has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
selected && "border-foreground/40 bg-foreground/[0.04]",
closed && !selected && "opacity-60",
closed && "cursor-default",
)}
>
<input
type="radio"
name={name}
value={o.id}
checked={selected}
onChange={() => setPicked(o.id)}
className="mt-1 size-3.5 shrink-0 accent-foreground"
/>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-1.5">
<span className="text-caption font-medium text-foreground">{o.title}</span>
{view.recommendedId === o.id ? (
<StatusBadge>
<Star className="size-3" />
推荐
</StatusBadge>
) : null}
{o.estimatedCost ? <StatusBadge>估算 {o.estimatedCost}</StatusBadge> : null}
</span>
<span className="mt-0.5 block text-meta break-words text-muted-foreground">{o.summary}</span>
{o.risks.length ? (
<ul className="mt-1 space-y-0.5 text-meta text-warning">
{o.risks.map((r) => (
<li key={r} className="break-words">
风险:{r}
</li>
))}
</ul>
) : null}
</span>
</label>
);
})}
{closed ? null : (
<label
className={cn(
"flex min-w-0 cursor-pointer gap-2.5 rounded-lg border border-dashed border-border px-3 py-2 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
picked === NONE && "border-foreground/40",
)}
>
<input
type="radio"
name={name}
value={NONE}
checked={picked === NONE}
onChange={() => setPicked(NONE)}
className="mt-1 size-3.5 shrink-0 accent-foreground"
/>
<span className="min-w-0 flex-1 text-caption text-foreground">都不要,说明原因</span>
</label>
)}
</fieldset>
{!closed && picked === NONE ? (
<Textarea
className="mt-2 min-h-16 text-caption"
value={reason}
maxLength={1000}
aria-label="不要的原因"
placeholder="例如:体积太大,只要 WEB-DL"
onChange={(e) => setReason(e.target.value)}
/>
) : null}
{closed ? (
<p className="mt-2 text-meta text-muted-foreground">{outcome}</p>
) : (
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<Button size="sm" onClick={submit} disabled={!picked || choose.isPending}>
{picked === NONE ? "退回" : "选定"}
</Button>
<span className="text-meta text-muted-foreground">
{formatDateTime(view.expiresAt)} 前没选,
{view.timeoutAction === "recommended" ? "自动选推荐项" : "Agent 停止"}。
</span>
</div>
)}
</section>
);
}
// ── 步骤处的审批卡 ──
const RISK: Record<RiskLevel, { label: string; tone: StatusTone }> = {
low: { label: "低风险", tone: "neutral" },
medium: { label: "中风险", tone: "warning" },
high: { label: "高风险", tone: "destructive" },
};
const PLAN_LABEL: Record<string, string> = {
acquisition_plan: "下载计划",
organize_plan: "整理计划",
policy_revision: "策略版本",
};
const RESOLVED_STATUS: Record<string, ApprovalCardStatus> = {
approved: "approved",
rejected: "rejected",
revoked: "revoked",
expired: "expired",
invalid: "expired",
consumed: "consumed",
};
/** 需审批的能力调用:在对应步骤处出审批卡(计划摘要、payload hash、风险、到期);批准 / 拒绝后会话继续 */
export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTitle?: string | null }) {
const admin = useIsAdminSession();
const live = useApproval(view.approvalId);
const decide = useDecideApproval();
const [reason, setReason] = useState("");
const a = live.data;
const expired = new Date(view.expiresAt).getTime() < Date.now();
const serverStatus = a?.status ?? (view.resolved ? view.resolved.status : "pending");
const status: ApprovalCardStatus = decide.isPending
? "submitting"
: serverStatus === "pending" && expired
? "expired"
: (RESOLVED_STATUS[serverStatus] ?? (serverStatus as ApprovalCardStatus));
const cap = CAPABILITY_CATALOG[view.capabilityId as KnownCapabilityId];
const act = (s: "approved" | "rejected") =>
decide.mutate(
{
id: view.approvalId,
status: s,
payloadHash: a?.payloadHash ?? view.payloadHash,
...(s === "rejected" && reason.trim() ? { reason: reason.trim() } : {}),
},
{
onSuccess: () => toast.success(s === "approved" ? "已批准,Agent 继续执行" : "已拒绝,Agent 按理由调整"),
onError: (e) => toast.error(errorMessage(e)),
},
);
const decidedReason = a?.reason && a.status === "rejected" ? a.reason : view.resolved?.reason;
return (
<ApprovalCard
status={status}
title={`批准「${cap?.title ?? view.capabilityId}」?`}
description={view.summary}
badges={
<>
<StatusBadge tone={RISK[view.riskLevel].tone}>{RISK[view.riskLevel].label}</StatusBadge>
{stepTitle ? <StatusBadge>步骤 · {stepTitle}</StatusBadge> : null}
</>
}
onApprove={() => act("approved")}
onReject={() => act("rejected")}
disabledReason={admin ? undefined : "只有控制台管理员会话能批准或拒绝"}
result={
status === "rejected" && decidedReason
? `已拒绝:${decidedReason}`
: a?.decidedAt
? `${status === "approved" || status === "consumed" ? "批准" : "处理"}于 ${formatDateTime(a.decidedAt)}`
: undefined
}
>
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta">
<dt className="text-muted-foreground">计划</dt>
<dd className="min-w-0 font-mono break-all text-foreground/85">
{PLAN_LABEL[view.planType] ?? view.planType} · {view.planId}
</dd>
<dt className="text-muted-foreground">payload hash</dt>
<dd className="min-w-0 font-mono break-all text-foreground/85">{view.payloadHash}</dd>
<dt className="text-muted-foreground">版本</dt>
<dd className="text-foreground/85 tabular-nums">r{view.expectedRevision}</dd>
<dt className="text-muted-foreground">到期</dt>
<dd className={cn("text-foreground/85", expired && status === "expired" && "text-destructive")}>
{formatDateTime(view.expiresAt)}
</dd>
</dl>
{status === "pending" && admin ? (
<Textarea
className="mt-2 min-h-14 text-caption"
value={reason}
maxLength={2000}
aria-label="拒绝理由"
placeholder="拒绝时写理由或建议(可选),Agent 按它调整"
onChange={(e) => setReason(e.target.value)}
/>
) : null}
</ApprovalCard>
);
}