Files
UHD_Media_Tool/apps/web/app/components/agent/agent-interaction.tsx
T
rootandClaude Opus 5.5 6fcf82afb0 feat(web): 智能体对话重做(AGUI-3)
- 会话流:用户消息 → 一行进度 → 结论正文 → 没做的事 → 一行实际结果。进度行把一段里的工具调用、思考和过程中的话
  聚成一行(1 次也折叠),按能力 verb/noun 人话计数(「查了站点、2 个下载任务和媒体库 · 1 项没做 · 3 秒」),
  展开每步一行;工具名、调用 ID、幂等键、输入输出进技术信息。去掉回合头、「模型说的」重复卡和系统核实大卡。
- agent.plan / agent.propose_options 不作为工具调用出现(旧事件回放同样跳过);被拒调用不出红卡,
  回合末尾一行「没做:检测站点,需要你批准 [批准并重试]」(非计划类能力建一次性持续授权后重试)。
- 方案卡:每个方案一个按钮,点一下就选定;推荐项第一位、主按钮;按钮写服务端给的动作要点;风险折叠;
  「都不要…」点开才出原因框;选完收成一行。审批卡只留摘要和批准 / 拒绝,计划 ID、hash、版本、到期进技术信息。
- 会话页去掉右侧栏和页头目标,步骤 / 子会话 / 模型 / 预算 / ID 进顶部概况条;输入区只有输入框和发送 / 停止,
  / 指令 /先给方案、/重跑(「先看方案再执行」开关原本只是新建时的本地状态,不需要迁移)。
- 抽屉去掉「当前 / 历史」Tab:头部「历史」弹层(最近 20 条)+「新对话」;上下文显示成名称标签,可去掉。
- 会话列表:搜索 + 全部 / 等你处理 / 失败;等你处理置顶,子会话收在父会话下,正常结束不加徽章。
- 入口页与空态给 3 个快捷目标。mock 加审查报告 3 个示例的演示数据;smoke 改测历史弹层。
- 组件测试:聚合、隐藏、拒绝一行、方案一键选定、历史置顶与子会话、指令。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 22:41:34 +08:00

