Files
UHD_Media_Tool/apps/web/app/components/agent/agent-interaction.tsx
T
rootandClaude Opus 5.5 417af9f253 fix(web): 智能体汇报展示层兜底与进度口径(FIX-7)
- 汇报正文里的 UUID、能力 ID、原因码展示时换成名称和人话,原文收进「正文原文」,历史不改
- 步骤进度统一 stepProgress:分子只数完成,跳过单独计(「4/6 · 跳过 2」)
- 进度行不再把失败的调用算进动作计数(3 次被拒的派发曾写成「派出了 5 个子会话」);
  实际结果行按会话树显示派出的子会话数
- 会话标题里的 ISO 时间与 download_failed 等码转人话

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 00:00:03 +08:00

385 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 { 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<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 progress = stepProgress(plan.steps);
const failed = progress.failed > 0;
if (compact) {
const pct = progress.pct;
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">
步骤 {progress.done}/{progress.total}
</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{progress.status}</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)}
count={progress.count}
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>
);
}