- 汇报正文里的 UUID、能力 ID、原因码展示时换成名称和人话,原文收进「正文原文」,历史不改 - 步骤进度统一 stepProgress:分子只数完成,跳过单独计(「4/6 · 跳过 2」) - 进度行不再把失败的调用算进动作计数(3 次被拒的派发曾写成「派出了 5 个子会话」); 实际结果行按会话树显示派出的子会话数 - 会话标题里的 ISO 时间与 download_failed 等码转人话 Co-Authored-By: Claude Opus 5.5 <[email protected]>
385 lines
16 KiB
TypeScript
385 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|