386 lines
16 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.
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 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 && s.status === "failed" ? { detail: <span title={s.note}>{s.note}</span> } : {}),
}));
}
/** 步骤清单:会话页右侧栏完整列表(实时勾选);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 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={!fill && !active}
maxHeightClassName={fill ? "max-h-none" : "max-h-56"}
/>
);
}
// ── 方案选择 ──
/** 方案排序:推荐项放第一位 */
export function orderOptions<T extends { id: string }>(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<string | null>(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 (
<p
data-slot="options-resolved"
className="flex w-full min-w-0 items-start gap-2 rounded-xl border border-border/70 px-3 py-2 text-caption text-muted-foreground"
>
<MessageCircleQuestion aria-hidden className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 break-words">
<span className="text-foreground/85">{view.question}</span> · {outcome}
</span>
</p>
);
return (
<section aria-label="选择方案" className="w-full min-w-0 rounded-xl border border-warning/40 bg-warning/[0.06] p-3">
<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>
<ul className="mt-2.5 flex flex-col gap-1.5">
{options.map((o) => {
const recommended = o.id === view.recommendedId;
const point = optionPoint(o);
return (
<li key={o.id}>
<button
type="button"
disabled={choose.isPending}
onClick={() => submit(o.id)}
data-recommended={recommended || undefined}
className={cn(
"flex w-full min-w-0 cursor-pointer flex-col gap-0.5 rounded-lg border px-3 py-2 text-left outline-none transition-colors duration-micro focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-60",
recommended
? "pill-primary border-transparent"
: "border-border/70 bg-background/40 hover:bg-foreground/[0.04]",
)}
>
<span className="flex min-w-0 flex-wrap items-center gap-x-1.5 text-caption font-medium">
{picking === o.id ? (
<LoaderCircle aria-hidden className="size-3.5 animate-spin motion-reduce:animate-none" />
) : null}
<span className="min-w-0 break-words">{o.title}</span>
{point ? <span className="font-normal opacity-80">· {point}</span> : null}
{recommended ? <span className="font-normal opacity-80">(推荐)</span> : null}
</span>
<span className={cn("text-meta break-words", recommended ? "opacity-80" : "text-muted-foreground")}>
{o.summary}
{o.action ? (o.action.approves ? "。选定即执行。" : "。执行前还会再确认。") : ""}
</span>
</button>
</li>
);
})}
</ul>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground">
{risks.length ? (
<button
type="button"
aria-expanded={riskOpen}
aria-controls={riskId}
onClick={() => setRiskOpen((v) => !v)}
className="inline-flex cursor-pointer items-center gap-0.5 rounded-md outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{risks.length} 条风险
<ChevronRight aria-hidden className={cn("size-3.5", CHEVRON_CLASS, riskOpen && "rotate-90")} />
</button>
) : null}
<button
type="button"
aria-expanded={noneOpen}
onClick={() => setNoneOpen((v) => !v)}
className="cursor-pointer rounded-md underline-offset-2 outline-none hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring"
>
都不要…
</button>
<span className="ml-auto">
{deadlineText(view.expiresAt)} 前没选,
{view.timeoutAction === "recommended" ? "自动选推荐项" : "智能体停止"}
</span>
</div>
<Disclosure id={riskId} open={riskOpen}>
<ul className="mt-1.5 space-y-0.5 text-meta text-warning">
{risks.map((r) => (
<li key={r.key} className="break-words">
{r.option}:{r.text}
</li>
))}
</ul>
</Disclosure>
{noneOpen ? (
<div className="mt-2 flex flex-col gap-2">
<Textarea
className="min-h-16 text-caption"
value={reason}
maxLength={1000}
aria-label="不要的原因"
placeholder="例如:体积太大,只要 WEB-DL"
onChange={(e) => setReason(e.target.value)}
/>
<Button
size="sm"
variant="outline"
className="self-start"
disabled={!reason.trim() || choose.isPending}
onClick={() => submit(null)}
>
退回
</Button>
</div>
) : null}
</section>
);
}
// ── 步骤处的审批卡 ──
const RISK_LABEL: Record<RiskLevel, string> = { low: "低", medium: "中", high: "高" };
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",
};
/** 需审批的能力调用:只显示摘要和【批准】【拒绝】;计划 ID、payload hash、版本、到期进技术信息 */
export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTitle?: string | null }) {
const admin = useIsAdminSession();
const live = useApproval(view.approvalId);
const decide = useDecideApproval();
const [rejecting, setRejecting] = useState(false);
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" ? "已批准,智能体继续执行" : "已拒绝,智能体按理由调整"),
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}
onApprove={() => act("approved")}
onReject={() => (rejecting ? act("rejected") : setRejecting(true))}
rejectLabel={rejecting ? "确认拒绝" : "拒绝"}
disabledReason={admin ? undefined : "只有管理员能批准或拒绝"}
result={
status === "rejected" && decidedReason
? `已拒绝:${decidedReason}`
: a?.decidedAt
? `${status === "approved" || status === "consumed" ? "批准" : "处理"}于 ${formatDateTime(a.decidedAt)}`
: undefined
}
>
{rejecting && status === "pending" ? (
<Textarea
className="mb-2 min-h-14 text-caption"
value={reason}
maxLength={2000}
aria-label="拒绝理由"
placeholder="理由或建议(可选),智能体按它调整"
onChange={(e) => setReason(e.target.value)}
/>
) : null}
<TechInfo
items={[
{ label: "步骤", value: stepTitle ?? null },
{ label: "风险", value: RISK_LABEL[view.riskLevel] },
{ label: "计划", value: `${PLAN_LABEL[view.planType] ?? view.planType} · ${view.planId}`, wide: true },
{ label: "payload hash", value: view.payloadHash, wide: true },
{ label: "版本", value: `r${view.expectedRevision}` },
{ label: "到期", value: formatDateTime(view.expiresAt) },
{ label: "审批 ID", value: view.approvalId, wide: true },
]}
/>
</ApprovalCard>
);
}